1use yog_ids::Format;
9use std::path::{Path, PathBuf};
10
11use base64::Engine as _;
12use serde::Serialize;
13use yog_book::layout_picture::{Piece, pieces};
14use yog_book::renderer::{BLOCK_PITCH, SLOT_YAW};
15use yog_formats::book::layout::{LayoutPart, RecipeLayout};
16use yog_data::Schema;
17use yog_formats::def::{Def, RecipeKindDef};
18use yog_gfx::{Color, Vec2};
19use yog_registry::recipe_kinds::{self, fields};
20
21use crate::design::{assets::Resources, gfx, images, raster};
22
23const SCALE: u32 = 3;
25
26#[derive(Debug, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct Slot {
29 pub name: String,
30 pub label: Option<String>,
32 pub description: Option<String>,
33 pub max: u32,
34 pub required: bool,
35 pub field: Option<String>,
37 pub grid: bool,
39}
40
41#[derive(Debug, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ValueSpec {
44 pub name: String,
45 pub kind: String,
47 pub default: Option<f64>,
49}
50
51#[derive(Debug, Serialize)]
53#[serde(rename_all = "camelCase", tag = "type")]
54pub enum Spot {
55 Slot { slot: String, index: usize, at: [f32; 2] },
57 Result { at: [f32; 2] },
58 Count { at: [f32; 2] },
59 Text { text: String, at: [f32; 2] },
61}
62
63#[derive(Debug, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct Layout {
66 pub file: String,
68 pub size: [f32; 2],
69 pub image: Option<String>,
71 pub scale: u32,
72 pub spots: Vec<Spot>,
73}
74
75#[derive(Debug, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Kind {
78 pub id: String,
79 pub label: Option<String>,
81 pub file: String,
83 pub slots: Vec<Slot>,
84 pub values: Vec<ValueSpec>,
85 pub layout: Option<Layout>,
86}
87
88#[derive(Debug, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct Fields {
94 #[serde(rename = "type")]
95 pub kind: &'static str,
96 pub result: &'static str,
97 pub count: &'static str,
98 pub slots: &'static str,
99 pub values: &'static str,
100 pub pattern: &'static str,
101 pub key: &'static str,
102 pub grid_side: usize,
103}
104
105#[derive(Debug, Serialize)]
107pub struct Kinds {
108 pub game: Vec<Kind>,
109 pub own: Vec<Kind>,
110 pub fields: Fields,
111}
112
113pub const FIELDS: Fields = Fields {
114 kind: fields::TYPE,
115 result: fields::RESULT,
116 count: fields::COUNT,
117 slots: fields::SLOTS,
118 values: fields::VALUES,
119 pattern: fields::PATTERN,
120 key: fields::KEY,
121 grid_side: recipe_kinds::GRID_SIDE,
122};
123
124pub fn all(root: Option<&Path>) -> Kinds {
126 Kinds {
127 game: root.map(game_kinds).unwrap_or_default(),
128 own: root.map(kinds).unwrap_or_default(),
129 fields: FIELDS,
130 }
131}
132
133pub fn game_kinds(root: &Path) -> Vec<Kind> {
136 let layouts = crate::design::mod_ui::recipe_layouts(root);
137 recipe_kinds::GAME.iter().map(|k| describe(root, k, k.id.clone(), String::new(), &layouts)).collect()
138}
139
140pub fn kinds(root: &Path) -> Vec<Kind> {
142 let ns = crate::design::project::mod_id(root).unwrap_or_default();
143 let files = crate::design::files::of(&root.join("content"), Format::Def);
144 let layouts = crate::design::mod_ui::recipe_layouts(root);
145 let mut out: Vec<Kind> = files
146 .iter()
147 .filter_map(|file| {
148 let text = std::fs::read_to_string(file).ok()?;
149 let Def::RecipeKind(k) = yog_data::from_text::<Def>(&text).ok()?.value else { return None };
150 let id = if k.id.contains(':') { k.id.clone() } else { format!("{ns}:{}", k.id) };
151 Some(describe(root, &k, id, file.to_string_lossy().into_owned(), &layouts))
152 })
153 .collect();
154 out.sort_by(|a, b| a.id.cmp(&b.id));
155 out
156}
157
158fn describe(root: &Path, k: &RecipeKindDef, id: String, file: String, layouts: &[(PathBuf, RecipeLayout)]) -> Kind {
161 let own: Vec<RecipeLayout> = layouts.iter().map(|(_, l)| l.clone()).collect();
162 let layout_file = layouts.iter().find(|(_, l)| l.kinds.contains(&id)).map(|(f, _)| f.clone()).unwrap_or_default();
163 Kind {
164 label: k.name.clone(),
165 file,
166 slots: k
167 .slots
168 .0
169 .iter()
170 .map(|(name, s)| Slot {
171 name: name.clone(),
172 label: s.name.clone(),
173 description: s.description.clone(),
174 max: s.max,
175 required: s.required,
176 field: s.field.clone(),
177 grid: s.grid,
178 })
179 .collect(),
180 values: k
181 .values
182 .0
183 .iter()
184 .map(|(name, t)| ValueSpec {
185 name: name.clone(),
186 kind: t.write().as_str().unwrap_or_default().to_string(),
187 default: k.default_of(name),
188 })
189 .collect(),
190 layout: yog_formats::book::layout::find(&own, &id).map(|l| layout(root, &layout_file, l)),
191 id,
192 }
193}
194
195fn layout(root: &Path, file: &Path, layout: &RecipeLayout) -> Layout {
197 let size = layout.size;
198 let mut spots = Vec::new();
199 for p in &layout.parts {
200 match p {
201 LayoutPart::Slots { slot, at, .. } => {
202 spots.extend(at.iter().enumerate().map(|(index, at)| Spot::Slot { slot: slot.clone(), index, at: *at }));
203 }
204 LayoutPart::Result { at, .. } => spots.push(Spot::Result { at: *at }),
205 LayoutPart::Count { at } => spots.push(Spot::Count { at: *at }),
206 LayoutPart::Text { text, at } => spots.push(Spot::Text { text: text.clone(), at: *at }),
207 LayoutPart::Image { .. } | LayoutPart::Item { .. } | LayoutPart::Block { .. } => {}
208 }
209 }
210 let (w, h) = (size[0].ceil().max(1.0) as u32, size[1].ceil().max(1.0) as u32);
211 let ops = gfx::record(w as i32, h as i32, |ctx| {
212 let d = ctx.draw2d();
213 for piece in pieces(layout) {
214 match piece {
215 Piece::Image { source, uv, at } => d.image_region(source, at, Some(uv), Color::WHITE),
216 Piece::Item { item, at } => d.item(item, Vec2::new(at.x, at.y), at.w),
217 Piece::Block { block, at } => d.blocks(&[((0, 0, 0), block)], at, SLOT_YAW, BLOCK_PITCH),
218 }
219 }
220 });
221 images::set_document(Some(root));
222 let res = Resources::for_mod(Some(root));
223 let image = raster::rasterize(&ops, w, h, SCALE, &res)
224 .png()
225 .ok()
226 .map(|png| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png)));
227 Layout { file: file.to_string_lossy().into_owned(), size, image, scale: SCALE, spots }
228}
229
230const LAYOUT_DIR: &str = "ui";
232const LAYOUT_SUFFIX: &str = "_layout";
233
234pub fn create(file: &Path, name: &str) -> Result<PathBuf, String> {
238 let root = super::mod_root(file).ok_or("not inside a mod")?;
239 let name = name.trim();
240 if name.is_empty() || !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
241 return Err("a kind's name: a-z, 0-9 and _".into());
242 }
243 let def = root.join(yog_ids::project::CONTENT).join(Format::Def.file(name));
244 let layout = root.join(yog_ids::project::SRC).join(LAYOUT_DIR).join(Format::Ui.file(&format!("{name}{LAYOUT_SUFFIX}")));
245 if let Some(taken) = [&def, &layout].into_iter().find(|f| f.exists()) {
246 return Err(format!("{} exists", taken.display()));
247 }
248 for (f, text) in [(&def, template(name)), (&layout, layout_template(name))] {
249 if let Some(dir) = f.parent() {
250 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
251 }
252 std::fs::write(f, text).map_err(|e| e.to_string())?;
253 }
254 Ok(layout)
255}
256
257pub fn template(id: &str) -> String {
259 format!(
260 r#"{{
261 kind: "recipe_kind",
262 id: "{id}",
263 // Named slots: how many ingredients each takes.
264 slots: {{
265 input: {{ required: true }},
266 extra: {{ max: 2 }},
267 }},
268 // Named values a recipe of the kind gives (number, text, bool).
269 values: {{ time: "number" }},
270}}
271"#
272 )
273}
274
275pub fn layout_template(id: &str) -> String {
278 format!(
279 r#"<!-- How recipes of {id} are shown: in books and in the recipe editor. -->
280<Component recipe_layout="{id}" width="100" height="40">
281 <RecipeSlot slot="input" x="4" y="12" frame="true" d:item="minecraft:iron_ore"/>
282 <RecipeSlot slot="extra" x="30" y="4" frame="true"/>
283 <RecipeSlot slot="extra" x="30" y="22" frame="true"/>
284 <RecipeResult x="78" y="12" frame="true" d:item="minecraft:iron_ingot"/>
285 <Label text="{{x:Bind time}} ticks" x="50" y="32"/>
286</Component>
287"#
288 )
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn reads_the_mods_kinds_and_lays_them_out() {
297 let dir = std::env::temp_dir().join(format!("yog-idle-kinds-{}", std::process::id()));
298 let _ = std::fs::remove_dir_all(&dir);
299 std::fs::create_dir_all(dir.join("content/machines")).unwrap();
300 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
301 std::fs::write(dir.join("content/machines/crusher.ydef"), template("crusher")).unwrap();
302 std::fs::create_dir_all(dir.join("src/ui")).unwrap();
303 std::fs::write(dir.join("src/ui/crusher_layout.yui"), layout_template("crusher")).unwrap();
304 std::fs::write(dir.join("content/other.ydef"), r#"{ kind: "sound_group", id: "x" }"#).unwrap();
305 let ks = kinds(&dir);
306 assert_eq!(ks.len(), 1);
307 let k = &ks[0];
308 assert_eq!(k.id, "mymod:crusher");
309 assert_eq!(k.slots.iter().map(|s| (s.name.as_str(), s.max, s.required)).collect::<Vec<_>>(), [("input", 1, true), ("extra", 2, false)]);
310 assert_eq!(k.values[0].kind, "number");
311 let l = k.layout.as_ref().unwrap();
312 assert!(l.image.as_deref().is_some_and(|i| i.starts_with("data:image/png")));
313 assert_eq!(l.spots.iter().filter(|s| matches!(s, Spot::Slot { .. })).count(), 3);
314 assert!(l.spots.iter().any(|s| matches!(s, Spot::Text { text, .. } if text == "{time} ticks")));
315 assert!(l.file.ends_with("crusher_layout.yui"));
316 let game = game_kinds(&dir);
317 assert_eq!(game.iter().map(|k| k.id.as_str()).collect::<Vec<_>>(), recipe_kinds::ids::ALL, "the registry's kinds, in its order");
318 let shaped = game[0].layout.as_ref().unwrap();
319 assert_eq!(shaped.spots.iter().filter(|s| matches!(s, Spot::Slot { slot, .. } if slot == "grid")).count(), 9, "the crafting table's own picture");
320 let smithing = game.iter().find(|k| k.id == recipe_kinds::ids::SMITHING).unwrap().layout.as_ref().unwrap();
321 let parts: Vec<&str> = smithing.spots.iter().filter_map(|s| if let Spot::Slot { slot, .. } = s { Some(slot.as_str()) } else { None }).collect();
322 assert_eq!(parts, ["base", "addition", "template"], "Patchouli's smithing strip");
323 assert!(smithing.spots.iter().any(|s| matches!(s, Spot::Result { .. })));
324 assert!(smithing.image.is_some());
325 let brewing = game.iter().find(|k| k.id == recipe_kinds::ids::BREWING).unwrap();
326 assert_eq!(brewing.slots.iter().map(|s| s.field.as_deref()).collect::<Vec<_>>(), [Some("from"), Some("ingredient")]);
327 assert!(brewing.layout.as_ref().is_some_and(|l| l.image.is_some()), "Yog's own brewing strip");
328 let json = serde_json::to_value(&l.spots).unwrap();
330 let types: Vec<&str> = json.as_array().unwrap().iter().map(|s| s["type"].as_str().unwrap()).collect();
331 assert_eq!(types, ["slot", "slot", "slot", "result", "text"]);
332 let _ = std::fs::remove_dir_all(&dir);
333 }
334}