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