use yog_ids::Format;
use std::path::{Path, PathBuf};
use base64::Engine as _;
use serde::Serialize;
use yog_book::layout_picture::{Piece, pieces};
use yog_book::renderer::{BLOCK_PITCH, SLOT_YAW};
use yog_formats::book::layout::{LayoutPart, RecipeLayout};
use yog_data::Schema;
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, Serialize)]
#[serde(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, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ValueSpec {
pub name: String,
pub kind: String,
pub default: Option<f64>,
}
#[derive(Debug, Serialize)]
#[serde(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, Serialize)]
#[serde(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, Serialize)]
#[serde(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, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Fields {
#[serde(rename = "type")]
pub kind: &'static str,
pub result: &'static str,
pub count: &'static str,
pub slots: &'static str,
pub values: &'static str,
pub pattern: &'static str,
pub key: &'static str,
pub grid_side: usize,
}
#[derive(Debug, Serialize)]
pub struct Kinds {
pub game: Vec<Kind>,
pub own: Vec<Kind>,
pub fields: Fields,
}
pub const FIELDS: Fields = Fields {
kind: fields::TYPE,
result: fields::RESULT,
count: fields::COUNT,
slots: fields::SLOTS,
values: fields::VALUES,
pattern: fields::PATTERN,
key: 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::design::files::of(&root.join("content"), Format::Def);
let layouts = crate::design::mod_ui::recipe_layouts(root);
let mut out: Vec<Kind> = files
.iter()
.filter_map(|file| {
let text = std::fs::read_to_string(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()
.ok()
.map(|png| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png)));
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 name.is_empty() || !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
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())?;
}
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 {
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="minecraft:iron_ore"/>
<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="minecraft:iron_ingot"/>
<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 json = serde_json::to_value(&l.spots).unwrap();
let types: Vec<&str> = json.as_array().unwrap().iter().map(|s| s["type"].as_str().unwrap()).collect();
assert_eq!(types, ["slot", "slot", "slot", "result", "text"]);
let _ = std::fs::remove_dir_all(&dir);
}
}