1use std::sync::LazyLock;
31
32use serde::{Deserialize, Serialize};
33use yog_data::Schema;
34use yog_data::kinds::Item;
35use yog_data::with::RefTo;
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Schema)]
39pub struct RecipeLayout {
40 pub kinds: Vec<String>,
42 pub size: [f32; 2],
44 #[serde(default)]
46 #[data(default)]
47 pub parts: Vec<LayoutPart>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Schema)]
52#[serde(tag = "type", rename_all = "snake_case")]
53#[data(tag = "type")]
54pub enum LayoutPart {
55 Image {
57 source: String,
58 uv: [f32; 4],
60 #[serde(default)]
61 #[data(default)]
62 at: [f32; 2],
63 },
64 Slots {
66 slot: String,
67 at: Vec<[f32; 2]>,
68 #[serde(default)]
70 #[data(default)]
71 frame: bool,
72 },
73 Result {
75 at: [f32; 2],
76 #[serde(default)]
77 #[data(default)]
78 frame: bool,
79 },
80 Count { at: [f32; 2] },
82 Item {
84 #[data(with = "RefTo<Item>")]
85 item: String,
86 at: [f32; 2],
87 },
88 Block {
92 block: String,
93 at: [f32; 2],
94 #[serde(default = "icon_px")]
96 #[data(default = "icon_px")]
97 size: f32,
98 },
99 Text { text: String, at: [f32; 2] },
101}
102
103pub static BUILT_IN: LazyLock<Vec<RecipeLayout>> = LazyLock::new(|| {
105 yog_data::from_text::<Vec<RecipeLayout>>(include_str!("../../assets/layouts.ydata"))
106 .unwrap_or_else(|e| panic!("yog-formats' built-in recipe layouts: {e}"))
107 .value
108});
109
110pub fn find<'a>(own: &'a [RecipeLayout], kind: &str) -> Option<&'a RecipeLayout> {
112 own.iter()
113 .chain(BUILT_IN.iter())
114 .find(|l| l.kinds.iter().any(|k| k == kind))
115}
116
117use crate::yui::Element;
120
121pub fn is_recipe_layout(root: &Element) -> bool {
123 root.name == yog_ids::yui::COMPONENT && root.attr(yog_ids::yui_attr::RECIPE_LAYOUT).is_some()
124}
125
126fn err<T>(el: &Element, msg: impl std::fmt::Display) -> Result<T, String> {
127 Err(format!("line {}: <{}>: {msg}", el.line, el.name))
128}
129
130fn num(el: &Element, key: &str, default: Option<f32>) -> Result<f32, String> {
131 match el.attr(key) {
132 Some(v) => v.trim().parse::<f32>().or_else(|_| err(el, format_args!("{key}=\"{v}\": a number of GUI pixels"))),
133 None => default.map(Ok).unwrap_or_else(|| err(el, format_args!("needs {key}=\"…\""))),
134 }
135}
136
137fn at(el: &Element) -> Result<[f32; 2], String> {
138 Ok([num(el, "x", Some(0.0))?, num(el, "y", Some(0.0))?])
139}
140
141fn flag(el: &Element, key: &str) -> bool {
142 el.attr(key).is_some_and(|v| v.trim() == "true")
143}
144
145pub fn game_kinds() -> impl Iterator<Item = &'static str> {
147 BUILT_IN.iter().flat_map(|l| l.kinds.iter().map(String::as_str))
148}
149
150pub fn compile(root: &Element, ns: &str) -> Result<RecipeLayout, String> {
153 let kinds: Vec<String> = root
154 .attr(yog_ids::yui_attr::RECIPE_LAYOUT)
155 .unwrap_or_default()
156 .split([' ', ','])
157 .filter(|k| !k.is_empty())
158 .map(|k| if k.contains(':') || game_kinds().any(|g| g == k) { k.to_string() } else { format!("{ns}:{k}") })
159 .collect();
160 if kinds.is_empty() {
161 return err(root, "recipe_layout=\"…\": the kinds it shows — the mod's own or the game's");
162 }
163 let size = [num(root, "width", None)?, num(root, "height", None)?];
164 let mut children: Vec<&Element> = Vec::new();
166 for c in &root.children {
167 if c.name == yog_ids::yui::CANVAS { children.extend(c.children.iter()) } else { children.push(c) }
168 }
169 let mut parts: Vec<LayoutPart> = Vec::new();
170 type Places = Vec<(usize, [f32; 2])>;
172 let mut places: Vec<(String, Places, bool)> = Vec::new();
173 for el in children {
174 match el.name.as_str() {
175 yog_ids::yui::IMAGE => {
176 let source = el.attr(yog_ids::yui_attr::SOURCE).map(Ok).unwrap_or_else(|| err(el, "needs source=\"…\""))?.to_string();
177 let uv = match el.attr(yog_ids::yui_attr::UV) {
178 Some(v) => {
179 let n: Vec<f32> = v.split_whitespace().filter_map(|x| x.parse().ok()).collect();
180 let [u, v, w, h] = n[..] else { return err(el, format_args!("uv=\"{v}\": u v width height")) };
181 [u, v, w, h]
182 }
183 None => [0.0, 0.0, num(el, "w", None)?, num(el, "h", None)?],
184 };
185 parts.push(LayoutPart::Image { source, uv, at: at(el)? });
186 }
187 yog_ids::yui::RECIPE_SLOT => {
188 let slot = el.attr(yog_ids::yui_attr::SLOT).map(Ok).unwrap_or_else(|| err(el, "needs slot=\"…\" (the kind's slot)"))?.to_string();
189 let index = match el.attr(yog_ids::yui_attr::INDEX) {
190 Some(i) => i.trim().parse::<usize>().or_else(|_| err(el, format_args!("index=\"{i}\": 0, 1, …")))?,
191 None => places.iter().find(|(s, ..)| *s == slot).map_or(0, |(_, p, _)| p.iter().map(|(i, _)| i + 1).max().unwrap_or(0)),
192 };
193 match places.iter_mut().find(|(s, ..)| *s == slot) {
194 Some((_, p, frame)) => {
195 if p.iter().any(|(i, _)| *i == index) {
196 return err(el, format_args!("{slot}'s place {index} is laid out twice"));
197 }
198 p.push((index, at(el)?));
199 *frame |= flag(el, "frame");
200 }
201 None => {
202 places.push((slot.clone(), vec![(index, at(el)?)], flag(el, "frame")));
203 parts.push(LayoutPart::Slots { slot, at: Vec::new(), frame: false });
204 }
205 }
206 }
207 yog_ids::yui::RECIPE_RESULT => parts.push(LayoutPart::Result { at: at(el)?, frame: flag(el, "frame") }),
208 yog_ids::yui::RECIPE_COUNT => parts.push(LayoutPart::Count { at: at(el)? }),
209 yog_ids::yui::ITEM_SLOT => {
210 let item = el.attr(yog_ids::yui_attr::ITEM).map(Ok).unwrap_or_else(|| err(el, "needs item=\"…\" (the station: minecraft:furnace)"))?;
211 let item = yog_ids::qualify(item.trim().trim_start_matches(yog_ids::reference::ITEM));
212 parts.push(LayoutPart::Item { item, at: at(el)? });
213 }
214 "BlockSlot" => {
215 let block = el.attr(yog_ids::yui_attr::BLOCK).map(Ok).unwrap_or_else(|| err(el, "needs block=\"…\" (a block state: minecraft:brewing_stand[has_bottle_0=true])"))?;
216 let block = yog_ids::qualify(block.trim().trim_start_matches(yog_ids::reference::BLOCK));
217 let size = el.attr("size").map(|s| s.trim().parse::<f32>().map_err(|_| format!("size=\"{s}\": a number of pixels"))).transpose().or_else(|e| err(el, e))?.unwrap_or(ICON_PX);
218 parts.push(LayoutPart::Block { block, at: at(el)?, size });
219 }
220 yog_ids::yui::LABEL => {
221 let text = el.attr(yog_ids::yui_attr::TEXT).map(Ok).unwrap_or_else(|| err(el, "needs text=\"…\""))?;
222 parts.push(LayoutPart::Text { text: text.replace("{x:Bind ", "{"), at: at(el)? });
223 }
224 other => {
225 return err(el, format_args!("not a recipe layout's element (`{other}`) — Image, RecipeSlot, RecipeResult, RecipeCount, ItemSlot, BlockSlot, Label"));
226 }
227 }
228 }
229 for part in &mut parts {
230 if let LayoutPart::Slots { slot, at, frame } = part
231 && let Some((_, p, f)) = places.iter().find(|(s, ..)| s == slot)
232 {
233 let mut p = p.clone();
234 p.sort_by_key(|(i, _)| *i);
235 if let Some(gap) = p.iter().enumerate().find(|(n, (i, _))| n != i) {
236 return Err(format!("{slot}: place {} isn't laid out (a slot's places are 0, 1, … in a row)", gap.0));
237 }
238 *at = p.into_iter().map(|(_, a)| a).collect();
239 *frame = *f;
240 }
241 }
242 Ok(RecipeLayout { kinds, size, parts })
243}
244
245pub const ICON_PX: f32 = 16.0;
247
248fn icon_px() -> f32 {
249 ICON_PX
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn every_game_kind_has_a_built_in_layout() {
258 for kind in [
259 "shaped",
260 "shapeless",
261 "smelting",
262 "blasting",
263 "smoking",
264 "campfire_cooking",
265 "stonecutting",
266 "smithing",
267 "brewing",
268 ] {
269 assert!(find(&[], kind).is_some(), "{kind}");
270 }
271 }
272
273 #[test]
274 fn a_mod_layout_reads_and_wins() {
275 let text = r#"{ kinds: ["shaped", "mymod:infusion"], size: [96, 44], parts: [
276 { type: "slots", slot: "core", at: [[40, 14]], frame: true },
277 { type: "item", item: "@item:minecraft:beacon", at: [0, 0] },
278 ] }"#;
279 let l = yog_data::from_text::<RecipeLayout>(text).unwrap().value;
280 assert!(matches!(&l.parts[1], LayoutPart::Item { item, .. } if item == "minecraft:beacon"));
281 let own = [l];
282 assert_eq!(find(&own, "shaped").unwrap().size, [96.0, 44.0]);
283 assert_eq!(find(&own, "mymod:infusion").unwrap().kinds.len(), 2);
284 let e = yog_data::from_text::<RecipeLayout>(
285 r#"{ kinds: [], size: [1, 1], parts: [{ type: "slot", at: [] }] }"#,
286 )
287 .unwrap_err();
288 assert!(
289 e.to_string()
290 .contains("unknown `slot` — did you mean `slots`?"),
291 "{e}"
292 );
293 }
294
295 #[test]
296 fn a_layout_compiles_from_yui() {
297 let el = crate::yui::parse(
298 r#"<Component name="Infusion" recipe_layout="infusion shaped" width="96" height="44">
299 <Canvas>
300 <Image source="mymod:gui/infusion" uv="0 0 96 44"/>
301 <RecipeSlot slot="ring" x="4" y="24" index="1" frame="true"/>
302 <RecipeSlot slot="ring" x="4" y="4" index="0"/>
303 <ItemSlot item="beacon" x="40" y="0"/>
304 <RecipeResult x="76" y="14"/>
305 <Label text="{x:Bind time} ticks" x="40" y="34"/>
306 </Canvas>
307 </Component>"#,
308 )
309 .unwrap();
310 assert!(is_recipe_layout(&el));
311 let l = compile(&el, "mymod").unwrap();
312 assert_eq!(l.kinds, ["mymod:infusion", "shaped"]);
313 assert_eq!(l.size, [96.0, 44.0]);
314 assert!(matches!(&l.parts[1], LayoutPart::Slots { slot, at, frame: true } if slot == "ring" && at == &[[4.0, 4.0], [4.0, 24.0]]));
315 assert!(matches!(&l.parts[2], LayoutPart::Item { item, .. } if item == "minecraft:beacon"));
316 assert!(matches!(&l.parts[4], LayoutPart::Text { text, .. } if text == "{time} ticks"));
317
318 let bad = crate::yui::parse(r#"<Component recipe_layout="x" width="1" height="1"><Button/></Component>"#).unwrap();
319 assert!(compile(&bad, "m").unwrap_err().contains("not a recipe layout's element (`Button`)"));
320 let gap = crate::yui::parse(r#"<Component recipe_layout="x" width="1" height="1"><RecipeSlot slot="a" index="1"/></Component>"#).unwrap();
321 assert!(compile(&gap, "m").unwrap_err().contains("place 0 isn't laid out"));
322 }
323}