Skip to main content

yog_book/
layout.rs

1//! Recipe layouts: how a kind of recipe is shown — in books and in
2//! Yog-IDLE's recipe editor. The game's kinds have built-in layouts
3//! (Patchouli's: the crafting grid, the furnace strip, the smithing
4//! strip, …); a mod lays out its own kinds, or the game's, with a `.yui`
5//! component — drawn and arranged in the screen designer like any UI:
6//!
7//! ```xml
8//! <Component name="InfusionLayout" recipe_layout="infusion" width="96" height="44">
9//!     <Image source="mymod:gui/infusion" uv="0 0 96 44"/>
10//!     <RecipeSlot slot="ring" x="4" y="4" frame="true"/>
11//!     <RecipeSlot slot="ring" x="4" y="24" frame="true"/>
12//!     <RecipeSlot slot="core" x="40" y="14" frame="true"/>
13//!     <ItemSlot item="minecraft:beacon" x="40" y="0"/>
14//!     <RecipeResult x="76" y="14"/>
15//!     <RecipeCount x="78" y="32"/>
16//!     <Label text="{x:Bind time} ticks" x="40" y="34"/>
17//! </Component>
18//! ```
19//!
20//! `recipe_layout` names the kinds it shows (several: space-separated):
21//! the mod's own (`infusion`, its namespace added) or the game's
22//! (`shaped`, `smelting`, …). Positions are GUI pixels from the layout's
23//! top-left; an item is 16×16. A slot's places take its ingredients in
24//! order (`index` to say which). The game's kinds name their slots:
25//! `grid` (crafting, 9 places; a shaped recipe's cells by position),
26//! `input` (cooking, stonecutting), `template`/`base`/`addition`
27//! (smithing). A label's bindings are the recipe's values (`time`,
28//! `experience`, or a mod kind's `values`).
29
30use std::sync::LazyLock;
31
32use serde::{Deserialize, Serialize};
33use yog_data::Schema;
34use yog_data::kinds::Item;
35use yog_data::with::RefTo;
36
37/// How one or more kinds of recipe are shown.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Schema)]
39pub struct RecipeLayout {
40    /// The recipe kinds it shows: the game's (`shaped`, `smelting`, …) or a mod's (`mymod:infusion`).
41    pub kinds: Vec<String>,
42    /// Width and height in GUI pixels.
43    pub size: [f32; 2],
44    /// What's drawn, in order.
45    #[serde(default)]
46    #[data(default)]
47    pub parts: Vec<LayoutPart>,
48}
49
50/// One part of a [`RecipeLayout`].
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Schema)]
52#[serde(tag = "type", rename_all = "snake_case")]
53#[data(tag = "type")]
54pub enum LayoutPart {
55    /// A region of an image (a Yog image source).
56    Image {
57        source: String,
58        /// `[u, v, width, height]` in the image's pixels.
59        uv: [f32; 4],
60        #[serde(default)]
61        #[data(default)]
62        at: [f32; 2],
63    },
64    /// Where a slot's ingredients go, in order.
65    Slots {
66        slot: String,
67        at: Vec<[f32; 2]>,
68        /// A slot frame behind each (for layouts without their own picture).
69        #[serde(default)]
70        #[data(default)]
71        frame: bool,
72    },
73    /// The recipe's result.
74    Result {
75        at: [f32; 2],
76        #[serde(default)]
77        #[data(default)]
78        frame: bool,
79    },
80    /// The result's count, when more than one.
81    Count { at: [f32; 2] },
82    /// An item drawn as it is: the station (a furnace, the crafting table).
83    Item {
84        #[data(with = "RefTo<Item>")]
85        item: String,
86        at: [f32; 2],
87    },
88    /// Text; `{name}` is the recipe's value of that name.
89    Text { text: String, at: [f32; 2] },
90}
91
92/// The image the built-in layouts are drawn from (Patchouli's crafting sheet).
93pub const SHEET: &str = crate::renderer::CRAFTING_SHEET;
94
95/// A slot frame on [`SHEET`], 24×24, the item inside at +4,+4.
96pub const FRAME_UV: [f32; 4] = [83.0, 71.0, 24.0, 24.0];
97
98/// The game's kinds' layouts.
99pub static BUILT_IN: LazyLock<Vec<RecipeLayout>> = LazyLock::new(|| {
100    yog_data::from_text::<Vec<RecipeLayout>>(include_str!("../assets/layouts.ydata"))
101        .unwrap_or_else(|e| panic!("yog-book's built-in layouts: {e}"))
102        .value
103});
104
105/// The layout for recipes of `kind`: one of `own` (a book's), else built in.
106pub fn find<'a>(own: &'a [RecipeLayout], kind: &str) -> Option<&'a RecipeLayout> {
107    own.iter()
108        .chain(BUILT_IN.iter())
109        .find(|l| l.kinds.iter().any(|k| k == kind))
110}
111
112// ── From `.yui` ────────────────────────────────────────────────────────────────
113
114use crate::ui::Element;
115
116/// Whether `root` is a recipe layout (`<Component recipe_layout="…">`).
117pub fn is_recipe_layout(root: &Element) -> bool {
118    root.name == "Component" && root.attr("recipe_layout").is_some()
119}
120
121fn err<T>(el: &Element, msg: impl std::fmt::Display) -> Result<T, String> {
122    Err(format!("line {}: <{}>: {msg}", el.line, el.name))
123}
124
125fn num(el: &Element, key: &str, default: Option<f32>) -> Result<f32, String> {
126    match el.attr(key) {
127        Some(v) => v.trim().parse::<f32>().or_else(|_| err(el, format_args!("{key}=\"{v}\": a number of GUI pixels"))),
128        None => default.map(Ok).unwrap_or_else(|| err(el, format_args!("needs {key}=\"…\""))),
129    }
130}
131
132fn at(el: &Element) -> Result<[f32; 2], String> {
133    Ok([num(el, "x", Some(0.0))?, num(el, "y", Some(0.0))?])
134}
135
136fn flag(el: &Element, key: &str) -> bool {
137    el.attr(key).is_some_and(|v| v.trim() == "true")
138}
139
140/// The game's kinds of recipe (those with built-in layouts).
141pub fn game_kinds() -> impl Iterator<Item = &'static str> {
142    BUILT_IN.iter().flat_map(|l| l.kinds.iter().map(String::as_str))
143}
144
145/// `root` (a `<Component recipe_layout="…">`) as a layout; `ns`: the mod's
146/// namespace, for its own kinds written without one.
147pub fn compile(root: &Element, ns: &str) -> Result<RecipeLayout, String> {
148    let kinds: Vec<String> = root
149        .attr("recipe_layout")
150        .unwrap_or_default()
151        .split([' ', ','])
152        .filter(|k| !k.is_empty())
153        .map(|k| if k.contains(':') || game_kinds().any(|g| g == k) { k.to_string() } else { format!("{ns}:{k}") })
154        .collect();
155    if kinds.is_empty() {
156        return err(root, "recipe_layout=\"…\": the kinds it shows — the mod's own or the game's");
157    }
158    let size = [num(root, "width", None)?, num(root, "height", None)?];
159    // The elements may sit in one <Canvas>, as a screen's do.
160    let mut children: Vec<&Element> = Vec::new();
161    for c in &root.children {
162        if c.name == "Canvas" { children.extend(c.children.iter()) } else { children.push(c) }
163    }
164    let mut parts: Vec<LayoutPart> = Vec::new();
165    // A slot's places (index, where), gathered into its one part, and its frame.
166    type Places = Vec<(usize, [f32; 2])>;
167    let mut places: Vec<(String, Places, bool)> = Vec::new();
168    for el in children {
169        match el.name.as_str() {
170            "Image" => {
171                let source = el.attr("source").map(Ok).unwrap_or_else(|| err(el, "needs source=\"…\""))?.to_string();
172                let uv = match el.attr("uv") {
173                    Some(v) => {
174                        let n: Vec<f32> = v.split_whitespace().filter_map(|x| x.parse().ok()).collect();
175                        let [u, v, w, h] = n[..] else { return err(el, format_args!("uv=\"{v}\": u v width height")) };
176                        [u, v, w, h]
177                    }
178                    None => [0.0, 0.0, num(el, "w", None)?, num(el, "h", None)?],
179                };
180                parts.push(LayoutPart::Image { source, uv, at: at(el)? });
181            }
182            "RecipeSlot" => {
183                let slot = el.attr("slot").map(Ok).unwrap_or_else(|| err(el, "needs slot=\"…\" (the kind's slot)"))?.to_string();
184                let index = match el.attr("index") {
185                    Some(i) => i.trim().parse::<usize>().or_else(|_| err(el, format_args!("index=\"{i}\": 0, 1, …")))?,
186                    None => places.iter().find(|(s, ..)| *s == slot).map_or(0, |(_, p, _)| p.iter().map(|(i, _)| i + 1).max().unwrap_or(0)),
187                };
188                match places.iter_mut().find(|(s, ..)| *s == slot) {
189                    Some((_, p, frame)) => {
190                        if p.iter().any(|(i, _)| *i == index) {
191                            return err(el, format_args!("{slot}'s place {index} is laid out twice"));
192                        }
193                        p.push((index, at(el)?));
194                        *frame |= flag(el, "frame");
195                    }
196                    None => {
197                        places.push((slot.clone(), vec![(index, at(el)?)], flag(el, "frame")));
198                        parts.push(LayoutPart::Slots { slot, at: Vec::new(), frame: false });
199                    }
200                }
201            }
202            "RecipeResult" => parts.push(LayoutPart::Result { at: at(el)?, frame: flag(el, "frame") }),
203            "RecipeCount" => parts.push(LayoutPart::Count { at: at(el)? }),
204            "ItemSlot" => {
205                let item = el.attr("item").map(Ok).unwrap_or_else(|| err(el, "needs item=\"…\" (the station: minecraft:furnace)"))?;
206                let item = item.trim().trim_start_matches("@item:").to_string();
207                let item = if item.contains(':') { item } else { format!("minecraft:{item}") };
208                parts.push(LayoutPart::Item { item, at: at(el)? });
209            }
210            "Label" => {
211                let text = el.attr("text").map(Ok).unwrap_or_else(|| err(el, "needs text=\"…\""))?;
212                parts.push(LayoutPart::Text { text: text.replace("{x:Bind ", "{"), at: at(el)? });
213            }
214            other => {
215                return err(el, format_args!("not a recipe layout's element (`{other}`) — Image, RecipeSlot, RecipeResult, RecipeCount, ItemSlot, Label"));
216            }
217        }
218    }
219    for part in &mut parts {
220        if let LayoutPart::Slots { slot, at, frame } = part
221            && let Some((_, p, f)) = places.iter().find(|(s, ..)| s == slot)
222        {
223            let mut p = p.clone();
224            p.sort_by_key(|(i, _)| *i);
225            if let Some(gap) = p.iter().enumerate().find(|(n, (i, _))| n != i) {
226                return Err(format!("{slot}: place {} isn't laid out (a slot's places are 0, 1, … in a row)", gap.0));
227            }
228            *at = p.into_iter().map(|(_, a)| a).collect();
229            *frame = *f;
230        }
231    }
232    Ok(RecipeLayout { kinds, size, parts })
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn every_game_kind_has_a_built_in_layout() {
241        for kind in [
242            "shaped",
243            "shapeless",
244            "smelting",
245            "blasting",
246            "smoking",
247            "campfire_cooking",
248            "stonecutting",
249            "smithing",
250        ] {
251            assert!(find(&[], kind).is_some(), "{kind}");
252        }
253    }
254
255    #[test]
256    fn a_mod_layout_reads_and_wins() {
257        let text = r#"{ kinds: ["shaped", "mymod:infusion"], size: [96, 44], parts: [
258            { type: "slots", slot: "core", at: [[40, 14]], frame: true },
259            { type: "item", item: "@item:minecraft:beacon", at: [0, 0] },
260        ] }"#;
261        let l = yog_data::from_text::<RecipeLayout>(text).unwrap().value;
262        assert!(matches!(&l.parts[1], LayoutPart::Item { item, .. } if item == "minecraft:beacon"));
263        let own = [l];
264        assert_eq!(find(&own, "shaped").unwrap().size, [96.0, 44.0]);
265        assert_eq!(find(&own, "mymod:infusion").unwrap().kinds.len(), 2);
266        let e = yog_data::from_text::<RecipeLayout>(
267            r#"{ kinds: [], size: [1, 1], parts: [{ type: "slot", at: [] }] }"#,
268        )
269        .unwrap_err();
270        assert!(
271            e.to_string()
272                .contains("unknown `slot` — did you mean `slots`?"),
273            "{e}"
274        );
275    }
276
277    #[test]
278    fn a_layout_compiles_from_yui() {
279        let el = crate::ui::parse(
280            r#"<Component name="Infusion" recipe_layout="infusion shaped" width="96" height="44">
281                <Canvas>
282                    <Image source="mymod:gui/infusion" uv="0 0 96 44"/>
283                    <RecipeSlot slot="ring" x="4" y="24" index="1" frame="true"/>
284                    <RecipeSlot slot="ring" x="4" y="4" index="0"/>
285                    <ItemSlot item="beacon" x="40" y="0"/>
286                    <RecipeResult x="76" y="14"/>
287                    <Label text="{x:Bind time} ticks" x="40" y="34"/>
288                </Canvas>
289            </Component>"#,
290        )
291        .unwrap();
292        assert!(is_recipe_layout(&el));
293        let l = compile(&el, "mymod").unwrap();
294        assert_eq!(l.kinds, ["mymod:infusion", "shaped"]);
295        assert_eq!(l.size, [96.0, 44.0]);
296        assert!(matches!(&l.parts[1], LayoutPart::Slots { slot, at, frame: true } if slot == "ring" && at == &[[4.0, 4.0], [4.0, 24.0]]));
297        assert!(matches!(&l.parts[2], LayoutPart::Item { item, .. } if item == "minecraft:beacon"));
298        assert!(matches!(&l.parts[4], LayoutPart::Text { text, .. } if text == "{time} ticks"));
299
300        let bad = crate::ui::parse(r#"<Component recipe_layout="x" width="1" height="1"><Button/></Component>"#).unwrap();
301        assert!(compile(&bad, "m").unwrap_err().contains("not a recipe layout's element (`Button`)"));
302        let gap = crate::ui::parse(r#"<Component recipe_layout="x" width="1" height="1"><RecipeSlot slot="a" index="1"/></Component>"#).unwrap();
303        assert!(compile(&gap, "m").unwrap_err().contains("place 0 isn't laid out"));
304    }
305}