Skip to main content

yog_book/
lib.rs

1//! yog-book — guide books for Yog mods: the format (a `.ybook` file, see
2//! [`Book`]) and the renderer the game and Yog-IDLE draw it with
3//! ([`BookRenderer`], `draw2d` alone).
4//!
5//! A book names what it shows — items, blocks, entities, recipes, its own
6//! entries — through typed references (`@item:minecraft:stick`, see
7//! [`refs`]); tools check them and hand the renderer plain ids.
8
9pub mod markup;
10pub mod pattern;
11pub mod refs;
12pub mod renderer;
13pub mod state;
14pub mod theme;
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET, LANG};
21pub use state::BookViewState;
22pub use theme::BookTheme;
23
24/// The blocks of a multiblock `pattern` (layers top to bottom, rows of
25/// characters) with their `mapping`: ((x, y, z), block state).
26pub fn multiblock_blocks(pattern: &[Vec<String>], mapping: &BTreeMap<String, String>) -> Vec<((i32, i32, i32), String)> {
27    let mut out = Vec::new();
28    let layers = pattern.len() as i32;
29    for (li, layer) in pattern.iter().enumerate() {
30        for (z, row) in layer.iter().enumerate() {
31            for (x, ch) in row.chars().enumerate() {
32                if ch == ' ' {
33                    continue;
34                }
35                if let Some(state) = mapping.get(&ch.to_string()) {
36                    out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
37                }
38            }
39        }
40    }
41    out
42}
43
44// ── Pages ────────────────────────────────────────────────────────────────────
45
46/// One page of an entry: `{"type": "spotlight", "item": "minecraft:stick", …}`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum BookPage {
50    /// Text (Yog text: `{b|…}`, `{#rrggbb|…}`, `{link=…|…}`), with an
51    /// optional title; in another `font` (a resource-pack font id, e.g. a
52    /// `ttf` provider) at `font_size` pixels a line.
53    Text {
54        text: String,
55        #[serde(default, skip_serializing_if = "Option::is_none")]
56        title: Option<String>,
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        font: Option<String>,
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        font_size: Option<f32>,
61    },
62    /// An item in a frame, its name (or `title`) and text.
63    Spotlight {
64        item: String,
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        title: Option<String>,
67        #[serde(default, skip_serializing_if = "Option::is_none")]
68        text: Option<String>,
69    },
70    /// A recipe as its station shows it (a crafting grid, a furnace, …).
71    Recipe {
72        recipe: String,
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        text: Option<String>,
75    },
76    /// An image (a Yog image source: a texture, `.svg` too).
77    Image {
78        texture: String,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        title: Option<String>,
81        #[serde(default, skip_serializing_if = "Option::is_none")]
82        text: Option<String>,
83    },
84    /// A living entity, turning slowly.
85    Entity {
86        entity: String,
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        name: Option<String>,
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        text: Option<String>,
91    },
92    /// A structure in 3D, turning: `pattern` is its layers top to bottom,
93    /// each a list of rows; each character stands for the block `mapping`
94    /// gives it (a block state: `minecraft:furnace[facing=north]`), ` ` for air.
95    Multiblock {
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        name: Option<String>,
98        pattern: Vec<Vec<String>>,
99        mapping: BTreeMap<String, String>,
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        text: Option<String>,
102    },
103    /// Links to other entries.
104    Relations {
105        entries: Vec<String>,
106        #[serde(default, skip_serializing_if = "Option::is_none")]
107        text: Option<String>,
108    },
109    /// A spell pattern: its name, what it takes and gives, the pattern
110    /// itself on a hexagonal grid (see [`pattern`]).
111    Pattern {
112        op_id: String,
113        /// The action's name (the page's title; `op_id` if none).
114        #[serde(default, skip_serializing_if = "Option::is_none")]
115        name: Option<String>,
116        #[serde(default)]
117        input: String,
118        #[serde(default)]
119        output: String,
120        #[serde(default)]
121        text: String,
122        /// The patterns drawn on the page (none: just the texts).
123        #[serde(default, skip_serializing_if = "Vec::is_empty")]
124        patterns: Vec<pattern::HexPattern>,
125    },
126    /// Nothing (to start the next on a new spread).
127    Empty,
128}
129
130// ── Categories and entries ───────────────────────────────────────────────────
131
132/// A category of the book's home page.
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134#[serde(default)]
135pub struct BookCategory {
136    pub id: String,
137    pub name: String,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub description: Option<String>,
140    /// An item (`minecraft:book`) or an image (`minecraft:textures/item/book.png`).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub icon: Option<String>,
143    /// Lower first.
144    pub sortnum: i32,
145}
146
147/// One entry: its pages, in a category.
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
149#[serde(default)]
150pub struct BookEntry {
151    pub id: String,
152    pub name: String,
153    pub category: String,
154    pub pages: Vec<BookPage>,
155    /// An item or an image, as [`BookCategory::icon`].
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub icon: Option<String>,
158    /// Lower first.
159    pub priority: i32,
160    /// Hidden from the book's lists.
161    pub secret: bool,
162    /// Shown as read before it's opened.
163    pub read_by_default: bool,
164    /// The advancement that unlocks it.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub advancement: Option<String>,
167}
168
169// ── Book ─────────────────────────────────────────────────────────────────────
170
171/// A guide book: what a `.ybook` file holds.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(default)]
174pub struct Book {
175    /// The item that opens it.
176    pub id: String,
177    pub name: String,
178    /// The nameplate's second line when `version` is 0.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub subtitle: Option<String>,
181    /// The book's edition: its nameplate says "1st Edition", "2nd Edition"…
182    /// (0: the `subtitle` instead).
183    pub version: u32,
184    pub landing_text: String,
185    /// The nameplate's text color (`rrggbb`).
186    pub nameplate_color: String,
187    /// The color `{accent|terms}` are shown in (`#rrggbb`).
188    pub accent: String,
189    /// Its sheet (laid out as [`BOOK_PNG`]): `ns:textures/x.png`, or Yog's.
190    pub book_texture: String,
191    pub categories: Vec<BookCategory>,
192    pub entries: Vec<BookEntry>,
193    /// Its texts are translation keys, shown in the game's language.
194    pub i18n: bool,
195}
196
197impl Default for Book {
198    fn default() -> Self {
199        Book {
200            id: String::new(),
201            name: String::new(),
202            subtitle: None,
203            version: 0,
204            landing_text: String::new(),
205            nameplate_color: "FFDD98".into(),
206            accent: "#8d6acc".into(),
207            book_texture: "yog:gui/book".into(),
208            categories: Vec::new(),
209            entries: Vec::new(),
210            i18n: false,
211        }
212    }
213}
214
215impl Book {
216    /// This book with its texts run through `tr` — names, descriptions,
217    /// landing text and every page's title/text. With [`Book::i18n`] on,
218    /// the texts are translation keys and the renderer shows
219    /// `localized(<current language>)` (see [`BookRenderer::set_translator`]).
220    pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
221        let opt = |o: &mut Option<String>| {
222            if let Some(t) = o {
223                *t = tr(t);
224            }
225        };
226        let mut b = self.clone();
227        b.name = tr(&b.name);
228        b.landing_text = tr(&b.landing_text);
229        opt(&mut b.subtitle);
230        for c in &mut b.categories {
231            c.name = tr(&c.name);
232            opt(&mut c.description);
233        }
234        for e in &mut b.entries {
235            e.name = tr(&e.name);
236            for p in &mut e.pages {
237                match p {
238                    BookPage::Text { text, title, .. } => {
239                        *text = tr(text);
240                        opt(title);
241                    }
242                    BookPage::Spotlight { title, text, .. } | BookPage::Image { title, text, .. } => {
243                        opt(title);
244                        opt(text);
245                    }
246                    BookPage::Entity { name, text, .. } | BookPage::Multiblock { name, text, .. } => {
247                        opt(name);
248                        opt(text);
249                    }
250                    BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
251                    BookPage::Pattern { text, name, .. } => {
252                        *text = tr(text);
253                        opt(name);
254                    }
255                    BookPage::Empty => {}
256                }
257            }
258        }
259        b
260    }
261
262    /// The book as the JSON hosts and `yog build` pass around.
263    pub fn to_json(&self) -> String {
264        serde_json::to_string(self).unwrap_or_default()
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn text(t: &str) -> BookPage {
273        BookPage::Text { text: t.into(), title: None, font: None, font_size: None }
274    }
275
276    #[test]
277    fn books_round_trip_through_their_json() {
278        let book = Book {
279            id: "yog:test".into(),
280            name: "Test Book".into(),
281            version: 1,
282            categories: vec![BookCategory { id: "yog:c1".into(), name: "Cat".into(), icon: Some("yog:ruby".into()), ..Default::default() }],
283            entries: vec![BookEntry {
284                id: "yog:e1".into(),
285                name: "Entry".into(),
286                category: "yog:c1".into(),
287                pages: vec![
288                    text("plain"),
289                    BookPage::Spotlight { item: "yog:ruby".into(), title: None, text: None },
290                    BookPage::Recipe { recipe: "yog:r1".into(), text: None },
291                    BookPage::Empty,
292                ],
293                ..Default::default()
294            }],
295            ..Default::default()
296        };
297        let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
298        assert_eq!(parsed.version, 1);
299        assert_eq!(parsed.entries[0].pages.len(), 4);
300        assert!(matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby"));
301        assert!(matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1"));
302    }
303
304    #[test]
305    fn localized_translates_every_text() {
306        let book = Book {
307            id: "m:b".into(),
308            name: "m.book.name".into(),
309            landing_text: "m.book.landing".into(),
310            categories: vec![BookCategory { id: "m:basics".into(), name: "m.cat".into(), ..Default::default() }],
311            entries: vec![BookEntry {
312                id: "m:e".into(),
313                name: "m.entry".into(),
314                category: "m:basics".into(),
315                pages: vec![
316                    BookPage::Text { text: "m.text".into(), title: Some("m.title".into()), font: None, font_size: None },
317                    text("raw text"),
318                ],
319                ..Default::default()
320            }],
321            ..Default::default()
322        };
323        let out = book.localized(|k| if k.starts_with("m.") { k.to_uppercase() } else { k.to_string() });
324        assert_eq!(out.name, "M.BOOK.NAME");
325        assert_eq!(out.landing_text, "M.BOOK.LANDING");
326        assert_eq!(out.categories[0].name, "M.CAT");
327        assert_eq!(out.entries[0].name, "M.ENTRY");
328        assert!(matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE")));
329        assert!(matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text"));
330    }
331}