Skip to main content

yog_book/renderer/
mod.rs

1//! Book renderer: yog-ui lays the book out and draws its text and
2//! buttons; everything else goes through `draw2d` too — the sprite
3//! sheets, item icons, SVG pages, entities — so the game and Yog-IDLE's
4//! book designer run the same code.
5
6mod flow;
7mod geometry;
8mod names;
9mod overlay;
10mod page;
11mod recipe;
12mod spread;
13mod widgets;
14
15use std::collections::HashMap;
16use std::time::Instant;
17
18use yog_formats::book::Book;
19use yog_gfx::{Color, GfxContext, Rect};
20use yog_ui::UiRoot;
21
22pub use geometry::{BLOCK_PITCH, SLOT_YAW};
23pub use overlay::PageDrawer;
24/// The image sources the book draws [`BOOK_PNG`] and [`CRAFTING_PNG`] as:
25/// the loader ships them under `assets/yog/textures/gui/book/` (Yog-IDLE
26/// resolves them to the bytes).
27pub use yog_ids::yog::{BOOK_SHEET, CRAFTING_SHEET};
28
29use crate::state::BookViewState;
30use crate::theme::BookTheme;
31use flow::{flow_traced, styled};
32use geometry::{BK_H, BK_W, MIN_SCALE, SCREEN_MARGIN};
33use overlay::{BgSprite, OverlayCmd};
34use recipe::{RecipeVis, parse_recipe};
35use spread::Spread;
36
37/// The book's sprite sheet (cover, pages, separators, nameplate, arrows).
38pub static BOOK_PNG: &[u8] = include_bytes!("../../assets/book_brown.png");
39/// The crafting-grid sprite sheet.
40pub static CRAFTING_PNG: &[u8] = include_bytes!("../../assets/crafting.png");
41/// The book's own words (`yog.book.*`) per language, as the loader's jars
42/// ship them in `assets/yog/lang/` (for hosts without them, like Yog-IDLE).
43pub static LANG: &[(&str, &str)] = &[
44    ("en_us", include_str!("../../assets/lang/en_us.json")),
45    ("ru_ru", include_str!("../../assets/lang/ru_ru.json")),
46];
47
48/// A book being read: what it shows, laid out for the screen it was last drawn on.
49pub struct BookRenderer {
50    pub book: Book,
51    pub state: BookViewState,
52    pub theme: BookTheme,
53    recipes: HashMap<String, RecipeVis>,
54    /// Per entry: which page as written each shown page came from.
55    origins: HashMap<String, Vec<usize>>,
56    /// The book as written (localized), before its text flowed.
57    written: Book,
58    /// Flowed with the host's own text metrics (else estimated ones).
59    measured: bool,
60    /// When the renderer was made: what animations count from.
61    start: Instant,
62    pub ui: Option<UiRoot>,
63    bg_sprites: Vec<BgSprite>,
64    overlays: Vec<OverlayCmd>,
65    dirty: bool,
66    /// The screen it was laid out for.
67    last_screen: (f32, f32),
68    /// i18n books: the untranslated book, the translator and the language
69    /// probe (the book name's text) the shown book was made for.
70    source: Option<Book>,
71    translator: Option<names::Translator>,
72    probe: Option<Option<String>>,
73}
74
75impl BookRenderer {
76    pub fn new(book: Book) -> Self {
77        let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
78        let written = styled(&book);
79        let (flowed, origins) = flow_traced(written.clone());
80        // Translated, it's flowed again from the book as written: flowing
81        // its keys would split them.
82        let source = book.i18n.then_some(book);
83        Self {
84            book: flowed,
85            origins,
86            written,
87            measured: false,
88            start: Instant::now(),
89            state: BookViewState::default(),
90            theme,
91            recipes: HashMap::new(),
92            ui: None,
93            bg_sprites: Vec::new(),
94            overlays: Vec::new(),
95            dirty: true,
96            last_screen: (0.0, 0.0),
97            translator: None,
98            probe: None,
99            source,
100        }
101    }
102
103    /// The book it draws (its long texts flowed onto more pages).
104    pub fn book(&self) -> &Book {
105        &self.book
106    }
107
108    /// The game's translations: items' names in recipe titles, and, for
109    /// [`Book::i18n`] books, the book's texts — translation keys, looked up
110    /// with `tr` (unknown keys stay as they are) and re-looked up whenever
111    /// the language changes.
112    pub fn set_translator(&mut self, tr: fn(&str) -> Option<String>) {
113        names::set_translator(tr);
114        if self.book.i18n {
115            self.translator = Some(tr);
116            self.probe = None;
117        }
118    }
119
120    /// Re-translates the book when the language changed since last time.
121    fn localize(&mut self) {
122        let (Some(src), Some(tr)) = (&self.source, self.translator) else {
123            return;
124        };
125        let probe = Some(tr(&src.name));
126        if probe == self.probe {
127            return;
128        }
129        let accent = src.accent.clone();
130        self.written = src.localized(|k| {
131            crate::markup::resolve(&tr(k).unwrap_or_else(|| k.to_string()), &accent)
132        });
133        (self.book, self.origins) = flow_traced(self.written.clone());
134        self.probe = probe;
135        self.dirty = true;
136    }
137
138    pub fn handle_event(&mut self, ev: &str) {
139        if self.state.handle(ev, &self.book) {
140            self.dirty = true;
141        }
142    }
143
144    /// Register a recipe (Yog's recipe, as Yog Data) so recipe pages can
145    /// render it visually. Unknown recipe types are ignored.
146    pub fn add_recipe(&mut self, id: impl Into<String>, recipe: &yog_data::Value) {
147        if let Some(vis) = parse_recipe(recipe) {
148            self.recipes.insert(id.into(), vis);
149            self.dirty = true;
150        }
151    }
152
153    /// Lays the book out for a `sw`×`sh` screen (when something changed);
154    /// the book's rect. It's drawn at its own size (272×180 GUI pixels),
155    /// smaller on a smaller screen, never bigger (the game's font is made for 1×).
156    fn prepare(&mut self, sw: f32, sh: f32) -> Rect {
157        let scale = ((sw - SCREEN_MARGIN) / BK_W)
158            .min((sh - SCREEN_MARGIN) / BK_H)
159            .clamp(MIN_SCALE, 1.0);
160        let (bw, bh) = (BK_W * scale, BK_H * scale);
161        let book_rect = Rect::new((sw - bw) / 2.0, (sh - bh) / 2.0, bw, bh);
162        self.localize();
163        if self.dirty || (sw, sh) != self.last_screen {
164            let sheet = names::book_sheet(&self.book.book_texture);
165            let spread = Spread {
166                book: &self.book,
167                state: &self.state,
168                theme: &self.theme,
169                recipes: &self.recipes,
170                sheet: &sheet,
171            };
172            let (root, bg_sprites, overlays) = spread.build(book_rect);
173            (self.ui, self.bg_sprites, self.overlays) = (Some(root), bg_sprites, overlays);
174            self.dirty = false;
175            self.last_screen = (sw, sh);
176        }
177        if let Some(ui) = &mut self.ui
178            && ui.needs_layout
179        {
180            ui.layout(sw, sh);
181        }
182        book_rect
183    }
184
185    /// Draws the book into a `sw`×`sh` screen — all of it through `draw2d`
186    /// (its sprite sheets as the image sources [`BOOK_SHEET`] and
187    /// [`CRAFTING_SHEET`]); mods' pictures (`BookPage::Drawn`) are left
188    /// empty — see [`BookRenderer::render_with`].
189    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
190        self.render_with(ctx, sw, sh, &mut |_, _, _, _| {});
191    }
192
193    /// Draws the book, mods' pictures with `draw(ctx, drawer, data, [x, y,
194    /// w, h])` (screen pixels) — the game calls the mod that registered `drawer`.
195    pub fn render_with(&mut self, ctx: &GfxContext, sw: f32, sh: f32, draw: PageDrawer<'_>) {
196        // Text flows by the host's own font metrics once it can measure.
197        yog_ui::text::use_host_metrics(ctx);
198        if !self.measured {
199            self.measured = true;
200            self.reflow();
201        }
202        let book_rect = self.prepare(sw, sh);
203        let sheet = names::book_sheet(&self.book.book_texture);
204        ctx.draw2d().image_region(
205            &sheet,
206            book_rect,
207            Some(Rect::new(0.0, 0.0, BK_W, BK_H)),
208            Color::WHITE,
209        );
210        overlay::draw_sprites(ctx, &self.bg_sprites, &sheet);
211        if let Some(ui) = &mut self.ui {
212            ui.render(ctx);
213        }
214        // Entities and structures turn (Patchouli's pace), an ingredient's
215        // alternatives take turns every second; items last (they change GL state).
216        let elapsed = self.start.elapsed();
217        overlay::draw_scenes(ctx, &self.overlays, elapsed.as_secs_f32(), draw);
218        overlay::draw_items(ctx, &self.overlays, elapsed.as_secs() as usize);
219    }
220
221    /// The page shown moves by itself (an entity, a structure, an
222    /// ingredient's alternatives): the host should keep redrawing it.
223    pub fn animated(&self) -> bool {
224        self.overlays.iter().any(OverlayCmd::animated)
225    }
226
227    /// A click at `(x, y)` (screen GUI pixels, as last rendered): what it
228    /// hit does its thing. The action it was (`"next_page"`, `"entry:3"`,
229    /// …), if any.
230    pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
231        let ui = self.ui.as_ref()?;
232        let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
233        let action = hit.on_click.clone()?;
234        self.handle_event(&action);
235        Some(action)
236    }
237
238    /// Shows `entry` (an entry id) at `page` — its page as written, not
239    /// counting the pages long text flowed onto — as a reader would have
240    /// got there; `false` if there's no such entry.
241    pub fn show(&mut self, entry: &str, page: usize) -> bool {
242        let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else {
243            return false;
244        };
245        let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else {
246            return false;
247        };
248        self.state.cat = cat;
249        self.state.at_home = false;
250        let Some(i) = self
251            .state
252            .entries_in_cat(&self.book)
253            .iter()
254            .position(|x| x.id == entry)
255        else {
256            return false;
257        };
258        let shown = self
259            .origins
260            .get(entry)
261            .and_then(|o| o.iter().position(|&w| w >= page))
262            .unwrap_or(page);
263        self.state.entry = i;
264        self.state.page = shown.min(e.pages.len().saturating_sub(1));
265        self.dirty = true;
266        true
267    }
268
269    /// The entry shown (its id) and the page of it as written, or `None`
270    /// on the landing and list pages.
271    pub fn shown(&self) -> Option<(String, usize)> {
272        if self.state.at_home {
273            return None;
274        }
275        let e = self.state.current_entry(&self.book)?;
276        let page = self
277            .origins
278            .get(&e.id)
279            .and_then(|o| o.get(self.state.page))
280            .copied()
281            .unwrap_or(self.state.page);
282        Some((e.id.clone(), page))
283    }
284
285    /// Flows the text again (the metrics changed), keeping the reader's place.
286    fn reflow(&mut self) {
287        let at = self.shown();
288        (self.book, self.origins) = flow_traced(self.written.clone());
289        self.dirty = true;
290        if let Some((entry, page)) = at {
291            self.show(&entry, page);
292        }
293    }
294
295    /// Replaces the book (an edit), keeping the reader where they were.
296    pub fn set_book(&mut self, book: Book) {
297        let at = self.shown();
298        self.written = styled(&book);
299        self.probe = None;
300        self.source = book.i18n.then_some(book);
301        (self.book, self.origins) = flow_traced(self.written.clone());
302        self.localize();
303        self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
304        self.dirty = true;
305        match at {
306            Some((entry, page)) if self.show(&entry, page) => {}
307            _ => {
308                if self.state.cat >= self.book.categories.len() {
309                    self.state = BookViewState::default();
310                }
311            }
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests;