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 layout;
10pub mod markup;
11pub mod page_type;
12pub mod refs;
13pub mod renderer;
14pub mod state;
15pub mod theme;
16pub mod ui;
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21use yog_data::Schema;
22use yog_data::kinds::{Block, Category, Entity, Entry, Item, Recipe};
23use yog_data::with::{List, Opt, RefTo, Values};
24
25use refs::{BookText, Icon};
26
27pub use renderer::{BOOK_PNG, BOOK_SHEET, BookRenderer, CRAFTING_PNG, CRAFTING_SHEET, LANG, PageDrawer};
28pub use state::BookViewState;
29pub use theme::BookTheme;
30
31/// A [`yog_data::Value`] in a book's JSON form.
32mod value_json {
33    use serde::{Deserialize, Deserializer, Serialize, Serializer};
34
35    pub fn serialize<S: Serializer>(v: &yog_data::Value, s: S) -> Result<S::Ok, S::Error> {
36        yog_data::json::to_json(v).serialize(s)
37    }
38
39    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<yog_data::Value, D::Error> {
40        Ok(yog_data::json::from_json(&serde_json::Value::deserialize(d)?))
41    }
42}
43
44/// The blocks of a multiblock `pattern` (layers top to bottom, rows of
45/// characters) with their `mapping`: ((x, y, z), block state).
46pub fn multiblock_blocks(
47    pattern: &[Vec<String>],
48    mapping: &BTreeMap<String, String>,
49) -> Vec<((i32, i32, i32), String)> {
50    let mut out = Vec::new();
51    let layers = pattern.len() as i32;
52    for (li, layer) in pattern.iter().enumerate() {
53        for (z, row) in layer.iter().enumerate() {
54            for (x, ch) in row.chars().enumerate() {
55                if ch == ' ' {
56                    continue;
57                }
58                if let Some(state) = mapping.get(&ch.to_string()) {
59                    out.push(((x as i32, layers - 1 - li as i32, z as i32), state.clone()));
60                }
61            }
62        }
63    }
64    out
65}
66
67// ── Pages ────────────────────────────────────────────────────────────────────
68
69/// One page of an entry: `{"type": "spotlight", "item": "@item:minecraft:stick", …}`.
70#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
71#[serde(tag = "type", rename_all = "snake_case")]
72#[data(tag = "type")]
73pub enum BookPage {
74    /// Text (Yog text: `{b|…}`, `{#rrggbb|…}`, `{link=…|…}`), with an
75    /// optional title; in another `font` (a resource-pack font id, e.g. a
76    /// `ttf` provider) at `font_size` pixels a line.
77    Text {
78        #[data(with = "BookText")]
79        text: String,
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        #[data(with = "Opt<BookText>")]
82        title: Option<String>,
83        /// A resource-pack font id.
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        font: Option<String>,
86        /// Pixels a line.
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        font_size: Option<f32>,
89    },
90    /// An item in a frame, its name (or `title`) and text.
91    Spotlight {
92        #[data(with = "RefTo<(Item, Block)>")]
93        item: String,
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        #[data(with = "Opt<BookText>")]
96        title: Option<String>,
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        #[data(with = "Opt<BookText>")]
99        text: Option<String>,
100    },
101    /// A recipe as its station shows it (a crafting grid, a furnace, …).
102    Recipe {
103        #[data(with = "RefTo<Recipe>")]
104        recipe: String,
105        #[serde(default, skip_serializing_if = "Option::is_none")]
106        #[data(with = "Opt<BookText>")]
107        text: Option<String>,
108    },
109    /// An image (a Yog image source: a texture, `.svg` too).
110    Image {
111        /// An image source (`mymod:textures/gui/x.png`, `.svg` too).
112        texture: String,
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        #[data(with = "Opt<BookText>")]
115        title: Option<String>,
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        #[data(with = "Opt<BookText>")]
118        text: Option<String>,
119    },
120    /// A living entity, turning slowly.
121    Entity {
122        #[data(with = "RefTo<Entity>")]
123        entity: String,
124        #[serde(default, skip_serializing_if = "Option::is_none")]
125        #[data(with = "Opt<BookText>")]
126        name: Option<String>,
127        #[serde(default, skip_serializing_if = "Option::is_none")]
128        #[data(with = "Opt<BookText>")]
129        text: Option<String>,
130    },
131    /// A structure in 3D, turning: `pattern` is its layers top to bottom,
132    /// each a list of rows; each character stands for the block `mapping`
133    /// gives it (a block state: `minecraft:furnace[facing=north]`), ` ` for air.
134    Multiblock {
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        #[data(with = "Opt<BookText>")]
137        name: Option<String>,
138        /// Layers top to bottom, each a list of rows of characters.
139        pattern: Vec<Vec<String>>,
140        /// What each character is: a block, with a state if it matters.
141        #[data(with = "Values<RefTo<Block>>")]
142        mapping: BTreeMap<String, String>,
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        #[data(with = "Opt<BookText>")]
145        text: Option<String>,
146    },
147    /// Links to other entries.
148    Relations {
149        #[data(with = "List<RefTo<Entry>>")]
150        entries: Vec<String>,
151        #[serde(default, skip_serializing_if = "Option::is_none")]
152        #[data(with = "Opt<BookText>")]
153        text: Option<String>,
154    },
155    /// A picture the mod's code draws (`Registry::on_book_page`) — Hex
156    /// Casting's patterns, a mod's diagram: the `drawer` registered under
157    /// that id gets `data` and a box the page's width and `height` high,
158    /// between the title and a caption line, over the text.
159    Drawn {
160        /// The id the mod's drawer is registered under (`hexcasting:pattern`).
161        drawer: String,
162        #[serde(default, skip_serializing_if = "Option::is_none")]
163        #[data(with = "Opt<BookText>")]
164        title: Option<String>,
165        /// The picture's height in book pixels (0: none).
166        #[serde(default)]
167        #[data(default, range = "0..=150")]
168        height: f32,
169        /// A line under the picture.
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        #[data(with = "Opt<BookText>")]
172        caption: Option<String>,
173        #[serde(default, skip_serializing_if = "Option::is_none")]
174        #[data(with = "Opt<BookText>")]
175        text: Option<String>,
176        /// Whatever the drawer reads, as is.
177        #[serde(default, with = "value_json")]
178        #[data(default)]
179        data: yog_data::Value,
180    },
181    /// Nothing (to start the next on a new spread).
182    Empty,
183    /// A page of a mod's own type (`type: "hexcasting:pattern"`), laid out
184    /// by the book's [`page_type::PageType`] of it.
185    #[serde(skip)]
186    #[data(other)]
187    Custom(page_type::CustomPage),
188}
189
190// ── Categories and entries ───────────────────────────────────────────────────
191
192/// A category of the book's home page.
193#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
194#[serde(default)]
195pub struct BookCategory {
196    pub id: String,
197    #[data(with = "BookText")]
198    pub name: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    #[data(with = "Opt<BookText>")]
201    pub description: Option<String>,
202    /// An item (`@item:minecraft:book`) or an image (`minecraft:textures/item/book.png`).
203    #[serde(skip_serializing_if = "Option::is_none")]
204    #[data(with = "Opt<Icon>")]
205    pub icon: Option<String>,
206    /// Lower first.
207    #[data(default)]
208    pub sortnum: i32,
209}
210
211/// One entry: its pages, in a category.
212#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema)]
213#[serde(default)]
214pub struct BookEntry {
215    pub id: String,
216    #[data(with = "BookText")]
217    pub name: String,
218    #[data(with = "RefTo<Category>")]
219    pub category: String,
220    #[data(default)]
221    pub pages: Vec<BookPage>,
222    /// An item or an image, as [`BookCategory::icon`].
223    #[serde(skip_serializing_if = "Option::is_none")]
224    #[data(with = "Opt<Icon>")]
225    pub icon: Option<String>,
226    /// Lower first.
227    #[data(default)]
228    pub priority: i32,
229    /// Hidden from the book's lists.
230    #[data(default)]
231    pub secret: bool,
232    /// Shown as read before it's opened.
233    #[data(default)]
234    pub read_by_default: bool,
235    /// Mods it's about — Yog or Java mod ids (`pehkui`): without every one
236    /// of them loaded the book doesn't show it.
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    #[data(default)]
239    pub requires: Vec<String>,
240    /// The advancement that unlocks it.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub advancement: Option<String>,
243}
244
245// ── Book ─────────────────────────────────────────────────────────────────────
246
247/// A guide book: what a `.ybook` file holds.
248#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
249#[serde(default)]
250pub struct Book {
251    /// The item that opens it (the mod's namespace if it has none).
252    pub id: String,
253    #[data(with = "BookText")]
254    pub name: String,
255    /// The nameplate's second line when `version` is 0.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    #[data(with = "Opt<BookText>")]
258    pub subtitle: Option<String>,
259    /// The book's edition: its nameplate says "1st Edition", "2nd Edition"…
260    /// (0: the `subtitle` instead).
261    #[data(default)]
262    pub version: u32,
263    #[data(with = "BookText", default)]
264    pub landing_text: String,
265    /// The nameplate's text color (`rrggbb`).
266    #[data(default = "default_nameplate_color")]
267    pub nameplate_color: String,
268    /// The color `{accent|terms}` are shown in (`#rrggbb`).
269    #[data(default = "default_accent")]
270    pub accent: String,
271    /// Its sheet (laid out as [`BOOK_PNG`]): `ns:textures/x.png`, or Yog's.
272    #[data(default = "default_book_texture")]
273    pub book_texture: String,
274    #[data(default)]
275    pub categories: Vec<BookCategory>,
276    #[data(default)]
277    pub entries: Vec<BookEntry>,
278    /// Its texts are translation keys, shown in the game's language (the
279    /// build turns it on when a text is a `.yres` text or a name).
280    #[data(default)]
281    pub i18n: bool,
282    /// Recipe layouts of its own; the build adds the mod's (`.ydef` recipe kinds and layouts).
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    #[data(default)]
285    pub layouts: Vec<layout::RecipeLayout>,
286    /// The mods' own kinds of page its pages use; the build adds them
287    /// (`.yui` components with `book_page`).
288    #[serde(skip)]
289    #[data(default)]
290    pub page_types: Vec<page_type::PageType>,
291}
292
293fn default_nameplate_color() -> String {
294    "FFDD98".into()
295}
296
297fn default_accent() -> String {
298    "#8d6acc".into()
299}
300
301fn default_book_texture() -> String {
302    "yog:gui/book".into()
303}
304
305impl Default for Book {
306    fn default() -> Self {
307        Book {
308            id: String::new(),
309            name: String::new(),
310            subtitle: None,
311            version: 0,
312            landing_text: String::new(),
313            nameplate_color: default_nameplate_color(),
314            accent: default_accent(),
315            book_texture: default_book_texture(),
316            categories: Vec::new(),
317            entries: Vec::new(),
318            i18n: false,
319            layouts: Vec::new(),
320            page_types: Vec::new(),
321        }
322    }
323}
324
325impl Book {
326    /// This book with its texts run through `tr` — names, descriptions,
327    /// landing text and every page's title/text. With [`Book::i18n`] on,
328    /// the texts are translation keys and the renderer shows
329    /// `localized(<current language>)` (see [`BookRenderer::set_translator`]).
330    pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
331        let opt = |o: &mut Option<String>| {
332            if let Some(t) = o {
333                *t = tr(t);
334            }
335        };
336        let mut b = self.clone();
337        let types = std::mem::take(&mut b.page_types);
338        b.name = tr(&b.name);
339        b.landing_text = tr(&b.landing_text);
340        opt(&mut b.subtitle);
341        for c in &mut b.categories {
342            c.name = tr(&c.name);
343            opt(&mut c.description);
344        }
345        for e in &mut b.entries {
346            e.name = tr(&e.name);
347            for p in &mut e.pages {
348                match p {
349                    BookPage::Text { text, title, .. } => {
350                        *text = tr(text);
351                        opt(title);
352                    }
353                    BookPage::Spotlight { title, text, .. }
354                    | BookPage::Image { title, text, .. } => {
355                        opt(title);
356                        opt(text);
357                    }
358                    BookPage::Entity { name, text, .. }
359                    | BookPage::Multiblock { name, text, .. } => {
360                        opt(name);
361                        opt(text);
362                    }
363                    BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
364                    BookPage::Drawn { title, caption, text, .. } => {
365                        opt(title);
366                        opt(caption);
367                        opt(text);
368                    }
369                    BookPage::Empty => {}
370                    // A mod's page: its texts, as its type says.
371                    BookPage::Custom(c) => {
372                        let Some(t) = types.iter().find(|t| t.id == c.kind) else { continue };
373                        for prop in t.properties.iter().filter(|p| matches!(p.kind, page_type::PropKind::Text | page_type::PropKind::BookText)) {
374                            if let Some(yog_data::Value::Str(s)) = c.fields.get_mut(&prop.name) {
375                                *s = tr(s);
376                            }
377                        }
378                    }
379                }
380            }
381        }
382        b.page_types = types;
383        b
384    }
385
386    /// The book as the JSON hosts and `yog build` pass around.
387    pub fn to_json(&self) -> String {
388        serde_json::to_string(self).unwrap_or_default()
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn text(t: &str) -> BookPage {
397        BookPage::Text {
398            text: t.into(),
399            title: None,
400            font: None,
401            font_size: None,
402        }
403    }
404
405    #[test]
406    fn books_round_trip_through_their_json() {
407        let book = Book {
408            id: "yog:test".into(),
409            name: "Test Book".into(),
410            version: 1,
411            categories: vec![BookCategory {
412                id: "yog:c1".into(),
413                name: "Cat".into(),
414                icon: Some("yog:ruby".into()),
415                ..Default::default()
416            }],
417            entries: vec![BookEntry {
418                id: "yog:e1".into(),
419                name: "Entry".into(),
420                category: "yog:c1".into(),
421                pages: vec![
422                    text("plain"),
423                    BookPage::Spotlight {
424                        item: "yog:ruby".into(),
425                        title: None,
426                        text: None,
427                    },
428                    BookPage::Recipe {
429                        recipe: "yog:r1".into(),
430                        text: None,
431                    },
432                    BookPage::Empty,
433                ],
434                ..Default::default()
435            }],
436            ..Default::default()
437        };
438        let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
439        assert_eq!(parsed.version, 1);
440        assert_eq!(parsed.entries[0].pages.len(), 4);
441        assert!(
442            matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby")
443        );
444        assert!(
445            matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1")
446        );
447    }
448
449    #[test]
450    fn localized_translates_every_text() {
451        let book = Book {
452            id: "m:b".into(),
453            name: "m.book.name".into(),
454            landing_text: "m.book.landing".into(),
455            categories: vec![BookCategory {
456                id: "m:basics".into(),
457                name: "m.cat".into(),
458                ..Default::default()
459            }],
460            entries: vec![BookEntry {
461                id: "m:e".into(),
462                name: "m.entry".into(),
463                category: "m:basics".into(),
464                pages: vec![
465                    BookPage::Text {
466                        text: "m.text".into(),
467                        title: Some("m.title".into()),
468                        font: None,
469                        font_size: None,
470                    },
471                    text("raw text"),
472                ],
473                ..Default::default()
474            }],
475            ..Default::default()
476        };
477        let out = book.localized(|k| {
478            if k.starts_with("m.") {
479                k.to_uppercase()
480            } else {
481                k.to_string()
482            }
483        });
484        assert_eq!(out.name, "M.BOOK.NAME");
485        assert_eq!(out.landing_text, "M.BOOK.LANDING");
486        assert_eq!(out.categories[0].name, "M.CAT");
487        assert_eq!(out.entries[0].name, "M.ENTRY");
488        assert!(
489            matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE"))
490        );
491        assert!(
492            matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
493        );
494    }
495}