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;
7pub(crate) mod 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_FILL, 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: about [`SCREEN_FILL`] of the screen whatever the
155    /// GUI scale, at whole multiples of its size (272×180 GUI pixels) so
156    /// the game's font stays sharp; smaller only on a screen it doesn't fit.
157    fn prepare(&mut self, sw: f32, sh: f32) -> Rect {
158        let room = ((sw - SCREEN_MARGIN) / BK_W).min((sh - SCREEN_MARGIN) / BK_H);
159        let wanted = (sh * SCREEN_FILL / BK_H).round().max(1.0);
160        let scale = if room >= 1.0 {
161            wanted.min(room.floor())
162        } else {
163            room.max(MIN_SCALE)
164        };
165        let (bw, bh) = (BK_W * scale, BK_H * scale);
166        let book_rect = Rect::new((sw - bw) / 2.0, (sh - bh) / 2.0, bw, bh);
167        self.localize();
168        if self.dirty || (sw, sh) != self.last_screen {
169            let sheet = names::book_sheet(&self.book.book_texture);
170            let spread = Spread {
171                book: &self.book,
172                state: &self.state,
173                theme: &self.theme,
174                recipes: &self.recipes,
175                sheet: &sheet,
176            };
177            let (root, bg_sprites, overlays) = spread.build(book_rect);
178            (self.ui, self.bg_sprites, self.overlays) = (Some(root), bg_sprites, overlays);
179            self.dirty = false;
180            self.last_screen = (sw, sh);
181        }
182        if let Some(ui) = &mut self.ui
183            && ui.needs_layout
184        {
185            ui.layout(sw, sh);
186        }
187        book_rect
188    }
189
190    /// Draws the book into a `sw`×`sh` screen — all of it through `draw2d`
191    /// (its sprite sheets as the image sources [`BOOK_SHEET`] and
192    /// [`CRAFTING_SHEET`]); mods' pictures (`BookPage::Drawn`) are left
193    /// empty — see [`BookRenderer::render_with`].
194    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
195        self.render_with(ctx, sw, sh, &mut |_, _, _, _| {});
196    }
197
198    /// Draws the book, mods' pictures with `draw(ctx, drawer, data, [x, y,
199    /// w, h])` (screen pixels) — the game calls the mod that registered `drawer`.
200    pub fn render_with(&mut self, ctx: &GfxContext, sw: f32, sh: f32, draw: PageDrawer<'_>) {
201        // Text flows by the host's own font metrics once it can measure.
202        yog_ui::text::use_host_metrics(ctx);
203        if !self.measured {
204            self.measured = true;
205            self.reflow();
206        }
207        let book_rect = self.prepare(sw, sh);
208        let sheet = names::book_sheet(&self.book.book_texture);
209        ctx.draw2d().image_region(
210            &sheet,
211            book_rect,
212            Some(Rect::new(0.0, 0.0, BK_W, BK_H)),
213            Color::WHITE,
214        );
215        overlay::draw_sprites(ctx, &self.bg_sprites, &sheet);
216        if let Some(ui) = &mut self.ui {
217            ui.render(ctx);
218        }
219        // Entities and structures turn (Patchouli's pace), an ingredient's
220        // alternatives take turns every second; items last (they change GL state).
221        let elapsed = self.start.elapsed();
222        overlay::draw_scenes(ctx, &self.overlays, elapsed.as_secs_f32(), draw);
223        overlay::draw_items(ctx, &self.overlays, elapsed.as_secs() as usize);
224    }
225
226    /// The page shown moves by itself (an entity, a structure, an
227    /// ingredient's alternatives): the host should keep redrawing it.
228    pub fn animated(&self) -> bool {
229        self.overlays.iter().any(OverlayCmd::animated)
230    }
231
232    /// A click at `(x, y)` (screen GUI pixels, as last rendered): what it
233    /// hit does its thing. The action it was (`"next_page"`, `"entry:3"`,
234    /// …), if any.
235    pub fn click(&mut self, x: f32, y: f32) -> Option<String> {
236        let ui = self.ui.as_ref()?;
237        let hit = yog_ui::layout::hit_test(&ui.layout_root, x, y)?;
238        let action = hit.on_click.clone()?;
239        self.handle_event(&action);
240        Some(action)
241    }
242
243    /// Shows `entry` (an entry id) at `page` — its page as written, not
244    /// counting the pages long text flowed onto — as a reader would have
245    /// got there; `false` if there's no such entry.
246    pub fn show(&mut self, entry: &str, page: usize) -> bool {
247        let Some(e) = self.book.entries.iter().find(|e| e.id == entry) else {
248            return false;
249        };
250        let Some(cat) = self.book.categories.iter().position(|c| c.id == e.category) else {
251            return false;
252        };
253        self.state.cat = cat;
254        self.state.at_home = false;
255        let Some(i) = self
256            .state
257            .entries_in_cat(&self.book)
258            .iter()
259            .position(|x| x.id == entry)
260        else {
261            return false;
262        };
263        let shown = self
264            .origins
265            .get(entry)
266            .and_then(|o| o.iter().position(|&w| w >= page))
267            .unwrap_or(page);
268        self.state.entry = i;
269        self.state.page = shown.min(e.pages.len().saturating_sub(1));
270        self.dirty = true;
271        true
272    }
273
274    /// The entry shown (its id) and the page of it as written, or `None`
275    /// on the landing and list pages.
276    pub fn shown(&self) -> Option<(String, usize)> {
277        if self.state.at_home {
278            return None;
279        }
280        let e = self.state.current_entry(&self.book)?;
281        let page = self
282            .origins
283            .get(&e.id)
284            .and_then(|o| o.get(self.state.page))
285            .copied()
286            .unwrap_or(self.state.page);
287        Some((e.id.clone(), page))
288    }
289
290    /// Flows the text again (the metrics changed), keeping the reader's place.
291    fn reflow(&mut self) {
292        let at = self.shown();
293        (self.book, self.origins) = flow_traced(self.written.clone());
294        self.dirty = true;
295        if let Some((entry, page)) = at {
296            self.show(&entry, page);
297        }
298    }
299
300    /// Replaces the book (an edit), keeping the reader where they were.
301    pub fn set_book(&mut self, book: Book) {
302        let at = self.shown();
303        self.written = styled(&book);
304        self.probe = None;
305        self.source = book.i18n.then_some(book);
306        (self.book, self.origins) = flow_traced(self.written.clone());
307        self.localize();
308        self.theme = BookTheme::default().with_nameplate(&self.book.nameplate_color);
309        self.dirty = true;
310        match at {
311            Some((entry, page)) if self.show(&entry, page) => {}
312            _ => {
313                if self.state.cat >= self.book.categories.len() {
314                    self.state = BookViewState::default();
315                }
316            }
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests;