1use std::path::{Path, PathBuf};
9use yog_ids::Format;
10
11use std::borrow::Cow;
12use yog_book::layout_picture::{Piece, pieces};
13use yog_book::renderer::{BLOCK_PITCH, SLOT_YAW};
14use yog_data::Schema;
15use yog_formats::book::layout::{LayoutPart, RecipeLayout};
16use yog_formats::def::{Def, RecipeKindDef};
17use yog_gfx::{Color, Vec2};
18use yog_registry::recipe_kinds::{self, fields};
19
20use crate::design::{assets::Resources, gfx, images, raster};
21
22const SCALE: u32 = 3;
24
25#[derive(Debug, yog_data::Schema)]
26#[data(rename_all = "camelCase")]
27pub struct Slot {
28 pub name: String,
29 pub label: Option<String>,
31 pub description: Option<String>,
32 pub max: u32,
33 pub required: bool,
34 pub field: Option<String>,
36 pub grid: bool,
38}
39
40#[derive(Debug, yog_data::Schema)]
41#[data(rename_all = "camelCase")]
42pub struct ValueSpec {
43 pub name: String,
44 pub kind: String,
46 pub default: Option<f64>,
48}
49
50#[derive(Debug, yog_data::Schema)]
52#[data(rename_all = "camelCase", tag = "type")]
53pub enum Spot {
54 Slot {
56 slot: String,
57 index: usize,
58 at: [f32; 2],
59 },
60 Result {
61 at: [f32; 2],
62 },
63 Count {
64 at: [f32; 2],
65 },
66 Text {
68 text: String,
69 at: [f32; 2],
70 },
71}
72
73#[derive(Debug, yog_data::Schema)]
74#[data(rename_all = "camelCase")]
75pub struct Layout {
76 pub file: String,
78 pub size: [f32; 2],
79 pub image: Option<String>,
81 pub scale: u32,
82 pub spots: Vec<Spot>,
83}
84
85#[derive(Debug, yog_data::Schema)]
86#[data(rename_all = "camelCase")]
87pub struct Kind {
88 pub id: String,
89 pub label: Option<String>,
91 pub file: String,
93 pub slots: Vec<Slot>,
94 pub values: Vec<ValueSpec>,
95 pub layout: Option<Layout>,
96}
97
98#[derive(Debug, yog_data::Schema)]
102#[data(rename_all = "camelCase")]
103pub struct Fields {
104 #[data(rename = "type")]
105 pub kind: Cow<'static, str>,
106 pub result: Cow<'static, str>,
107 pub count: Cow<'static, str>,
108 pub slots: Cow<'static, str>,
109 pub values: Cow<'static, str>,
110 pub pattern: Cow<'static, str>,
111 pub key: Cow<'static, str>,
112 pub grid_side: usize,
113}
114
115#[derive(Debug, yog_data::Schema)]
117pub struct Kinds {
118 pub game: Vec<Kind>,
119 pub own: Vec<Kind>,
120 pub fields: Fields,
121}
122
123pub const FIELDS: Fields = Fields {
124 kind: Cow::Borrowed(fields::TYPE),
125 result: Cow::Borrowed(fields::RESULT),
126 count: Cow::Borrowed(fields::COUNT),
127 slots: Cow::Borrowed(fields::SLOTS),
128 values: Cow::Borrowed(fields::VALUES),
129 pattern: Cow::Borrowed(fields::PATTERN),
130 key: Cow::Borrowed(fields::KEY),
131 grid_side: recipe_kinds::GRID_SIDE,
132};
133
134pub fn all(root: Option<&Path>) -> Kinds {
136 Kinds {
137 game: root.map(game_kinds).unwrap_or_default(),
138 own: root.map(kinds).unwrap_or_default(),
139 fields: FIELDS,
140 }
141}
142
143pub fn game_kinds(root: &Path) -> Vec<Kind> {
146 let layouts = crate::design::mod_ui::recipe_layouts(root);
147 recipe_kinds::GAME
148 .iter()
149 .map(|k| describe(root, k, k.id.clone(), String::new(), &layouts))
150 .collect()
151}
152
153pub fn kinds(root: &Path) -> Vec<Kind> {
155 let ns = crate::design::project::mod_id(root).unwrap_or_default();
156 let files = crate::files::of(&root.join(yog_ids::project::CONTENT), Format::Def);
157 let layouts = crate::design::mod_ui::recipe_layouts(root);
158 let mut out: Vec<Kind> = files
159 .iter()
160 .filter_map(|file| {
161 let text = crate::files::read_source(file).ok()?;
162 let Def::RecipeKind(k) = yog_data::from_text::<Def>(&text).ok()?.value else {
163 return None;
164 };
165 let id = if k.id.contains(':') {
166 k.id.clone()
167 } else {
168 format!("{ns}:{}", k.id)
169 };
170 Some(describe(
171 root,
172 &k,
173 id,
174 file.to_string_lossy().into_owned(),
175 &layouts,
176 ))
177 })
178 .collect();
179 out.sort_by(|a, b| a.id.cmp(&b.id));
180 out
181}
182
183fn describe(
186 root: &Path,
187 k: &RecipeKindDef,
188 id: String,
189 file: String,
190 layouts: &[(PathBuf, RecipeLayout)],
191) -> Kind {
192 let own: Vec<RecipeLayout> = layouts.iter().map(|(_, l)| l.clone()).collect();
193 let layout_file = layouts
194 .iter()
195 .find(|(_, l)| l.kinds.contains(&id))
196 .map(|(f, _)| f.clone())
197 .unwrap_or_default();
198 Kind {
199 label: k.name.clone(),
200 file,
201 slots: k
202 .slots
203 .0
204 .iter()
205 .map(|(name, s)| Slot {
206 name: name.clone(),
207 label: s.name.clone(),
208 description: s.description.clone(),
209 max: s.max,
210 required: s.required,
211 field: s.field.clone(),
212 grid: s.grid,
213 })
214 .collect(),
215 values: k
216 .values
217 .0
218 .iter()
219 .map(|(name, t)| ValueSpec {
220 name: name.clone(),
221 kind: t.write().as_str().unwrap_or_default().to_string(),
222 default: k.default_of(name),
223 })
224 .collect(),
225 layout: yog_formats::book::layout::find(&own, &id).map(|l| layout(root, &layout_file, l)),
226 id,
227 }
228}
229
230fn layout(root: &Path, file: &Path, layout: &RecipeLayout) -> Layout {
232 let size = layout.size;
233 let mut spots = Vec::new();
234 for p in &layout.parts {
235 match p {
236 LayoutPart::Slots { slot, at, .. } => {
237 spots.extend(at.iter().enumerate().map(|(index, at)| Spot::Slot {
238 slot: slot.clone(),
239 index,
240 at: *at,
241 }));
242 }
243 LayoutPart::Result { at, .. } => spots.push(Spot::Result { at: *at }),
244 LayoutPart::Count { at } => spots.push(Spot::Count { at: *at }),
245 LayoutPart::Text { text, at } => spots.push(Spot::Text {
246 text: text.clone(),
247 at: *at,
248 }),
249 LayoutPart::Image { .. } | LayoutPart::Item { .. } | LayoutPart::Block { .. } => {}
250 }
251 }
252 let (w, h) = (
253 size[0].ceil().max(1.0) as u32,
254 size[1].ceil().max(1.0) as u32,
255 );
256 let ops = gfx::record(w as i32, h as i32, |ctx| {
257 let d = ctx.draw2d();
258 for piece in pieces(layout) {
259 match piece {
260 Piece::Image { source, uv, at } => {
261 d.image_region(source, at, Some(uv), Color::WHITE)
262 }
263 Piece::Item { item, at } => d.item(item, Vec2::new(at.x, at.y), at.w),
264 Piece::Block { block, at } => {
265 d.blocks(&[((0, 0, 0), block)], at, SLOT_YAW, BLOCK_PITCH)
266 }
267 }
268 }
269 });
270 images::set_document(Some(root));
271 let res = Resources::for_mod(Some(root));
272 let image = raster::rasterize(&ops, w, h, SCALE, &res).png_url();
273 Layout {
274 file: file.to_string_lossy().into_owned(),
275 size,
276 image,
277 scale: SCALE,
278 spots,
279 }
280}
281
282const LAYOUT_DIR: &str = "ui";
284const LAYOUT_SUFFIX: &str = "_layout";
285
286pub fn create(file: &Path, name: &str) -> Result<PathBuf, String> {
290 let root = super::mod_root(file).ok_or("not inside a mod")?;
291 let name = name.trim();
292 if !yog_ids::valid_name(name) {
293 return Err("a kind's name: a-z, 0-9 and _".into());
294 }
295 let def = root
296 .join(yog_ids::project::CONTENT)
297 .join(Format::Def.file(name));
298 let layout = root
299 .join(yog_ids::project::SRC)
300 .join(LAYOUT_DIR)
301 .join(Format::Ui.file(&format!("{name}{LAYOUT_SUFFIX}")));
302 if let Some(taken) = [&def, &layout].into_iter().find(|f| f.exists()) {
303 return Err(format!("{} exists", taken.display()));
304 }
305 for (f, text) in [(&def, template(name)), (&layout, layout_template(name))] {
306 if let Some(dir) = f.parent() {
307 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
308 }
309 if yog_ids::Format::of_path(f).is_some_and(|f| f.is_data()) {
310 crate::files::write_source(f, &text)?;
311 } else {
312 std::fs::write(f, text).map_err(|e| e.to_string())?;
313 }
314 }
315 Ok(layout)
316}
317
318pub fn template(id: &str) -> String {
320 format!(
321 r#"{{
322 kind: "recipe_kind",
323 id: "{id}",
324 // Named slots: how many ingredients each takes.
325 slots: {{
326 input: {{ required: true }},
327 extra: {{ max: 2 }},
328 }},
329 // Named values a recipe of the kind gives (number, text, bool).
330 values: {{ time: "number" }},
331}}
332"#
333 )
334}
335
336pub fn layout_template(id: &str) -> String {
339 let (input, result) = (yog_ids::qualify("iron_ore"), yog_ids::qualify("iron_ingot"));
341 format!(
342 r#"<!-- How recipes of {id} are shown: in books and in the recipe editor. -->
343<Component recipe_layout="{id}" width="100" height="40">
344 <RecipeSlot slot="input" x="4" y="12" frame="true" d:item="{input}"/>
345 <RecipeSlot slot="extra" x="30" y="4" frame="true"/>
346 <RecipeSlot slot="extra" x="30" y="22" frame="true"/>
347 <RecipeResult x="78" y="12" frame="true" d:item="{result}"/>
348 <Label text="{{x:Bind time}} ticks" x="50" y="32"/>
349</Component>
350"#
351 )
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn reads_the_mods_kinds_and_lays_them_out() {
360 let dir = std::env::temp_dir().join(format!("yog-idle-kinds-{}", std::process::id()));
361 let _ = std::fs::remove_dir_all(&dir);
362 std::fs::create_dir_all(dir.join("content/machines")).unwrap();
363 std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
364 std::fs::write(
365 dir.join("content/machines/crusher.ydef"),
366 template("crusher"),
367 )
368 .unwrap();
369 std::fs::create_dir_all(dir.join("src/ui")).unwrap();
370 std::fs::write(
371 dir.join("src/ui/crusher_layout.yui"),
372 layout_template("crusher"),
373 )
374 .unwrap();
375 std::fs::write(
376 dir.join("content/other.ydef"),
377 r#"{ kind: "sound_group", id: "x" }"#,
378 )
379 .unwrap();
380 let ks = kinds(&dir);
381 assert_eq!(ks.len(), 1);
382 let k = &ks[0];
383 assert_eq!(k.id, "mymod:crusher");
384 assert_eq!(
385 k.slots
386 .iter()
387 .map(|s| (s.name.as_str(), s.max, s.required))
388 .collect::<Vec<_>>(),
389 [("input", 1, true), ("extra", 2, false)]
390 );
391 assert_eq!(k.values[0].kind, "number");
392 let l = k.layout.as_ref().unwrap();
393 assert!(
394 l.image
395 .as_deref()
396 .is_some_and(|i| i.starts_with("data:image/png"))
397 );
398 assert_eq!(
399 l.spots
400 .iter()
401 .filter(|s| matches!(s, Spot::Slot { .. }))
402 .count(),
403 3
404 );
405 assert!(
406 l.spots
407 .iter()
408 .any(|s| matches!(s, Spot::Text { text, .. } if text == "{time} ticks"))
409 );
410 assert!(l.file.ends_with("crusher_layout.yui"));
411 let game = game_kinds(&dir);
412 assert_eq!(
413 game.iter().map(|k| k.id.as_str()).collect::<Vec<_>>(),
414 recipe_kinds::ids::ALL,
415 "the registry's kinds, in its order"
416 );
417 let shaped = game[0].layout.as_ref().unwrap();
418 assert_eq!(
419 shaped
420 .spots
421 .iter()
422 .filter(|s| matches!(s, Spot::Slot { slot, .. } if slot == "grid"))
423 .count(),
424 9,
425 "the crafting table's own picture"
426 );
427 let smithing = game
428 .iter()
429 .find(|k| k.id == recipe_kinds::ids::SMITHING)
430 .unwrap()
431 .layout
432 .as_ref()
433 .unwrap();
434 let parts: Vec<&str> = smithing
435 .spots
436 .iter()
437 .filter_map(|s| {
438 if let Spot::Slot { slot, .. } = s {
439 Some(slot.as_str())
440 } else {
441 None
442 }
443 })
444 .collect();
445 assert_eq!(
446 parts,
447 ["base", "addition", "template"],
448 "Patchouli's smithing strip"
449 );
450 assert!(
451 smithing
452 .spots
453 .iter()
454 .any(|s| matches!(s, Spot::Result { .. }))
455 );
456 assert!(smithing.image.is_some());
457 let brewing = game
458 .iter()
459 .find(|k| k.id == recipe_kinds::ids::BREWING)
460 .unwrap();
461 assert_eq!(
462 brewing
463 .slots
464 .iter()
465 .map(|s| s.field.as_deref())
466 .collect::<Vec<_>>(),
467 [Some("from"), Some("ingredient")]
468 );
469 assert!(
470 brewing.layout.as_ref().is_some_and(|l| l.image.is_some()),
471 "Yog's own brewing strip"
472 );
473 let spots = yog_data::Schema::write(&l.spots);
475 let types: Vec<&str> = spots
476 .as_list()
477 .unwrap()
478 .iter()
479 .map(|s| s.get("type").and_then(yog_data::Value::as_str).unwrap())
480 .collect();
481 assert_eq!(types, ["slot", "slot", "slot", "result", "text"]);
482 let _ = std::fs::remove_dir_all(&dir);
483 }
484}