Skip to main content

yog_inventory/
lib.rs

1//! yog-inventory — real Minecraft Container/Menu screens for Yog mods.
2//!
3//! Unlike `yog-ui` (a HUD-drawn overlay screen with no real item slots), this
4//! crate describes actual vanilla-style inventory screens: a block's own
5//! slots plus, optionally, the player's own inventory grid underneath —
6//! backed by a real Minecraft `BlockEntity`/`AbstractContainerMenu` on the
7//! Java side, with real drag-and-drop and network sync for free.
8//!
9//! See `DESIGN.md` for the full architecture and phased plan. This crate
10//! currently covers the data model (phase 2); the Java-side `BlockEntity`/
11//! `Menu`/`Screen` plumbing lands in later phases.
12
13use serde::Deserialize;
14
15/// Pixel position of a single slot in vanilla GUI coordinate space (the same
16/// space vanilla screens use: origin at the panel's top-left corner, one
17/// vanilla slot = 18×18px).
18#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
19pub struct SlotLayout {
20    pub x: f32,
21    pub y: f32,
22}
23
24/// Vanilla spacing between adjacent slots in the default grid.
25pub const SLOT_SIZE: f32 = 18.0;
26
27/// Default position of the player's main inventory (3×9) relative to the
28/// panel's top-left corner — matches vanilla furnace/chest-style screens.
29pub const DEFAULT_PLAYER_INV_OFFSET: (f32, f32) = (8.0, 84.0);
30/// Extra vertical gap between the player's main inventory and their hotbar,
31/// on top of the normal per-row `SLOT_SIZE` spacing (also a vanilla convention).
32pub const PLAYER_INV_TO_HOTBAR_GAP: f32 = 4.0;
33
34/// Describes one inventory-backed screen: how many slots a mod block has,
35/// where they sit, whether the player's own inventory is appended below,
36/// and which background texture to draw (vanilla default, or a mod's own).
37#[derive(Debug, Clone)]
38pub struct InventoryDef {
39    pub id: String,
40    pub slot_count: usize,
41    /// Per-slot pixel positions. Empty = auto-generate a default grid (see
42    /// [`InventoryDef::default_grid`]).
43    pub layout: Vec<SlotLayout>,
44    /// Appends the player's main inventory (3×9) + hotbar (9) below the
45    /// custom slots — no armor, no offhand. Native vanilla slot rendering.
46    pub include_player_inventory: bool,
47    pub player_inv_offset: (f32, f32),
48    /// `None` = default vanilla-style panel texture.
49    pub background_texture: Option<String>,
50    pub title: String,
51    /// Screen size to center the container on — `None` uses the vanilla
52    /// default (176×166). Set this when a custom `on_ui_render` overlay
53    /// draws decoration at a different size, so the real vanilla `Slot`s
54    /// (positioned by `layout`/`player_inv_offset` relative to the
55    /// container's top-left corner) land under that decoration instead of
56    /// the two disagreeing about where the top-left corner even is.
57    pub background_size: Option<(f32, f32)>,
58}
59
60impl InventoryDef {
61    pub fn new(id: impl Into<String>, slot_count: usize) -> Self {
62        Self {
63            id: id.into(),
64            slot_count,
65            layout: Vec::new(),
66            include_player_inventory: true,
67            player_inv_offset: DEFAULT_PLAYER_INV_OFFSET,
68            background_texture: None,
69            title: String::new(),
70            background_size: None,
71        }
72    }
73
74    /// Explicit per-slot pixel positions, overriding the default grid.
75    pub fn layout(mut self, layout: Vec<SlotLayout>) -> Self {
76        self.layout = layout;
77        self
78    }
79
80    /// Remap the slot grid from a JSON array of `{"x":.., "y":..}` objects —
81    /// one entry per slot, in slot-index order. Lets users/mod-packs override
82    /// the layout without recompiling. Invalid or short JSON is ignored
83    /// (falls back to whatever layout was set before, or the default grid).
84    pub fn layout_from_json(mut self, json: &str) -> Self {
85        if let Ok(parsed) = serde_json::from_str::<Vec<SlotLayout>>(json) {
86            self.layout = parsed;
87        }
88        self
89    }
90
91    /// Whether the player's main inventory + hotbar (no armor/offhand) is
92    /// appended below the custom slots. Default: `true`.
93    pub fn include_player_inventory(mut self, v: bool) -> Self {
94        self.include_player_inventory = v;
95        self
96    }
97
98    pub fn player_inv_offset(mut self, x: f32, y: f32) -> Self {
99        self.player_inv_offset = (x, y);
100        self
101    }
102
103    /// Custom background texture (resource path); `None` keeps the default
104    /// vanilla-style panel.
105    pub fn background_texture(mut self, path: impl Into<String>) -> Self {
106        self.background_texture = Some(path.into());
107        self
108    }
109
110    /// Size (in GUI pixels) a custom `on_ui_render` overlay draws its
111    /// decoration at — used to center the real vanilla container on the
112    /// same footprint so its `Slot`s land under that decoration. Leave
113    /// unset to keep the vanilla default 176×166 centering.
114    pub fn background_size(mut self, w: f32, h: f32) -> Self {
115        self.background_size = Some((w, h));
116        self
117    }
118
119    pub fn title(mut self, title: impl Into<String>) -> Self {
120        self.title = title.into();
121        self
122    }
123
124    /// Resolve the effective slot layout: the explicit one if set, otherwise
125    /// a default vanilla-style grid (9 columns, wrapping downward, 18px
126    /// spacing, starting at (8, 18) — same convention as vanilla containers).
127    pub fn resolved_layout(&self) -> Vec<SlotLayout> {
128        if !self.layout.is_empty() {
129            return self.layout.clone();
130        }
131        Self::default_grid(self.slot_count)
132    }
133
134    /// Default vanilla-style grid: up to 9 columns, 18px spacing, starting at
135    /// (8, 18) — leaves room for a title above, matches vanilla containers.
136    pub fn default_grid(slot_count: usize) -> Vec<SlotLayout> {
137        const COLS: usize = 9;
138        (0..slot_count)
139            .map(|i| {
140                let col = (i % COLS) as f32;
141                let row = (i / COLS) as f32;
142                SlotLayout {
143                    x: 8.0 + col * SLOT_SIZE,
144                    y: 18.0 + row * SLOT_SIZE,
145                }
146            })
147            .collect()
148    }
149}
150
151/// Encode an [`InventoryDef`]'s slot layout as `"x:y,x:y,..."` — the
152/// wire format used when handing the layout to the Java host (mirrors how
153/// `BlockDef::connect_groups` is comma-joined for its ABI struct).
154pub fn encode_layout(layout: &[SlotLayout]) -> String {
155    layout
156        .iter()
157        .map(|s| format!("{}:{}", s.x, s.y))
158        .collect::<Vec<_>>()
159        .join(",")
160}
161
162/// Inverse of [`encode_layout`]. Malformed entries are skipped.
163pub fn decode_layout(s: &str) -> Vec<SlotLayout> {
164    if s.is_empty() {
165        return Vec::new();
166    }
167    s.split(',')
168        .filter_map(|pair| {
169            let (x, y) = pair.split_once(':')?;
170            Some(SlotLayout {
171                x: x.parse().ok()?,
172                y: y.parse().ok()?,
173            })
174        })
175        .collect()
176}
177
178// ── Client-side "currently open menu" snapshot ───────────────────────────────
179//
180// A mod that wants to decorate its own `yog-ui` widget tree with real slot
181// contents (see `yog-ui`'s `inv_slot` widget — purely data-driven, it has no
182// idea what "the currently open menu" even is) needs to read, on the client
183// render thread, what's actually in that menu's slots right now. A plain
184// Rust `static` cannot carry that across the mod/runtime boundary: the
185// runtime and each mod's own dynamic library each get their own separate
186// copy of any crate-level static, so writes on one side are invisible on
187// the other (this is what broke the original design).
188//
189// Instead, the runtime resolves and calls `yog_inventory_set_snapshot` — an
190// exported symbol every mod that depends on this crate carries automatically
191// — once per frame, right before invoking `on_ui_render` handlers, handing
192// over a fresh pointer to THIS mod's own copy of the `SNAPSHOT` static below.
193// Resolved the same way the runtime already resolves `yog_mod_register` /
194// `__yog_resolve_imports_final` on each loaded mod's library.
195
196use std::sync::atomic::{AtomicPtr, Ordering};
197use yog_abi::YogStr;
198
199/// Capacity of [`YogInvSnapshotRaw::slots`] — covers the largest realistic
200/// inventory screen (a mod's own slots + player main inventory + hotbar).
201pub const MAX_SNAPSHOT_SLOTS: usize = 64;
202
203/// One inventory slot's raw wire data for [`YogInvSnapshotRaw`].
204#[repr(C)]
205#[derive(Copy, Clone)]
206pub struct YogInvSlotRaw {
207    /// Empty (`len == 0`) if the slot itself is empty.
208    pub item_id: YogStr,
209    pub count: u32,
210    /// Screen-space pixel position.
211    pub x: i32,
212    pub y: i32,
213}
214
215impl YogInvSlotRaw {
216    pub const EMPTY: Self = Self {
217        item_id: YogStr::EMPTY,
218        count: 0,
219        x: 0,
220        y: 0,
221    };
222}
223
224/// Raw per-frame snapshot of the currently-open inventory screen, built and
225/// owned by the runtime, valid only for the duration of the render call that
226/// hands it over.
227#[repr(C)]
228#[derive(Copy, Clone)]
229pub struct YogInvSnapshotRaw {
230    pub slot_count: u32,
231    pub slots: [YogInvSlotRaw; MAX_SNAPSHOT_SLOTS],
232}
233
234impl YogInvSnapshotRaw {
235    pub const EMPTY: Self = Self {
236        slot_count: 0,
237        slots: [YogInvSlotRaw::EMPTY; MAX_SNAPSHOT_SLOTS],
238    };
239}
240
241static SNAPSHOT: AtomicPtr<YogInvSnapshotRaw> = AtomicPtr::new(std::ptr::null_mut());
242
243/// Called by the runtime once per frame, before running `on_ui_render`
244/// handlers, to refresh this mod's view of the currently-open inventory
245/// screen. Not meant to be called by mod code directly.
246#[doc(hidden)]
247#[no_mangle]
248pub extern "C" fn yog_inventory_set_snapshot(snapshot: *const YogInvSnapshotRaw) {
249    SNAPSHOT.store(snapshot as *mut _, Ordering::Release);
250}
251
252/// One inventory slot's content, as of the last snapshot.
253#[derive(Debug, Clone, Default)]
254pub struct SlotView {
255    /// `"namespace:item_id"` — empty if the slot is empty.
256    pub item_id: String,
257    pub count: u32,
258    /// Screen-space pixel position.
259    pub x: i32,
260    pub y: i32,
261}
262
263/// Number of slots in the currently-open inventory screen (0 if none is open).
264pub fn current_slot_count() -> usize {
265    let ptr = SNAPSHOT.load(Ordering::Acquire);
266    if ptr.is_null() {
267        return 0;
268    }
269    unsafe { &*ptr }.slot_count as usize
270}
271
272/// Slot `index`'s content right now, or `None` if out of range.
273pub fn current_slot(index: usize) -> Option<SlotView> {
274    let ptr = SNAPSHOT.load(Ordering::Acquire);
275    if ptr.is_null() {
276        return None;
277    }
278    let snap = unsafe { &*ptr };
279    if index >= snap.slot_count as usize {
280        return None;
281    }
282    let raw = &snap.slots[index];
283    let item_id = if raw.item_id.is_empty() {
284        String::new()
285    } else {
286        unsafe { raw.item_id.as_str() }.to_owned()
287    };
288    Some(SlotView {
289        item_id,
290        count: raw.count,
291        x: raw.x,
292        y: raw.y,
293    })
294}