use std::path::{Path, PathBuf};
use yog_ids::Format;
use std::borrow::Cow;
use yog_book::layout_picture::{Piece, pieces};
use yog_book::renderer::{BLOCK_PITCH, SLOT_YAW};
use yog_data::Schema;
use yog_formats::book::layout::{LayoutPart, RecipeLayout};
use yog_formats::def::{Def, RecipeKindDef};
use yog_gfx::{Color, Vec2};
use yog_registry::recipe_kinds::{self, fields};
use crate::design::{assets::Resources, gfx, images, raster};
const SCALE: u32 = 3;
#[derive(Debug, yog_data::Schema)]
#[data(rename_all = "camelCase")]
pub struct Slot {
pub name: String,
pub label: Option<String>,
pub description: Option<String>,
pub max: u32,
pub required: bool,
pub field: Option<String>,
pub grid: bool,
}
#[derive(Debug, yog_data::Schema)]
#[data(rename_all = "camelCase")]
pub struct ValueSpec {
pub name: String,
pub kind: String,
pub default: Option<f64>,
}
#[derive(Debug, yog_data::Schema)]
#[data(rename_all = "camelCase", tag = "type")]
pub enum Spot {
Slot {
slot: String,
index: usize,
at: [f32; 2],
},
Result {
at: [f32; 2],
},
Count {
at: [f32; 2],
},
Text {
text: String,
at: [f32; 2],
},
}
#[derive(Debug, yog_data::Schema)]
#[data(rename_all = "camelCase")]
pub struct Layout {
pub file: String,
pub size: [f32; 2],
pub image: Option<String>,
pub scale: u32,
pub spots: Vec<Spot>,
}
#[derive(Debug, yog_data::Schema)]
#[data(rename_all = "camelCase")]
pub struct Kind {
pub id: String,
pub label: Option<String>,
pub file: String,
pub slots: Vec<Slot>,
pub values: Vec<ValueSpec>,
pub layout: Option<Layout>,
}
#[derive(Debug, yog_data::Schema)]
#[data(rename_all = "camelCase")]
pub struct Fields {
#[data(rename = "type")]
pub kind: Cow<'static, str>,
pub result: Cow<'static, str>,
pub count: Cow<'static, str>,
pub slots: Cow<'static, str>,
pub values: Cow<'static, str>,
pub pattern: Cow<'static, str>,
pub key: Cow<'static, str>,
pub grid_side: usize,
}
#[derive(Debug, yog_data::Schema)]
pub struct Kinds {
pub game: Vec<Kind>,
pub own: Vec<Kind>,
pub fields: Fields,
}
pub const FIELDS: Fields = Fields {
kind: Cow::Borrowed(fields::TYPE),
result: Cow::Borrowed(fields::RESULT),
count: Cow::Borrowed(fields::COUNT),
slots: Cow::Borrowed(fields::SLOTS),
values: Cow::Borrowed(fields::VALUES),
pattern: Cow::Borrowed(fields::PATTERN),
key: Cow::Borrowed(fields::KEY),
grid_side: recipe_kinds::GRID_SIDE,
};
pub fn all(root: Option<&Path>) -> Kinds {
Kinds {
game: root.map(game_kinds).unwrap_or_default(),
own: root.map(kinds).unwrap_or_default(),
fields: FIELDS,
}
}
pub fn game_kinds(root: &Path) -> Vec<Kind> {
let layouts = crate::design::mod_ui::recipe_layouts(root);
recipe_kinds::GAME
.iter()
.map(|k| describe(root, k, k.id.clone(), String::new(), &layouts))
.collect()
}
pub fn kinds(root: &Path) -> Vec<Kind> {
let ns = crate::design::project::mod_id(root).unwrap_or_default();
let files = crate::files::of(&root.join(yog_ids::project::CONTENT), Format::Def);
let layouts = crate::design::mod_ui::recipe_layouts(root);
let mut out: Vec<Kind> = files
.iter()
.filter_map(|file| {
let text = crate::files::read_source(file).ok()?;
let Def::RecipeKind(k) = yog_data::from_text::<Def>(&text).ok()?.value else {
return None;
};
let id = if k.id.contains(':') {
k.id.clone()
} else {
format!("{ns}:{}", k.id)
};
Some(describe(
root,
&k,
id,
file.to_string_lossy().into_owned(),
&layouts,
))
})
.collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
fn describe(
root: &Path,
k: &RecipeKindDef,
id: String,
file: String,
layouts: &[(PathBuf, RecipeLayout)],
) -> Kind {
let own: Vec<RecipeLayout> = layouts.iter().map(|(_, l)| l.clone()).collect();
let layout_file = layouts
.iter()
.find(|(_, l)| l.kinds.contains(&id))
.map(|(f, _)| f.clone())
.unwrap_or_default();
Kind {
label: k.name.clone(),
file,
slots: k
.slots
.0
.iter()
.map(|(name, s)| Slot {
name: name.clone(),
label: s.name.clone(),
description: s.description.clone(),
max: s.max,
required: s.required,
field: s.field.clone(),
grid: s.grid,
})
.collect(),
values: k
.values
.0
.iter()
.map(|(name, t)| ValueSpec {
name: name.clone(),
kind: t.write().as_str().unwrap_or_default().to_string(),
default: k.default_of(name),
})
.collect(),
layout: yog_formats::book::layout::find(&own, &id).map(|l| layout(root, &layout_file, l)),
id,
}
}
fn layout(root: &Path, file: &Path, layout: &RecipeLayout) -> Layout {
let size = layout.size;
let mut spots = Vec::new();
for p in &layout.parts {
match p {
LayoutPart::Slots { slot, at, .. } => {
spots.extend(at.iter().enumerate().map(|(index, at)| Spot::Slot {
slot: slot.clone(),
index,
at: *at,
}));
}
LayoutPart::Result { at, .. } => spots.push(Spot::Result { at: *at }),
LayoutPart::Count { at } => spots.push(Spot::Count { at: *at }),
LayoutPart::Text { text, at } => spots.push(Spot::Text {
text: text.clone(),
at: *at,
}),
LayoutPart::Image { .. } | LayoutPart::Item { .. } | LayoutPart::Block { .. } => {}
}
}
let (w, h) = (
size[0].ceil().max(1.0) as u32,
size[1].ceil().max(1.0) as u32,
);
let ops = gfx::record(w as i32, h as i32, |ctx| {
let d = ctx.draw2d();
for piece in pieces(layout) {
match piece {
Piece::Image { source, uv, at } => {
d.image_region(source, at, Some(uv), Color::WHITE)
}
Piece::Item { item, at } => d.item(item, Vec2::new(at.x, at.y), at.w),
Piece::Block { block, at } => {
d.blocks(&[((0, 0, 0), block)], at, SLOT_YAW, BLOCK_PITCH)
}
}
}
});
images::set_document(Some(root));
let res = Resources::for_mod(Some(root));
let image = raster::rasterize(&ops, w, h, SCALE, &res).png_url();
Layout {
file: file.to_string_lossy().into_owned(),
size,
image,
scale: SCALE,
spots,
}
}
const LAYOUT_DIR: &str = "ui";
const LAYOUT_SUFFIX: &str = "_layout";
pub fn create(file: &Path, name: &str) -> Result<PathBuf, String> {
let root = super::mod_root(file).ok_or("not inside a mod")?;
let name = name.trim();
if !yog_ids::valid_name(name) {
return Err("a kind's name: a-z, 0-9 and _".into());
}
let def = root
.join(yog_ids::project::CONTENT)
.join(Format::Def.file(name));
let layout = root
.join(yog_ids::project::SRC)
.join(LAYOUT_DIR)
.join(Format::Ui.file(&format!("{name}{LAYOUT_SUFFIX}")));
if let Some(taken) = [&def, &layout].into_iter().find(|f| f.exists()) {
return Err(format!("{} exists", taken.display()));
}
for (f, text) in [(&def, template(name)), (&layout, layout_template(name))] {
if let Some(dir) = f.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
if yog_ids::Format::of_path(f).is_some_and(|f| f.is_data()) {
crate::files::write_source(f, &text)?;
} else {
std::fs::write(f, text).map_err(|e| e.to_string())?;
}
}
Ok(layout)
}
pub fn template(id: &str) -> String {
format!(
r#"{{
kind: "recipe_kind",
id: "{id}",
// Named slots: how many ingredients each takes.
slots: {{
input: {{ required: true }},
extra: {{ max: 2 }},
}},
// Named values a recipe of the kind gives (number, text, bool).
values: {{ time: "number" }},
}}
"#
)
}
pub fn layout_template(id: &str) -> String {
let (input, result) = (yog_ids::qualify("iron_ore"), yog_ids::qualify("iron_ingot"));
format!(
r#"<!-- How recipes of {id} are shown: in books and in the recipe editor. -->
<Component recipe_layout="{id}" width="100" height="40">
<RecipeSlot slot="input" x="4" y="12" frame="true" d:item="{input}"/>
<RecipeSlot slot="extra" x="30" y="4" frame="true"/>
<RecipeSlot slot="extra" x="30" y="22" frame="true"/>
<RecipeResult x="78" y="12" frame="true" d:item="{result}"/>
<Label text="{{x:Bind time}} ticks" x="50" y="32"/>
</Component>
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_mods_kinds_and_lays_them_out() {
let dir = std::env::temp_dir().join(format!("yog-idle-kinds-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("content/machines")).unwrap();
std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
std::fs::write(
dir.join("content/machines/crusher.ydef"),
template("crusher"),
)
.unwrap();
std::fs::create_dir_all(dir.join("src/ui")).unwrap();
std::fs::write(
dir.join("src/ui/crusher_layout.yui"),
layout_template("crusher"),
)
.unwrap();
std::fs::write(
dir.join("content/other.ydef"),
r#"{ kind: "sound_group", id: "x" }"#,
)
.unwrap();
let ks = kinds(&dir);
assert_eq!(ks.len(), 1);
let k = &ks[0];
assert_eq!(k.id, "mymod:crusher");
assert_eq!(
k.slots
.iter()
.map(|s| (s.name.as_str(), s.max, s.required))
.collect::<Vec<_>>(),
[("input", 1, true), ("extra", 2, false)]
);
assert_eq!(k.values[0].kind, "number");
let l = k.layout.as_ref().unwrap();
assert!(
l.image
.as_deref()
.is_some_and(|i| i.starts_with("data:image/png"))
);
assert_eq!(
l.spots
.iter()
.filter(|s| matches!(s, Spot::Slot { .. }))
.count(),
3
);
assert!(
l.spots
.iter()
.any(|s| matches!(s, Spot::Text { text, .. } if text == "{time} ticks"))
);
assert!(l.file.ends_with("crusher_layout.yui"));
let game = game_kinds(&dir);
assert_eq!(
game.iter().map(|k| k.id.as_str()).collect::<Vec<_>>(),
recipe_kinds::ids::ALL,
"the registry's kinds, in its order"
);
let shaped = game[0].layout.as_ref().unwrap();
assert_eq!(
shaped
.spots
.iter()
.filter(|s| matches!(s, Spot::Slot { slot, .. } if slot == "grid"))
.count(),
9,
"the crafting table's own picture"
);
let smithing = game
.iter()
.find(|k| k.id == recipe_kinds::ids::SMITHING)
.unwrap()
.layout
.as_ref()
.unwrap();
let parts: Vec<&str> = smithing
.spots
.iter()
.filter_map(|s| {
if let Spot::Slot { slot, .. } = s {
Some(slot.as_str())
} else {
None
}
})
.collect();
assert_eq!(
parts,
["base", "addition", "template"],
"Patchouli's smithing strip"
);
assert!(
smithing
.spots
.iter()
.any(|s| matches!(s, Spot::Result { .. }))
);
assert!(smithing.image.is_some());
let brewing = game
.iter()
.find(|k| k.id == recipe_kinds::ids::BREWING)
.unwrap();
assert_eq!(
brewing
.slots
.iter()
.map(|s| s.field.as_deref())
.collect::<Vec<_>>(),
[Some("from"), Some("ingredient")]
);
assert!(
brewing.layout.as_ref().is_some_and(|l| l.image.is_some()),
"Yog's own brewing strip"
);
let spots = yog_data::Schema::write(&l.spots);
let types: Vec<&str> = spots
.as_list()
.unwrap()
.iter()
.map(|s| s.get("type").and_then(yog_data::Value::as_str).unwrap())
.collect();
assert_eq!(types, ["slot", "slot", "slot", "result", "text"]);
let _ = std::fs::remove_dir_all(&dir);
}
}