Skip to main content

ui_fonts/
ui-fonts.rs

1//! A font is a source like any other: `startup.font` reads one
2//! `with_assets` loaded, an `egui::FontDefinitions` names it first in a
3//! family, and `startup.set_fonts` draws the UI in it from the first frame
4//! on.
5//!
6//! A corner layer names its own fonts over a scene: three `StationKind`
7//! cubes placed around a round platform's edge. Each draws a `Prompt` for
8//! `Trigger::Hail` above it; a `bracket` draws its name, a small reading
9//! and a large number in the `display` family instead, under hover.
10//! Clicking one plays two lines, typed one glyph a tick, in a box sized
11//! once from the whole line. `Tab` opens a font sample over the three
12//! families the game loaded. The camera turns on its own and by a held
13//! left button, and the wheel zooms it about the platform's center.
14
15use core::time::Duration;
16use std::sync::Arc;
17
18use mirage_engine::prelude::*;
19
20const PROPORTIONAL_FONT: &str = "pixel-operator";
21const MONOSPACE_FONT: &str = "pixel-operator-mono";
22const DISPLAY_FONT: &str = "ferrum";
23const DISPLAY_FAMILY: &str = "display";
24const PIXEL_ONLY_FAMILY: &str = "pixel-only";
25const PROMPT_FONT: &str = "kenney-input-keyboard-mouse";
26const PROMPT_FAMILY: &str = "prompts";
27
28/// A pixel font's own grid, the sizes it draws crisp at; the `display`
29/// and `prompts` families are vector faces and take any size.
30const BODY_SIZE: f32 = 16.0;
31const HEADING_SIZE: f32 = 32.0;
32const NUMBER_SIZE: f32 = 28.0;
33const PROMPT_SIZE: f32 = 32.0;
34const TEXT_COLOR: egui::Color32 = egui::Color32::from_gray(230);
35
36const PLATFORM_SIZE: f32 = 8.0;
37const PLATFORM_COLOR: Color = Color::rgb(0.05, 0.05, 0.06);
38
39/// A `StationKind` center's own distance from the platform's center, and
40/// its cube, width by height by depth, in meters.
41const STATION_RADIUS: f32 = 3.1;
42const STATION_SIZE: Vec3 = Vec3::new(0.6, 0.9, 0.5);
43/// The emissive cube set against the `STATION_SIZE` cube's own `-Z` side.
44const STATION_FRONT_SIZE: Vec3 = Vec3::new(0.42, 0.5, 0.06);
45/// The distance past the `STATION_SIZE` cube's own face the emissive
46/// cube's front keeps, in meters, so the two stay flush at a shallow
47/// angle.
48const STATION_FRONT_OUTWARD: f32 = 0.005;
49
50const RADAR_SWEEP_RATE: f32 = 40.0;
51const REACTOR_RATE: f32 = 0.5;
52const REACTOR_BASE: f32 = 55.0;
53const REACTOR_SWING: f32 = 35.0;
54
55const SUN_DIRECTION: Vec3 = Vec3::new(0.4, -1.0, -0.3);
56const SUN_COLOR: Color = Color::rgb(0.6, 0.62, 0.7);
57
58const CAMERA_FOV: f32 = 42.0;
59const CAMERA_TARGET: Vec3 = Vec3::new(0.0, STATION_SIZE.y * 0.5, 0.0);
60const START_YAW: f32 = 0.4;
61const START_PITCH: f32 = 0.35;
62/// How far short of straight up or down the pitch may turn, in radians.
63const PITCH_LIMIT: f32 = 0.9;
64const START_DISTANCE: f32 = 8.0;
65const MIN_DISTANCE: f32 = 3.0;
66const MAX_DISTANCE: f32 = 14.0;
67/// Radians the camera turns by on its own, per second.
68const AUTO_TURN_RATE: f32 = 0.12;
69/// Radians the pointer's own motion turns the view by, per physical pixel
70/// it crosses, while [`Trigger::Hail`] is held.
71const TURN_SENSITIVITY: f32 = core::f32::consts::FRAC_PI_2 / 1280.0;
72/// The factor one full wheel step divides the distance to the target by.
73const ZOOM_STEP: f32 = 1.12;
74
75const BRACKET_MARGIN: f32 = 10.0;
76const BRACKET_STROKE: f32 = 2.0;
77const BRACKET_CORNER: f32 = 7.0;
78/// The gap kept past the line above's own full line height, so a glyph
79/// whose `mesh_bounds` reaches past that height still clears the line
80/// below.
81const STACK_GAP: f32 = 4.0;
82
83/// The gap between a `StationKind`'s own top and the `Prompt` drawn above
84/// it, in logical points.
85const PROMPT_LIFT: f32 = 20.0;
86
87const DIALOGUE_PADDING_X: f32 = 28.0;
88const DIALOGUE_PADDING_Y: f32 = 20.0;
89const DIALOGUE_MARGIN: f32 = 24.0;
90
91/// A sample the pixel font holds no glyph for, so `Proportional` falls
92/// back to egui's own font and `pixel-only` shows the missing glyph box.
93const FALLBACK_SAMPLE: &str = "café λ";
94/// Different widths of glyph together, so a family's own advance shows.
95const FAMILY_SAMPLE: &str = "mill and wall";
96const GRID_SAMPLE: &str = "the quick fox";
97
98meshes! { enum Shape { Cube, Plane } }
99
100/// This game's own sky: a dark, dim gradient, so each `StationKind`'s own
101/// emissive glow still reads as the platform's brightest color.
102#[derive(Catalog, Clone, Copy, Debug, PartialEq, Eq, Hash)]
103enum Sky {
104    Dusk,
105}
106
107impl Skyboxes for Sky {
108    fn build(&self, _assets: &Assets) -> SkyboxData {
109        SkyboxData::gradient(
110            Color::rgb(0.05, 0.06, 0.12),
111            Color::rgb(0.18, 0.12, 0.16),
112            Color::rgb(0.01, 0.01, 0.02),
113        )
114        .lit_by(0.25)
115    }
116}
117
118/// What one `StationKind` is drawn and named as.
119struct StationLook {
120    name: &'static str,
121    color: Color,
122    glow: Color,
123    lines: [&'static str; 2],
124}
125
126#[derive(Clone, Copy, PartialEq, Eq)]
127enum StationKind {
128    Radar,
129    Reactor,
130    Clock,
131}
132
133impl StationKind {
134    const ALL: [Self; 3] = [Self::Radar, Self::Reactor, Self::Clock];
135
136    fn index(self) -> usize {
137        self as usize
138    }
139
140    fn look(self) -> StationLook {
141        match self {
142            Self::Radar => StationLook {
143                name: "radar",
144                color: Color::rgb(0.22, 0.26, 0.30),
145                glow: Color::rgb(0.3, 1.4, 1.1),
146                lines: [
147                    "the radar sweeps the dark past the platform for anything that moves",
148                    "nothing answers back tonight",
149                ],
150            },
151            Self::Reactor => StationLook {
152                name: "reactor",
153                color: Color::rgb(0.30, 0.22, 0.18),
154                glow: Color::rgb(1.6, 0.7, 0.2),
155                lines: [
156                    "the reactor gauge holds steady at a comfortable idle",
157                    "plenty of power left for the long watch ahead",
158                ],
159            },
160            Self::Clock => StationLook {
161                name: "clock",
162                color: Color::rgb(0.20, 0.24, 0.22),
163                glow: Color::rgb(0.8, 0.9, 1.6),
164                lines: [
165                    "the clock keeps the same count it always has",
166                    "the watch ends when it says so and not before",
167                ],
168            },
169        }
170    }
171
172    /// This `StationKind`'s own center, on the platform's edge.
173    fn center(self) -> Vec3 {
174        let angle = self.index() as f32 / Self::ALL.len() as f32 * core::f32::consts::TAU;
175        Vec3::new(
176            STATION_RADIUS * angle.cos(),
177            STATION_SIZE.y * 0.5,
178            STATION_RADIUS * angle.sin(),
179        )
180    }
181
182    fn aabb(self) -> (Vec3, Vec3) {
183        let half = STATION_SIZE * 0.5;
184        (self.center() - half, self.center() + half)
185    }
186
187    /// The reading a `bracket` shows through `Monospace`, and the one
188    /// large number it shows through the `display` family, both from
189    /// `elapsed` seconds.
190    fn reading(self, elapsed: f32) -> (String, String) {
191        match self {
192            Self::Radar => {
193                let bearing = (elapsed * RADAR_SWEEP_RATE).rem_euclid(360.0);
194                (
195                    format!("{bearing:.0} degrees bearing"),
196                    format!("{bearing:03.0}"),
197                )
198            }
199            Self::Reactor => {
200                let percent = REACTOR_BASE + REACTOR_SWING * (elapsed * REACTOR_RATE).sin();
201                (
202                    format!("{percent:.0} percent output"),
203                    format!("{percent:.0}%"),
204                )
205            }
206            Self::Clock => {
207                let seconds = elapsed.rem_euclid(60.0);
208                (
209                    format!("{seconds:.1} seconds this minute"),
210                    format!("{seconds:04.1}"),
211                )
212            }
213        }
214    }
215}
216
217/// The `StationKind` `ray` lies nearest along, if any.
218fn hit_station(ray: Ray) -> Option<StationKind> {
219    StationKind::ALL
220        .into_iter()
221        .filter_map(|station| {
222            let (min, max) = station.aabb();
223            ray.hit_aabb(min, max).map(|distance| (distance, station))
224        })
225        .min_by(|(a, _), (b, _)| a.total_cmp(b))
226        .map(|(_, station)| station)
227}
228
229/// The logical point egui paints `pixel`, a physical pixel, at.
230fn logical(pixel: Vec2, pixels_per_point: f32) -> egui::Pos2 {
231    let point = pixel / pixels_per_point;
232    egui::pos2(point.x, point.y)
233}
234
235/// `text` at `font`, in this game's own text color.
236fn styled(text: impl Into<String>, font: egui::FontId) -> egui::RichText {
237    egui::RichText::new(text.into())
238        .font(font)
239        .color(TEXT_COLOR)
240}
241
242/// The position that draws `galley` with its `mesh_bounds` centered on
243/// `center_x` and its `mesh_bounds`'s own bottom at `bottom`.
244fn ink_bottom_at(galley: &egui::Galley, center_x: f32, bottom: f32) -> egui::Pos2 {
245    let ink = galley.mesh_bounds;
246    egui::pos2(center_x - ink.center().x, bottom - ink.max.y)
247}
248
249/// How a control reads to a player: a glyph the `prompts` family draws,
250/// or a control's own name in the `Proportional` font.
251enum Prompt {
252    Glyph(char),
253    Text(String),
254}
255
256impl Prompt {
257    fn text(&self) -> String {
258        match self {
259            Self::Glyph(glyph) => glyph.to_string(),
260            Self::Text(text) => text.clone(),
261        }
262    }
263
264    fn family(&self) -> egui::FontFamily {
265        match self {
266            Self::Glyph(_) => egui::FontFamily::Name(PROMPT_FAMILY.into()),
267            Self::Text(_) => egui::FontFamily::Proportional,
268        }
269    }
270}
271
272/// `binding` read for a player: a mouse glyph for its left or right
273/// button, or its own `Display` text otherwise, so a key reads as `E`
274/// and a pad button as its name.
275fn prompt(binding: &ButtonBinding) -> Prompt {
276    match binding {
277        ButtonBinding::Mouse(MouseButton::Left) => Prompt::Glyph('\u{E0EC}'),
278        ButtonBinding::Mouse(MouseButton::Right) => Prompt::Glyph('\u{E0F0}'),
279        other => Prompt::Text(other.to_string()),
280    }
281}
282
283/// A name in `corners`, a reading and a number, placed one above the
284/// other upward from `at`, which sits right above a `StationKind`'s top,
285/// one line height clear of it: the name boxed and nearest `at`, the
286/// reading a full line height above it, the number a full line height
287/// above that, so no two lines and no line and the box ever overlap.
288///
289/// Each gap is measured in the line below's own full line height, not its
290/// smaller `mesh_bounds`, so a glyph whose `mesh_bounds` reaches past that
291/// height still clears the line above it. The name's own frame is sized to
292/// its `mesh_bounds`, margin included, and the name centers inside it on
293/// every side. The caller measures every `Galley`; this only draws. Lifts
294/// into another game unchanged.
295fn bracket(
296    painter: &egui::Painter,
297    at: egui::Pos2,
298    name: Arc<egui::Galley>,
299    reading: Arc<egui::Galley>,
300    number: Arc<egui::Galley>,
301) {
302    let frame_bottom = at.y - name.rect.height();
303    let name_ink = name.mesh_bounds;
304    let frame_height = name_ink.height() + BRACKET_MARGIN * 2.0;
305    let frame = egui::Rect::from_min_size(
306        egui::pos2(
307            at.x - name_ink.width() * 0.5 - BRACKET_MARGIN,
308            frame_bottom - frame_height,
309        ),
310        egui::vec2(name_ink.width() + BRACKET_MARGIN * 2.0, frame_height),
311    );
312    let name_pos = ink_bottom_at(&name, at.x, frame_bottom - BRACKET_MARGIN);
313
314    let reading_bottom = frame.top() - STACK_GAP;
315    let reading_pos = ink_bottom_at(&reading, at.x, reading_bottom);
316
317    let number_bottom = reading_pos.y - reading.rect.height() - STACK_GAP;
318    let number_pos = ink_bottom_at(&number, at.x, number_bottom);
319
320    corners(painter, frame);
321    painter.galley(name_pos, name, TEXT_COLOR);
322    painter.galley(reading_pos, reading, TEXT_COLOR);
323    painter.galley(number_pos, number, TEXT_COLOR);
324}
325
326/// `glyph`, its `mesh_bounds` centered on `at`. Lifts into another game
327/// unchanged.
328fn prompt_at(painter: &egui::Painter, at: egui::Pos2, glyph: Arc<egui::Galley>) {
329    let ink = glyph.mesh_bounds;
330    let pos = egui::pos2(at.x - ink.center().x, at.y - ink.center().y);
331    painter.galley(pos, glyph, TEXT_COLOR);
332}
333
334/// Four short lines at `rect`'s own corners, in place of its sides.
335fn corners(painter: &egui::Painter, rect: egui::Rect) {
336    let stroke = egui::Stroke::new(BRACKET_STROKE, TEXT_COLOR);
337    for (corner, inward) in [
338        (rect.left_top(), egui::vec2(1.0, 1.0)),
339        (rect.right_top(), egui::vec2(-1.0, 1.0)),
340        (rect.left_bottom(), egui::vec2(1.0, -1.0)),
341        (rect.right_bottom(), egui::vec2(-1.0, -1.0)),
342    ] {
343        painter.line_segment(
344            [corner, corner + egui::vec2(inward.x * BRACKET_CORNER, 0.0)],
345            stroke,
346        );
347        painter.line_segment(
348            [corner, corner + egui::vec2(0.0, inward.y * BRACKET_CORNER)],
349            stroke,
350        );
351    }
352}
353
354/// A name shown large and one line typed a glyph a tick, over two lines a
355/// click steps through. Holds no `StationKind` of its own, so it lifts
356/// into another game unchanged.
357struct Dialogue {
358    name: String,
359    lines: [String; 2],
360    line: usize,
361    revealed: usize,
362}
363
364impl Dialogue {
365    fn start(name: impl Into<String>, lines: [String; 2]) -> Self {
366        Self {
367            name: name.into(),
368            lines,
369            line: 0,
370            revealed: 0,
371        }
372    }
373
374    fn current_line(&self) -> &str {
375        &self.lines[self.line]
376    }
377
378    /// Shows one more glyph of the current line, up to its whole length.
379    fn tick(&mut self) {
380        let len = self.current_line().chars().count();
381        self.revealed = (self.revealed + 1).min(len);
382    }
383
384    /// Steps to the next line; `false` where the last line was already
385    /// shown, for the caller to close instead.
386    fn advance(&mut self) -> bool {
387        if self.line + 1 < self.lines.len() {
388            self.line += 1;
389            self.revealed = 0;
390            true
391        } else {
392            false
393        }
394    }
395
396    /// Draws the box at a size `whole_line` set, so it never grows as the
397    /// glyphs of the same line arrive: `set_min_size` inside the window
398    /// holds that size from the frame this window first draws, rather
399    /// than egui's own resize state, which only grows a window to match
400    /// what the frame before it drew.
401    fn draw(&self, ui: &mut egui::Ui, whole_line: egui::Vec2) {
402        let display = egui::FontFamily::Name(DISPLAY_FAMILY.into());
403        let box_size = egui::vec2(
404            whole_line.x + DIALOGUE_PADDING_X,
405            whole_line.y + HEADING_SIZE + DIALOGUE_PADDING_Y,
406        );
407        egui::Window::new("hail")
408            .title_bar(false)
409            .resizable(false)
410            .collapsible(false)
411            .anchor(
412                egui::Align2::CENTER_BOTTOM,
413                egui::vec2(0.0, -DIALOGUE_MARGIN),
414            )
415            .show(ui.ctx(), |ui| {
416                ui.set_min_size(box_size);
417                ui.label(styled(&self.name, egui::FontId::new(HEADING_SIZE, display)));
418                let shown: String = self.current_line().chars().take(self.revealed).collect();
419                ui.label(styled(shown, egui::FontId::proportional(BODY_SIZE)));
420            });
421    }
422}
423
424/// Every family this game loaded, its name at `16` points beside a sample
425/// at `32` in that family; the same sample under `Proportional`, where
426/// egui's own fonts back what the pixel font holds no glyph for, beside
427/// it again under a family that holds only the pixel font; and the pixel
428/// font at `16` points beside itself at `17`, where its own grid stops
429/// holding it crisp.
430fn sheet(ui: &mut egui::Ui) {
431    egui::Window::new("font sheet")
432        .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
433        .collapsible(false)
434        .resizable(false)
435        .show(ui.ctx(), |ui| {
436            egui::Grid::new("font sheet grid").show(ui, |ui| {
437                for (label, family) in [
438                    ("proportional", egui::FontFamily::Proportional),
439                    ("monospace", egui::FontFamily::Monospace),
440                    ("display", egui::FontFamily::Name(DISPLAY_FAMILY.into())),
441                ] {
442                    ui.label(styled(label, egui::FontId::proportional(BODY_SIZE)));
443                    ui.label(styled(
444                        FAMILY_SAMPLE,
445                        egui::FontId::new(HEADING_SIZE, family),
446                    ));
447                    ui.end_row();
448                }
449
450                let pixel_only = egui::FontFamily::Name(PIXEL_ONLY_FAMILY.into());
451                for (label, family) in [
452                    ("egui's fonts behind", egui::FontFamily::Proportional),
453                    ("pixel font alone", pixel_only),
454                ] {
455                    ui.label(styled(label, egui::FontId::proportional(BODY_SIZE)));
456                    ui.label(styled(
457                        FALLBACK_SAMPLE,
458                        egui::FontId::new(BODY_SIZE, family),
459                    ));
460                    ui.end_row();
461                }
462
463                for (label, size) in [("16 points", 16.0), ("17 points", 17.0)] {
464                    ui.label(styled(label, egui::FontId::proportional(BODY_SIZE)));
465                    ui.label(styled(GRID_SAMPLE, egui::FontId::proportional(size)));
466                    ui.end_row();
467                }
468            });
469        });
470}
471
472#[derive(InputButtonAction, Clone, Copy)]
473enum Trigger {
474    Hail,
475    Sheet,
476    Close,
477}
478
479impl InputButtonAction for Trigger {
480    fn bindings(&self) -> Vec<ButtonBinding> {
481        match self {
482            Self::Hail => vec![MouseButton::Left.into()],
483            Self::Sheet => vec![Key::Tab.into()],
484            Self::Close => vec![Key::Escape.into()],
485        }
486    }
487}
488
489/// The pointer's own motion, read only while [`Trigger::Hail`] is held.
490#[derive(InputAxis2Action, Clone, Copy)]
491enum Turn {
492    Look,
493}
494
495impl InputAxis2Action for Turn {
496    fn bindings(&self) -> Vec<Axis2Binding> {
497        match self {
498            Self::Look => vec![Axis2Binding::pointer().scale(TURN_SENSITIVITY)],
499        }
500    }
501}
502
503#[derive(InputAxisAction, Clone, Copy)]
504enum Zoom {
505    Wheel,
506}
507
508impl InputAxisAction for Zoom {
509    fn bindings(&self) -> Vec<AxisBinding> {
510        match self {
511            Self::Wheel => vec![AxisBinding::from(WheelDelta::Up).scale(4.0)],
512        }
513    }
514}
515
516struct Controls;
517
518impl InputActions for Controls {
519    type Button = Trigger;
520    type Axis = Zoom;
521    type Axis2 = Turn;
522}
523
524/// Reads every name in `names` and adds them under `family`, front to
525/// back in the order given, ahead of whatever `family` already held.
526fn install_family(
527    fonts: &mut egui::FontDefinitions,
528    startup: &mut Startup,
529    family: egui::FontFamily,
530    names: &[&str],
531) -> Result<(), Error> {
532    for &name in names {
533        if !fonts.font_data.contains_key(name) {
534            fonts
535                .font_data
536                .insert(name.to_owned(), startup.font(name)?.into());
537        }
538    }
539    let list = fonts.families.entry(family).or_default();
540    for &name in names.iter().rev() {
541        list.insert(0, name.to_owned());
542    }
543    Ok(())
544}
545
546/// The camera around [`CAMERA_TARGET`], its own distance clamped between
547/// [`MIN_DISTANCE`] and [`MAX_DISTANCE`], `yaw` free and `pitch` held to
548/// [`PITCH_LIMIT`]. Holds nothing of the game, so it copies into another
549/// one with its own values.
550struct Orbit {
551    yaw: f32,
552    pitch: f32,
553    distance: f32,
554}
555
556impl Default for Orbit {
557    fn default() -> Self {
558        Self {
559            yaw: START_YAW,
560            pitch: START_PITCH,
561            distance: START_DISTANCE,
562        }
563    }
564}
565
566impl Orbit {
567    fn camera(&self) -> Camera {
568        let direction = Vec3::new(
569            self.pitch.cos() * self.yaw.sin(),
570            self.pitch.sin(),
571            self.pitch.cos() * self.yaw.cos(),
572        );
573        Camera::new(
574            View::look_at(CAMERA_TARGET + direction * self.distance, CAMERA_TARGET),
575            Projection::perspective(CAMERA_FOV),
576        )
577    }
578
579    /// Turns `yaw` by `-by.x` and `pitch` by `by.y`, `pitch` held to its
580    /// limit.
581    fn turn(&mut self, by: Vec2) {
582        self.yaw -= by.x;
583        self.pitch = (self.pitch + by.y).clamp(-PITCH_LIMIT, PITCH_LIMIT);
584    }
585
586    /// Divides the distance to [`CAMERA_TARGET`] by `factor`, held to its
587    /// own least and most.
588    fn zoom(&mut self, factor: f32) {
589        self.distance = (self.distance / factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
590    }
591}
592
593struct WatchRoom {
594    elapsed: Duration,
595    orbit: Orbit,
596    hailed: Option<StationKind>,
597    dialogue: Option<Dialogue>,
598    sheet_open: bool,
599}
600
601impl WatchRoom {
602    fn init(ctx: &mut InitContext<'_, Self>) -> Result<Self, Error> {
603        let startup = ctx.startup();
604        let mut fonts = egui::FontDefinitions::default();
605        for (family, names) in [
606            (egui::FontFamily::Proportional, &[PROPORTIONAL_FONT][..]),
607            (egui::FontFamily::Monospace, &[MONOSPACE_FONT][..]),
608            (
609                egui::FontFamily::Name(DISPLAY_FAMILY.into()),
610                &[DISPLAY_FONT][..],
611            ),
612            (
613                egui::FontFamily::Name(PIXEL_ONLY_FAMILY.into()),
614                &[PROPORTIONAL_FONT][..],
615            ),
616            (
617                egui::FontFamily::Name(PROMPT_FAMILY.into()),
618                &[PROMPT_FONT, PROPORTIONAL_FONT][..],
619            ),
620        ] {
621            install_family(&mut fonts, startup, family, names)?;
622        }
623        startup.set_fonts(fonts)?;
624
625        Ok(Self {
626            elapsed: Duration::ZERO,
627            orbit: Orbit::default(),
628            hailed: None,
629            dialogue: None,
630            sheet_open: false,
631        })
632    }
633
634    fn handle_hail(&mut self, ctx: &mut TickContext<'_, Self>) {
635        let ray = ctx
636            .last_camera()
637            .ray_through(ctx.pointer(), ctx.window_size());
638        let Some(station) = hit_station(ray) else {
639            return;
640        };
641
642        if self.hailed != Some(station) {
643            self.hailed = Some(station);
644            let look = station.look();
645            self.dialogue = Some(Dialogue::start(look.name, look.lines.map(str::to_owned)));
646            return;
647        }
648        let Some(dialogue) = &mut self.dialogue else {
649            return;
650        };
651        if !dialogue.advance() {
652            self.dialogue = None;
653            self.hailed = None;
654        }
655    }
656
657    fn draw_station(&self, ctx: &mut FrameContext<'_, Self>, station: StationKind) {
658        let look = station.look();
659        let center = station.center();
660        let front_offset =
661            STATION_SIZE.z * 0.5 - STATION_FRONT_SIZE.z * 0.5 + STATION_FRONT_OUTWARD;
662        let front = center - Vec3::new(0.0, 0.0, front_offset);
663        for (size, position, material) in [
664            (STATION_SIZE, center, Material::lit(look.color)),
665            (
666                STATION_FRONT_SIZE,
667                front,
668                Material::color(Color::BLACK).emissive(look.glow),
669            ),
670        ] {
671            ctx.draw(
672                Cube.at(Transform::from_scale_rotation_translation(
673                    size,
674                    Quat::IDENTITY,
675                    position,
676                ))
677                .material(material),
678            );
679        }
680    }
681
682    fn draw_bracket(&self, ctx: &mut FrameContext<'_, Self>, camera: Camera, station: StationKind) {
683        let top = station.center() + Vec3::Y * (STATION_SIZE.y * 0.5);
684        let window_size = ctx.window_size();
685        let Some(pixel) = camera.pixel_of(top, window_size) else {
686            return;
687        };
688        let at = logical(pixel, ctx.pixels_per_point());
689
690        let name = ctx.text_layout(station.look().name, egui::FontId::proportional(BODY_SIZE));
691        let (reading_text, number_text) = station.reading(self.elapsed.as_secs_f32());
692        let reading = ctx.text_layout(&reading_text, egui::FontId::monospace(BODY_SIZE));
693        let number = ctx.text_layout(
694            &number_text,
695            egui::FontId::new(NUMBER_SIZE, egui::FontFamily::Name(DISPLAY_FAMILY.into())),
696        );
697
698        ctx.ui(|ui| bracket(ui.painter(), at, name, reading, number));
699    }
700
701    /// A `Prompt` for `Trigger::Hail`, above every `StationKind` but
702    /// `hovered`: what a player presses to reach one, apart from a hover.
703    fn draw_prompts(
704        &self,
705        ctx: &mut FrameContext<'_, Self>,
706        camera: Camera,
707        hovered: Option<StationKind>,
708    ) {
709        let Some(binding) = ctx.bindings(Trigger::Hail).into_iter().next() else {
710            return;
711        };
712        let hint = prompt(&binding);
713        let glyph = ctx.text_layout(&hint.text(), egui::FontId::new(PROMPT_SIZE, hint.family()));
714        let window_size = ctx.window_size();
715        let pixels_per_point = ctx.pixels_per_point();
716
717        ctx.ui(|ui| {
718            let painter = ui.painter();
719            for station in StationKind::ALL {
720                if Some(station) == hovered {
721                    continue;
722                }
723                let top = station.center() + Vec3::Y * (STATION_SIZE.y * 0.5);
724                let Some(pixel) = camera.pixel_of(top, window_size) else {
725                    continue;
726                };
727                let at = logical(pixel, pixels_per_point);
728                let at = egui::pos2(at.x, at.y - PROMPT_LIFT);
729                prompt_at(painter, at, glyph.clone());
730            }
731        });
732    }
733
734    /// The title, a line and the reading, each in a font this game loaded
735    /// rather than egui's own.
736    fn panel(&self, ctx: &mut FrameContext<'_, Self>) {
737        ctx.ui(|ui| {
738            ui.label(styled(
739                "a game's own fonts",
740                egui::FontId::proportional(HEADING_SIZE),
741            ));
742            ui.label(styled(
743                "drawn in Pixel Operator, the game's proportional font",
744                egui::FontId::proportional(BODY_SIZE),
745            ));
746            ui.label(styled(
747                "the readings above each station in Pixel Operator Mono",
748                egui::FontId::monospace(BODY_SIZE),
749            ));
750        });
751    }
752
753    fn draw_dialogue(&self, ctx: &mut FrameContext<'_, Self>) {
754        let Some(dialogue) = &self.dialogue else {
755            return;
756        };
757        let whole = ctx.text_layout(
758            dialogue.current_line(),
759            egui::FontId::proportional(BODY_SIZE),
760        );
761        let size = whole.size();
762        ctx.ui(|ui| dialogue.draw(ui, size));
763    }
764
765    /// The `StationKind` under the pointer, `None` while the UI holds it.
766    fn hovered(ctx: &FrameContext<'_, Self>) -> Option<StationKind> {
767        if ctx.ui_wants_pointer() {
768            return None;
769        }
770        hit_station(
771            ctx.last_camera()
772                .ray_through(ctx.pointer(), ctx.window_size()),
773        )
774    }
775
776    /// A held [`Trigger::Hail`] turns the camera by the pointer's own
777    /// motion; the wheel zooms it.
778    fn steer(&mut self, ctx: &mut FrameContext<'_, Self>) {
779        if !ctx.ui_wants_pointer() && ctx.down(Trigger::Hail) {
780            self.orbit.turn(ctx.axis2(Turn::Look));
781        }
782        let wheel = ctx.axis(Zoom::Wheel);
783        if !ctx.ui_wants_pointer() && wheel != 0.0 {
784            self.orbit.zoom(ZOOM_STEP.powf(wheel));
785        }
786    }
787}
788
789impl Game for WatchRoom {
790    type Meshes = Shape;
791    type Sounds = NoSounds;
792    type InputActions = Controls;
793    type Skyboxes = Sky;
794    type SurfaceStyles = NoSurfaceStyles;
795    type PostEffects = NoPostEffects;
796
797    fn tick(&mut self, ctx: &mut TickContext<'_, Self>) {
798        self.elapsed += ctx.dt();
799        self.orbit.yaw += AUTO_TURN_RATE * ctx.dt().as_secs_f32();
800
801        if let Some(dialogue) = &mut self.dialogue {
802            dialogue.tick();
803        }
804        if ctx.pressed(Trigger::Close) {
805            self.dialogue = None;
806            self.hailed = None;
807        }
808        if ctx.pressed(Trigger::Sheet) {
809            self.sheet_open = !self.sheet_open;
810        }
811        if ctx.pressed(Trigger::Hail) && !ctx.ui_wants_pointer() {
812            self.handle_hail(ctx);
813        }
814    }
815
816    fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
817        self.steer(ctx);
818
819        let camera = self.orbit.camera();
820        ctx.set_camera(camera);
821        ctx.set_skybox(Sky::Dusk);
822        ctx.light(Light::directional(SUN_DIRECTION, SUN_COLOR).shadow());
823
824        ctx.draw(
825            Plane
826                .at(Transform::from_scale(Vec3::new(
827                    PLATFORM_SIZE,
828                    1.0,
829                    PLATFORM_SIZE,
830                )))
831                .material(Material::lit(PLATFORM_COLOR)),
832        );
833        for station in StationKind::ALL {
834            self.draw_station(ctx, station);
835        }
836
837        let hovered = Self::hovered(ctx);
838        if !self.sheet_open {
839            if let Some(station) = hovered {
840                ctx.set_cursor(Cursor::Pointer);
841                self.draw_bracket(ctx, camera, station);
842            }
843            self.draw_prompts(ctx, camera, hovered);
844        }
845        if self.dialogue.is_some() {
846            self.draw_dialogue(ctx);
847        }
848        if self.sheet_open {
849            ctx.ui(sheet);
850        }
851        self.panel(ctx);
852    }
853}
854
855fn main() {
856    run(
857        Config::new("Mirage: a game's own fonts")
858            .with_size(1280, 720)
859            .with_assets([
860                "examples/assets/pixel-operator.ttf",
861                "examples/assets/pixel-operator-mono.ttf",
862                "examples/assets/ferrum.otf",
863                "examples/assets/kenney-input-keyboard-mouse.ttf",
864            ]),
865        WatchRoom::init,
866    );
867}