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/// Whether the reader has advancement `id` — set by the host the book is
246/// read in (the runtime asks the game); everything is unlocked without one
247/// (an editor's preview).
248static UNLOCKED: std::sync::OnceLock<fn(&str) -> bool> = std::sync::OnceLock::new();
249
250/// Tells the books how to know what the reader has unlocked.
251pub fn set_unlocked(check: fn(&str) -> bool) {
252    let _ = UNLOCKED.set(check);
253}
254
255/// Whether `entry` shows in the book's lists: not secret, and its
256/// advancement (if it waits for one) had.
257pub fn shown(entry: &BookEntry) -> bool {
258    !entry.secret && entry.advancement.as_deref().is_none_or(|a| UNLOCKED.get().is_none_or(|check| check(a)))
259}
260
261// ── Book ─────────────────────────────────────────────────────────────────────
262
263/// A guide book: what a `.ybook` file holds.
264#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
265#[serde(default)]
266pub struct Book {
267    /// The item that opens it (the mod's namespace if it has none).
268    pub id: String,
269    #[data(with = "BookText")]
270    pub name: String,
271    /// The nameplate's second line when `version` is 0.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    #[data(with = "Opt<BookText>")]
274    pub subtitle: Option<String>,
275    /// The book's edition: its nameplate says "1st Edition", "2nd Edition"…
276    /// (0: the `subtitle` instead).
277    #[data(default)]
278    pub version: u32,
279    #[data(with = "BookText", default)]
280    pub landing_text: String,
281    /// The nameplate's text color (`rrggbb`).
282    #[data(default = "default_nameplate_color")]
283    pub nameplate_color: String,
284    /// The color `{accent|terms}` are shown in (`#rrggbb`).
285    #[data(default = "default_accent")]
286    pub accent: String,
287    /// Its sheet (laid out as [`BOOK_PNG`]): `ns:textures/x.png`, or Yog's.
288    #[data(default = "default_book_texture")]
289    pub book_texture: String,
290    #[data(default)]
291    pub categories: Vec<BookCategory>,
292    #[data(default)]
293    pub entries: Vec<BookEntry>,
294    /// Its texts are translation keys, shown in the game's language (the
295    /// build turns it on when a text is a `.yres` text or a name).
296    #[data(default)]
297    pub i18n: bool,
298    /// Recipe layouts of its own; the build adds the mod's (`.ydef` recipe kinds and layouts).
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    #[data(default)]
301    pub layouts: Vec<layout::RecipeLayout>,
302    /// The mods' own kinds of page its pages use; the build adds them
303    /// (`.yui` components with `book_page`).
304    #[serde(skip)]
305    #[data(default)]
306    pub page_types: Vec<page_type::PageType>,
307}
308
309fn default_nameplate_color() -> String {
310    "FFDD98".into()
311}
312
313fn default_accent() -> String {
314    "#8d6acc".into()
315}
316
317fn default_book_texture() -> String {
318    "yog:gui/book".into()
319}
320
321impl Default for Book {
322    fn default() -> Self {
323        Book {
324            id: String::new(),
325            name: String::new(),
326            subtitle: None,
327            version: 0,
328            landing_text: String::new(),
329            nameplate_color: default_nameplate_color(),
330            accent: default_accent(),
331            book_texture: default_book_texture(),
332            categories: Vec::new(),
333            entries: Vec::new(),
334            i18n: false,
335            layouts: Vec::new(),
336            page_types: Vec::new(),
337        }
338    }
339}
340
341impl Book {
342    /// This book with its texts run through `tr` — names, descriptions,
343    /// landing text and every page's title/text. With [`Book::i18n`] on,
344    /// the texts are translation keys and the renderer shows
345    /// `localized(<current language>)` (see [`BookRenderer::set_translator`]).
346    pub fn localized(&self, tr: impl Fn(&str) -> String) -> Book {
347        let opt = |o: &mut Option<String>| {
348            if let Some(t) = o {
349                *t = tr(t);
350            }
351        };
352        let mut b = self.clone();
353        let types = std::mem::take(&mut b.page_types);
354        b.name = tr(&b.name);
355        b.landing_text = tr(&b.landing_text);
356        opt(&mut b.subtitle);
357        for c in &mut b.categories {
358            c.name = tr(&c.name);
359            opt(&mut c.description);
360        }
361        for e in &mut b.entries {
362            e.name = tr(&e.name);
363            for p in &mut e.pages {
364                match p {
365                    BookPage::Text { text, title, .. } => {
366                        *text = tr(text);
367                        opt(title);
368                    }
369                    BookPage::Spotlight { title, text, .. }
370                    | BookPage::Image { title, text, .. } => {
371                        opt(title);
372                        opt(text);
373                    }
374                    BookPage::Entity { name, text, .. }
375                    | BookPage::Multiblock { name, text, .. } => {
376                        opt(name);
377                        opt(text);
378                    }
379                    BookPage::Recipe { text, .. } | BookPage::Relations { text, .. } => opt(text),
380                    BookPage::Drawn { title, caption, text, .. } => {
381                        opt(title);
382                        opt(caption);
383                        opt(text);
384                    }
385                    BookPage::Empty => {}
386                    // A mod's page: its texts, as its type says.
387                    BookPage::Custom(c) => {
388                        let Some(t) = types.iter().find(|t| t.id == c.kind) else { continue };
389                        for prop in t.properties.iter().filter(|p| matches!(p.kind, page_type::PropKind::Text | page_type::PropKind::BookText)) {
390                            if let Some(yog_data::Value::Str(s)) = c.fields.get_mut(&prop.name) {
391                                *s = tr(s);
392                            }
393                        }
394                    }
395                }
396            }
397        }
398        b.page_types = types;
399        b
400    }
401
402    /// The book as the JSON hosts and `yog build` pass around.
403    pub fn to_json(&self) -> String {
404        serde_json::to_string(self).unwrap_or_default()
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn text(t: &str) -> BookPage {
413        BookPage::Text {
414            text: t.into(),
415            title: None,
416            font: None,
417            font_size: None,
418        }
419    }
420
421    #[test]
422    fn books_round_trip_through_their_json() {
423        let book = Book {
424            id: "yog:test".into(),
425            name: "Test Book".into(),
426            version: 1,
427            categories: vec![BookCategory {
428                id: "yog:c1".into(),
429                name: "Cat".into(),
430                icon: Some("yog:ruby".into()),
431                ..Default::default()
432            }],
433            entries: vec![BookEntry {
434                id: "yog:e1".into(),
435                name: "Entry".into(),
436                category: "yog:c1".into(),
437                pages: vec![
438                    text("plain"),
439                    BookPage::Spotlight {
440                        item: "yog:ruby".into(),
441                        title: None,
442                        text: None,
443                    },
444                    BookPage::Recipe {
445                        recipe: "yog:r1".into(),
446                        text: None,
447                    },
448                    BookPage::Empty,
449                ],
450                ..Default::default()
451            }],
452            ..Default::default()
453        };
454        let parsed: Book = serde_json::from_str(&book.to_json()).expect("its JSON parses");
455        assert_eq!(parsed.version, 1);
456        assert_eq!(parsed.entries[0].pages.len(), 4);
457        assert!(
458            matches!(&parsed.entries[0].pages[1], BookPage::Spotlight { item, .. } if item == "yog:ruby")
459        );
460        assert!(
461            matches!(&parsed.entries[0].pages[2], BookPage::Recipe { recipe, .. } if recipe == "yog:r1")
462        );
463    }
464
465    #[test]
466    fn localized_translates_every_text() {
467        let book = Book {
468            id: "m:b".into(),
469            name: "m.book.name".into(),
470            landing_text: "m.book.landing".into(),
471            categories: vec![BookCategory {
472                id: "m:basics".into(),
473                name: "m.cat".into(),
474                ..Default::default()
475            }],
476            entries: vec![BookEntry {
477                id: "m:e".into(),
478                name: "m.entry".into(),
479                category: "m:basics".into(),
480                pages: vec![
481                    BookPage::Text {
482                        text: "m.text".into(),
483                        title: Some("m.title".into()),
484                        font: None,
485                        font_size: None,
486                    },
487                    text("raw text"),
488                ],
489                ..Default::default()
490            }],
491            ..Default::default()
492        };
493        let out = book.localized(|k| {
494            if k.starts_with("m.") {
495                k.to_uppercase()
496            } else {
497                k.to_string()
498            }
499        });
500        assert_eq!(out.name, "M.BOOK.NAME");
501        assert_eq!(out.landing_text, "M.BOOK.LANDING");
502        assert_eq!(out.categories[0].name, "M.CAT");
503        assert_eq!(out.entries[0].name, "M.ENTRY");
504        assert!(
505            matches!(&out.entries[0].pages[0], BookPage::Text { text, title, .. } if text == "M.TEXT" && title.as_deref() == Some("M.TITLE"))
506        );
507        assert!(
508            matches!(&out.entries[0].pages[1], BookPage::Text { text, .. } if text == "raw text")
509        );
510    }
511}