mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Paintings: the item's variant is placed and named in its tooltip
A painting item with a `painting/variant` component (every one but the first in the creative tab) placed a random painting and showed only "Painting". HangingEntityItem.useOn runs Painting.create's random pick, then EntityType.createDefaultStackConfig applies the stack's components (Painting.applyImplicitComponents sets the variant) and the painting must still survive. HangingEntityItem.appendHoverText adds the variant's title, author and size, or "Random variant" in a creative tab without one. The component also crosses the wire now (a painting_variant holder, component 109): a creative client's pick reaches the server, and the server's stacks keep their variant on the client. Fixes #3503 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
11494e17f9
commit
daa5415724
6 files changed
+197
-7
No files matched your search
@@ -11346,6 +11346,7 @@ impl Game<'_> {
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
let mut hovered: Option<ItemStack> = None;
|
||||
let mut hovered_creative = false;
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
@@ -11396,6 +11397,8 @@ impl Game<'_> {
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = Some(stack.clone());
|
||||
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
|
||||
hovered_creative = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11450,7 +11453,8 @@ impl Game<'_> {
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(item) = hovered
|
||||
{
|
||||
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
|
||||
let (x, y) = self.cursor;
|
||||
self.draw_flagged_stack_tooltip(batch, &item, hovered_creative, x, y);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Paintings: `HangingEntityItem.useOn` placement with `Painting.create`'s
|
||||
//! variant choice, `HangingEntity.survives` checks every 100 ticks,
|
||||
//! breaking by attack (`dropItem`), saving, and `PaintingRenderer` quads.
|
||||
//! variant choice (or the stack's `painting/variant`), `HangingEntity.survives`
|
||||
//! checks every 100 ticks, breaking by attack (`dropItem`), saving, and
|
||||
//! `PaintingRenderer` quads.
|
||||
use super::{Game, Hit};
|
||||
use horizon_client::{entities::Painting, items::ItemStack, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
@@ -16,6 +17,9 @@ pub(super) struct Variant {
|
||||
pub asset: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
/// `PaintingVariant.title` and `author`: text components.
|
||||
pub title: Option<serde_json::Value>,
|
||||
pub author: Option<serde_json::Value>,
|
||||
}
|
||||
/// Variants by id plus the `#minecraft:placeable` list.
|
||||
#[derive(Default)]
|
||||
@@ -48,6 +52,8 @@ impl PaintingVariants {
|
||||
.to_owned(),
|
||||
width: v["width"].as_i64().unwrap_or(1) as i32,
|
||||
height: v["height"].as_i64().unwrap_or(1) as i32,
|
||||
title: v.get("title").cloned(),
|
||||
author: v.get("author").cloned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -64,6 +70,11 @@ impl PaintingVariants {
|
||||
pub fn get(&self, id: &str) -> Option<&Variant> {
|
||||
self.all.iter().find(|v| &*v.id == id)
|
||||
}
|
||||
/// The variant a stack's `painting/variant` component names.
|
||||
pub fn of_stack(&self, stack: &ItemStack) -> Option<&Variant> {
|
||||
let id = horizon_client::save::text(stack.data()?.other("minecraft:painting/variant"))?;
|
||||
self.get(&horizon_world::datafix::dynamic::namespaced(&id))
|
||||
}
|
||||
}
|
||||
fn overlaps(a: ([f64; 3], [f64; 3]), b: ([f64; 3], [f64; 3])) -> bool {
|
||||
(0..3).all(|i| a.0[i] < b.1[i] && b.0[i] < a.1[i])
|
||||
@@ -154,6 +165,15 @@ impl Game<'_> {
|
||||
.unwrap_or(0) as usize;
|
||||
let v = &fitting[pick];
|
||||
(candidate.variant, candidate.width, candidate.height) = (v.id.clone(), v.width, v.height);
|
||||
// EntityType.createDefaultStackConfig -> Painting.applyImplicitComponents:
|
||||
// the stack's `painting/variant` replaces the random pick, and that
|
||||
// painting has to survive where it goes (else the use is CONSUME).
|
||||
if let Some(v) = self.painting_variants.of_stack(stack).cloned() {
|
||||
(candidate.variant, candidate.width, candidate.height) = (v.id, v.width, v.height);
|
||||
if !self.painting_survives(&candidate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let (lo, hi) = candidate.bounds();
|
||||
let at = [
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
|
||||
@@ -1696,7 +1696,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
self.stack_tooltip_lines(stack, false)
|
||||
self.stack_tooltip_lines(stack, false, false)
|
||||
}
|
||||
/// `ItemStack.getRarity`'s colour for the name line and a `[name]` link.
|
||||
fn rarity_color(&self, stack: &ItemStack) -> u32 {
|
||||
@@ -1743,11 +1743,13 @@ impl Game<'_> {
|
||||
}
|
||||
/// `ItemStack.getTooltipLines`. A `nested` stack is a crossbow's
|
||||
/// projectile: `ChargedProjectiles.addProjectileTooltip` takes its details
|
||||
/// without the name, the player and the advanced flag.
|
||||
/// without the name, the player and the advanced flag. `creative` is
|
||||
/// `TooltipFlag.isCreative`: a creative tab's own slot.
|
||||
fn stack_tooltip_lines(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
nested: bool,
|
||||
creative: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.res.stations().enchantments;
|
||||
let color = self.rarity_color(stack);
|
||||
@@ -1775,6 +1777,27 @@ impl Game<'_> {
|
||||
for (text, c) in horizon_client::item_tooltip::smithing_template_lines(stack, &translate) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// HangingEntityItem.appendHoverText: a painting's variant, its title,
|
||||
// author and size, or in a creative tab "Random variant".
|
||||
if &*stack.item == "minecraft:painting" && display.shows("minecraft:painting/variant") {
|
||||
use horizon_client::text::Text;
|
||||
if let Some(v) = self.painting_variants.of_stack(stack) {
|
||||
for part in [&v.title, &v.author].into_iter().flatten() {
|
||||
let text = horizon_client::component::to_text(part, &mut |_| Text::empty());
|
||||
lines.push((text, u32::MAX));
|
||||
}
|
||||
let size = [v.width, v.height].map(|n| Text::literal(n.to_string()));
|
||||
lines.push((Text::translate("painting.dimensions", &size), u32::MAX));
|
||||
} else if creative
|
||||
&& stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:painting/variant"))
|
||||
.is_none()
|
||||
{
|
||||
let random = Text::translate("painting.random", &[]);
|
||||
lines.push((random.with_color(horizon_client::text::GRAY), u32::MAX));
|
||||
}
|
||||
}
|
||||
// InstrumentComponent.addToTooltip.
|
||||
if let Some(text) =
|
||||
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
|
||||
@@ -1848,7 +1871,7 @@ impl Game<'_> {
|
||||
)
|
||||
};
|
||||
lines.push((line, u32::MAX));
|
||||
for (text, c) in self.stack_tooltip_lines(projectile, true) {
|
||||
for (text, c) in self.stack_tooltip_lines(projectile, true, false) {
|
||||
// `literal(" ").append(line).withStyle(GRAY)`: the line's
|
||||
// own colours win over the gray.
|
||||
let rgb = if c == u32::MAX {
|
||||
@@ -2245,7 +2268,19 @@ impl Game<'_> {
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let lines = Self::resolved(&self.stack_tooltip(stack));
|
||||
self.draw_flagged_stack_tooltip(batch, stack, false, x, y);
|
||||
}
|
||||
/// [`Self::draw_stack_tooltip`] with `TooltipFlag.isCreative` set when
|
||||
/// `creative` (a creative tab's own slot).
|
||||
pub(super) fn draw_flagged_stack_tooltip(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
creative: bool,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let lines = Self::resolved(&self.stack_tooltip_lines(stack, false, creative));
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,13 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
};
|
||||
Tag::List(layers)
|
||||
}
|
||||
// `painting/variant`: a synced registry entry, as trims are.
|
||||
Component::PaintingVariant(id) => {
|
||||
let Some(name) = synced.name("minecraft:painting_variant", *id) else {
|
||||
continue;
|
||||
};
|
||||
Tag::String(name.into())
|
||||
}
|
||||
// A shield's `base_color`: the dye's id.
|
||||
Component::Int(v) if *kind == component::BASE_COLOR => Tag::String(dye_name(*v).into()),
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
@@ -585,4 +592,66 @@ mod tests {
|
||||
Some("minecraft:swiftness")
|
||||
);
|
||||
}
|
||||
|
||||
/// A painting's `painting/variant` (a `painting_variant` holder, id 109)
|
||||
/// crosses the wire both ways: a server's creative slot gets the variant
|
||||
/// its client picked, and a client's tooltip names the server's.
|
||||
#[test]
|
||||
fn a_painting_keeps_its_variant_across_the_wire() {
|
||||
use horizon_protocol::buf::{Codec, Reader, Writer};
|
||||
use horizon_protocol::item::Slot;
|
||||
let mut components = vec![String::new(); 110];
|
||||
components[109] = "minecraft:painting/variant".into();
|
||||
let items = ["minecraft:air", "minecraft:painting"];
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".to_owned(),
|
||||
Ids::from_names(items.iter().map(|s| s.to_string()).collect()),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".to_owned(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let variants = ["minecraft:alban", "minecraft:kebab"];
|
||||
let dynamic = Dynamic {
|
||||
registries: [(
|
||||
"minecraft:painting_variant".to_owned(),
|
||||
variants.iter().map(|v| (v.to_string(), None)).collect(),
|
||||
)]
|
||||
.into(),
|
||||
};
|
||||
// ItemStack.OPTIONAL_STREAM_CODEC: count 1, the painting, one added
|
||||
// component (109: the reference to kebab, its id plus one), none removed.
|
||||
let vanilla = [1u8, 1, 1, 0, 109, 2];
|
||||
let stack = Slot::read(&mut Reader::new(&vanilla)).unwrap().unwrap();
|
||||
let back = from_network(&stack, &builtin, &dynamic);
|
||||
let variant = |s: &ItemStack| {
|
||||
s.data()
|
||||
.and_then(|d| d.other("minecraft:painting/variant"))
|
||||
.cloned()
|
||||
};
|
||||
assert_eq!(variant(&back), Some(Tag::String("minecraft:kebab".into())));
|
||||
let mut alban = ItemStack::new("minecraft:painting", 1);
|
||||
alban.data_mut().set_other(
|
||||
"minecraft:painting/variant",
|
||||
Some(Tag::String("minecraft:alban".into())),
|
||||
);
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| items.iter().position(|i| *i == name).unwrap_or(0) as i32,
|
||||
|registry, name| dynamic.id(registry, name),
|
||||
&Some(alban.clone()),
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&wire, &mut w);
|
||||
assert_eq!(w.data, [1, 1, 1, 0, 109, 1]);
|
||||
let wire = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
variant(&from_network(&wire, &builtin, &dynamic)),
|
||||
variant(&alban)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -642,6 +642,18 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::BannerPatterns(layers),
|
||||
));
|
||||
}
|
||||
// `painting/variant`: a reference into the synced registry.
|
||||
if let Some(id) = crate::save::text(d.other("minecraft:painting/variant")).and_then(|v| {
|
||||
synced_id(
|
||||
"minecraft:painting_variant",
|
||||
&horizon_world::datafix::dynamic::namespaced(&v),
|
||||
)
|
||||
}) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::PAINTING_VARIANT,
|
||||
horizon_protocol::item::Component::PaintingVariant(id),
|
||||
));
|
||||
}
|
||||
// A shield's `base_color`.
|
||||
if let Some(id) = d.base_color.as_deref().and_then(dye_id) {
|
||||
stack.added.push((
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod component {
|
||||
pub const FIREWORKS: i32 = 71;
|
||||
pub const BLOCK_ENTITY_DATA: i32 = 62;
|
||||
pub const CONTAINER: i32 = 77;
|
||||
pub const PAINTING_VARIANT: i32 = 109;
|
||||
}
|
||||
|
||||
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
|
||||
@@ -121,6 +122,9 @@ pub enum Component {
|
||||
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
|
||||
/// registry name: that value stays [`Component::Raw`].
|
||||
BannerPatterns(Vec<(i32, i32)>),
|
||||
/// `painting/variant` naming its `painting_variant` by registry id; a
|
||||
/// direct variant stays [`Component::Raw`].
|
||||
PaintingVariant(i32),
|
||||
/// A component Horizon reads past without interpreting it: its encoded
|
||||
/// bytes, written back as they came.
|
||||
Raw(Vec<u8>),
|
||||
@@ -358,6 +362,18 @@ impl Component {
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
// Holder<PaintingVariant>: a reference's id plus one, or 0 and
|
||||
// the variant itself (no registry name: read past).
|
||||
PAINTING_VARIANT => {
|
||||
let start = r.position();
|
||||
skip::component(PAINTING_VARIANT, r)?;
|
||||
let bytes = r.since(start);
|
||||
let mut ids = Reader::new(bytes);
|
||||
match ids.varint() {
|
||||
Ok(id @ 1..) if ids.remaining() == 0 => Self::PaintingVariant(id - 1),
|
||||
_ => Self::Raw(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
// DyeColor.STREAM_CODEC: its id.
|
||||
BASE_COLOR => Self::Int(r.varint()?),
|
||||
// BannerPatternLayers.STREAM_CODEC: a list of a pattern holder (a
|
||||
@@ -408,6 +424,7 @@ impl Component {
|
||||
w.varint(material + 1);
|
||||
w.varint(pattern + 1);
|
||||
}
|
||||
Self::PaintingVariant(id) => w.varint(id + 1),
|
||||
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
|
||||
Self::Int(v) => w.varint(*v),
|
||||
Self::Unit => {}
|
||||
@@ -1055,4 +1072,37 @@ mod tests {
|
||||
assert_eq!(w.data, bytes, "component {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painting_variants_decode_by_reference() {
|
||||
// Holder<PaintingVariant>: a reference (its id plus one) is typed; a
|
||||
// direct variant (0, width, height, asset id, no title, no author)
|
||||
// is read past. Both encode back to the same bytes.
|
||||
let asset = b"minecraft:x";
|
||||
let mut direct = vec![1u8, 7, 1, 0, 109, 0, 2, 1, asset.len() as u8];
|
||||
direct.extend_from_slice(asset);
|
||||
direct.extend_from_slice(&[0, 0]);
|
||||
for (bytes, expected) in [
|
||||
(
|
||||
vec![1u8, 7, 1, 0, 109, 5],
|
||||
Some(Component::PaintingVariant(4)),
|
||||
),
|
||||
(direct, None),
|
||||
] {
|
||||
let mut r = Reader::new(&bytes);
|
||||
let stack = Slot::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
let c = stack
|
||||
.as_ref()
|
||||
.and_then(|s| s.component(component::PAINTING_VARIANT))
|
||||
.unwrap();
|
||||
match &expected {
|
||||
Some(e) => assert_eq!(c, e),
|
||||
None => assert!(matches!(c, Component::Raw(_))),
|
||||
}
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&stack, &mut w);
|
||||
assert_eq!(w.data, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user