use core::time::Duration;
use std::sync::Arc;
use mirage_engine::prelude::*;
const PROPORTIONAL_FONT: &str = "pixel-operator";
const MONOSPACE_FONT: &str = "pixel-operator-mono";
const DISPLAY_FONT: &str = "ferrum";
const DISPLAY_FAMILY: &str = "display";
const PIXEL_ONLY_FAMILY: &str = "pixel-only";
const PROMPT_FONT: &str = "kenney-input-keyboard-mouse";
const PROMPT_FAMILY: &str = "prompts";
const BODY_SIZE: f32 = 16.0;
const HEADING_SIZE: f32 = 32.0;
const NUMBER_SIZE: f32 = 28.0;
const PROMPT_SIZE: f32 = 32.0;
const TEXT_COLOR: egui::Color32 = egui::Color32::from_gray(230);
const PLATFORM_SIZE: f32 = 8.0;
const PLATFORM_COLOR: Color = Color::rgb(0.05, 0.05, 0.06);
const STATION_RADIUS: f32 = 3.1;
const STATION_SIZE: Vec3 = Vec3::new(0.6, 0.9, 0.5);
const STATION_FRONT_SIZE: Vec3 = Vec3::new(0.42, 0.5, 0.06);
const STATION_FRONT_OUTWARD: f32 = 0.005;
const RADAR_SWEEP_RATE: f32 = 40.0;
const REACTOR_RATE: f32 = 0.5;
const REACTOR_BASE: f32 = 55.0;
const REACTOR_SWING: f32 = 35.0;
const SUN_DIRECTION: Vec3 = Vec3::new(0.4, -1.0, -0.3);
const SUN_COLOR: Color = Color::rgb(0.6, 0.62, 0.7);
const CAMERA_FOV: f32 = 42.0;
const CAMERA_TARGET: Vec3 = Vec3::new(0.0, STATION_SIZE.y * 0.5, 0.0);
const START_YAW: f32 = 0.4;
const START_PITCH: f32 = 0.35;
const PITCH_LIMIT: f32 = 0.9;
const START_DISTANCE: f32 = 8.0;
const MIN_DISTANCE: f32 = 3.0;
const MAX_DISTANCE: f32 = 14.0;
const AUTO_TURN_RATE: f32 = 0.12;
const TURN_SENSITIVITY: f32 = core::f32::consts::FRAC_PI_2 / 1280.0;
const ZOOM_STEP: f32 = 1.12;
const BRACKET_MARGIN: f32 = 10.0;
const BRACKET_STROKE: f32 = 2.0;
const BRACKET_CORNER: f32 = 7.0;
const STACK_GAP: f32 = 4.0;
const PROMPT_LIFT: f32 = 20.0;
const DIALOGUE_PADDING_X: f32 = 28.0;
const DIALOGUE_PADDING_Y: f32 = 20.0;
const DIALOGUE_MARGIN: f32 = 24.0;
const FALLBACK_SAMPLE: &str = "café λ";
const FAMILY_SAMPLE: &str = "mill and wall";
const GRID_SAMPLE: &str = "the quick fox";
meshes! { enum Shape { Cube, Plane } }
#[derive(Catalog, Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Sky {
Dusk,
}
impl Skyboxes for Sky {
fn build(&self, _assets: &Assets) -> SkyboxData {
SkyboxData::gradient(
Color::rgb(0.05, 0.06, 0.12),
Color::rgb(0.18, 0.12, 0.16),
Color::rgb(0.01, 0.01, 0.02),
)
.lit_by(0.25)
}
}
struct StationLook {
name: &'static str,
color: Color,
glow: Color,
lines: [&'static str; 2],
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StationKind {
Radar,
Reactor,
Clock,
}
impl StationKind {
const ALL: [Self; 3] = [Self::Radar, Self::Reactor, Self::Clock];
fn index(self) -> usize {
self as usize
}
fn look(self) -> StationLook {
match self {
Self::Radar => StationLook {
name: "radar",
color: Color::rgb(0.22, 0.26, 0.30),
glow: Color::rgb(0.3, 1.4, 1.1),
lines: [
"the radar sweeps the dark past the platform for anything that moves",
"nothing answers back tonight",
],
},
Self::Reactor => StationLook {
name: "reactor",
color: Color::rgb(0.30, 0.22, 0.18),
glow: Color::rgb(1.6, 0.7, 0.2),
lines: [
"the reactor gauge holds steady at a comfortable idle",
"plenty of power left for the long watch ahead",
],
},
Self::Clock => StationLook {
name: "clock",
color: Color::rgb(0.20, 0.24, 0.22),
glow: Color::rgb(0.8, 0.9, 1.6),
lines: [
"the clock keeps the same count it always has",
"the watch ends when it says so and not before",
],
},
}
}
fn center(self) -> Vec3 {
let angle = self.index() as f32 / Self::ALL.len() as f32 * core::f32::consts::TAU;
Vec3::new(
STATION_RADIUS * angle.cos(),
STATION_SIZE.y * 0.5,
STATION_RADIUS * angle.sin(),
)
}
fn aabb(self) -> (Vec3, Vec3) {
let half = STATION_SIZE * 0.5;
(self.center() - half, self.center() + half)
}
fn reading(self, elapsed: f32) -> (String, String) {
match self {
Self::Radar => {
let bearing = (elapsed * RADAR_SWEEP_RATE).rem_euclid(360.0);
(
format!("{bearing:.0} degrees bearing"),
format!("{bearing:03.0}"),
)
}
Self::Reactor => {
let percent = REACTOR_BASE + REACTOR_SWING * (elapsed * REACTOR_RATE).sin();
(
format!("{percent:.0} percent output"),
format!("{percent:.0}%"),
)
}
Self::Clock => {
let seconds = elapsed.rem_euclid(60.0);
(
format!("{seconds:.1} seconds this minute"),
format!("{seconds:04.1}"),
)
}
}
}
}
fn hit_station(ray: Ray) -> Option<StationKind> {
StationKind::ALL
.into_iter()
.filter_map(|station| {
let (min, max) = station.aabb();
ray.hit_aabb(min, max).map(|distance| (distance, station))
})
.min_by(|(a, _), (b, _)| a.total_cmp(b))
.map(|(_, station)| station)
}
fn logical(pixel: Vec2, pixels_per_point: f32) -> egui::Pos2 {
let point = pixel / pixels_per_point;
egui::pos2(point.x, point.y)
}
fn styled(text: impl Into<String>, font: egui::FontId) -> egui::RichText {
egui::RichText::new(text.into())
.font(font)
.color(TEXT_COLOR)
}
fn ink_bottom_at(galley: &egui::Galley, center_x: f32, bottom: f32) -> egui::Pos2 {
let ink = galley.mesh_bounds;
egui::pos2(center_x - ink.center().x, bottom - ink.max.y)
}
enum Prompt {
Glyph(char),
Text(String),
}
impl Prompt {
fn text(&self) -> String {
match self {
Self::Glyph(glyph) => glyph.to_string(),
Self::Text(text) => text.clone(),
}
}
fn family(&self) -> egui::FontFamily {
match self {
Self::Glyph(_) => egui::FontFamily::Name(PROMPT_FAMILY.into()),
Self::Text(_) => egui::FontFamily::Proportional,
}
}
}
fn prompt(binding: &ButtonBinding) -> Prompt {
match binding {
ButtonBinding::Mouse(MouseButton::Left) => Prompt::Glyph('\u{E0EC}'),
ButtonBinding::Mouse(MouseButton::Right) => Prompt::Glyph('\u{E0F0}'),
other => Prompt::Text(other.to_string()),
}
}
fn bracket(
painter: &egui::Painter,
at: egui::Pos2,
name: Arc<egui::Galley>,
reading: Arc<egui::Galley>,
number: Arc<egui::Galley>,
) {
let frame_bottom = at.y - name.rect.height();
let name_ink = name.mesh_bounds;
let frame_height = name_ink.height() + BRACKET_MARGIN * 2.0;
let frame = egui::Rect::from_min_size(
egui::pos2(
at.x - name_ink.width() * 0.5 - BRACKET_MARGIN,
frame_bottom - frame_height,
),
egui::vec2(name_ink.width() + BRACKET_MARGIN * 2.0, frame_height),
);
let name_pos = ink_bottom_at(&name, at.x, frame_bottom - BRACKET_MARGIN);
let reading_bottom = frame.top() - STACK_GAP;
let reading_pos = ink_bottom_at(&reading, at.x, reading_bottom);
let number_bottom = reading_pos.y - reading.rect.height() - STACK_GAP;
let number_pos = ink_bottom_at(&number, at.x, number_bottom);
corners(painter, frame);
painter.galley(name_pos, name, TEXT_COLOR);
painter.galley(reading_pos, reading, TEXT_COLOR);
painter.galley(number_pos, number, TEXT_COLOR);
}
fn prompt_at(painter: &egui::Painter, at: egui::Pos2, glyph: Arc<egui::Galley>) {
let ink = glyph.mesh_bounds;
let pos = egui::pos2(at.x - ink.center().x, at.y - ink.center().y);
painter.galley(pos, glyph, TEXT_COLOR);
}
fn corners(painter: &egui::Painter, rect: egui::Rect) {
let stroke = egui::Stroke::new(BRACKET_STROKE, TEXT_COLOR);
for (corner, inward) in [
(rect.left_top(), egui::vec2(1.0, 1.0)),
(rect.right_top(), egui::vec2(-1.0, 1.0)),
(rect.left_bottom(), egui::vec2(1.0, -1.0)),
(rect.right_bottom(), egui::vec2(-1.0, -1.0)),
] {
painter.line_segment(
[corner, corner + egui::vec2(inward.x * BRACKET_CORNER, 0.0)],
stroke,
);
painter.line_segment(
[corner, corner + egui::vec2(0.0, inward.y * BRACKET_CORNER)],
stroke,
);
}
}
struct Dialogue {
name: String,
lines: [String; 2],
line: usize,
revealed: usize,
}
impl Dialogue {
fn start(name: impl Into<String>, lines: [String; 2]) -> Self {
Self {
name: name.into(),
lines,
line: 0,
revealed: 0,
}
}
fn current_line(&self) -> &str {
&self.lines[self.line]
}
fn tick(&mut self) {
let len = self.current_line().chars().count();
self.revealed = (self.revealed + 1).min(len);
}
fn advance(&mut self) -> bool {
if self.line + 1 < self.lines.len() {
self.line += 1;
self.revealed = 0;
true
} else {
false
}
}
fn draw(&self, ui: &mut egui::Ui, whole_line: egui::Vec2) {
let display = egui::FontFamily::Name(DISPLAY_FAMILY.into());
let box_size = egui::vec2(
whole_line.x + DIALOGUE_PADDING_X,
whole_line.y + HEADING_SIZE + DIALOGUE_PADDING_Y,
);
egui::Window::new("hail")
.title_bar(false)
.resizable(false)
.collapsible(false)
.anchor(
egui::Align2::CENTER_BOTTOM,
egui::vec2(0.0, -DIALOGUE_MARGIN),
)
.show(ui.ctx(), |ui| {
ui.set_min_size(box_size);
ui.label(styled(&self.name, egui::FontId::new(HEADING_SIZE, display)));
let shown: String = self.current_line().chars().take(self.revealed).collect();
ui.label(styled(shown, egui::FontId::proportional(BODY_SIZE)));
});
}
}
fn sheet(ui: &mut egui::Ui) {
egui::Window::new("font sheet")
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.collapsible(false)
.resizable(false)
.show(ui.ctx(), |ui| {
egui::Grid::new("font sheet grid").show(ui, |ui| {
for (label, family) in [
("proportional", egui::FontFamily::Proportional),
("monospace", egui::FontFamily::Monospace),
("display", egui::FontFamily::Name(DISPLAY_FAMILY.into())),
] {
ui.label(styled(label, egui::FontId::proportional(BODY_SIZE)));
ui.label(styled(
FAMILY_SAMPLE,
egui::FontId::new(HEADING_SIZE, family),
));
ui.end_row();
}
let pixel_only = egui::FontFamily::Name(PIXEL_ONLY_FAMILY.into());
for (label, family) in [
("egui's fonts behind", egui::FontFamily::Proportional),
("pixel font alone", pixel_only),
] {
ui.label(styled(label, egui::FontId::proportional(BODY_SIZE)));
ui.label(styled(
FALLBACK_SAMPLE,
egui::FontId::new(BODY_SIZE, family),
));
ui.end_row();
}
for (label, size) in [("16 points", 16.0), ("17 points", 17.0)] {
ui.label(styled(label, egui::FontId::proportional(BODY_SIZE)));
ui.label(styled(GRID_SAMPLE, egui::FontId::proportional(size)));
ui.end_row();
}
});
});
}
#[derive(InputButtonAction, Clone, Copy)]
enum Trigger {
Hail,
Sheet,
Close,
}
impl InputButtonAction for Trigger {
fn bindings(&self) -> Vec<ButtonBinding> {
match self {
Self::Hail => vec![MouseButton::Left.into()],
Self::Sheet => vec![Key::Tab.into()],
Self::Close => vec![Key::Escape.into()],
}
}
}
#[derive(InputAxis2Action, Clone, Copy)]
enum Turn {
Look,
}
impl InputAxis2Action for Turn {
fn bindings(&self) -> Vec<Axis2Binding> {
match self {
Self::Look => vec![Axis2Binding::pointer().scale(TURN_SENSITIVITY)],
}
}
}
#[derive(InputAxisAction, Clone, Copy)]
enum Zoom {
Wheel,
}
impl InputAxisAction for Zoom {
fn bindings(&self) -> Vec<AxisBinding> {
match self {
Self::Wheel => vec![AxisBinding::from(WheelDelta::Up).scale(4.0)],
}
}
}
struct Controls;
impl InputActions for Controls {
type Button = Trigger;
type Axis = Zoom;
type Axis2 = Turn;
}
fn install_family(
fonts: &mut egui::FontDefinitions,
startup: &mut Startup,
family: egui::FontFamily,
names: &[&str],
) -> Result<(), Error> {
for &name in names {
if !fonts.font_data.contains_key(name) {
fonts
.font_data
.insert(name.to_owned(), startup.font(name)?.into());
}
}
let list = fonts.families.entry(family).or_default();
for &name in names.iter().rev() {
list.insert(0, name.to_owned());
}
Ok(())
}
struct Orbit {
yaw: f32,
pitch: f32,
distance: f32,
}
impl Default for Orbit {
fn default() -> Self {
Self {
yaw: START_YAW,
pitch: START_PITCH,
distance: START_DISTANCE,
}
}
}
impl Orbit {
fn camera(&self) -> Camera {
let direction = Vec3::new(
self.pitch.cos() * self.yaw.sin(),
self.pitch.sin(),
self.pitch.cos() * self.yaw.cos(),
);
Camera::new(
View::look_at(CAMERA_TARGET + direction * self.distance, CAMERA_TARGET),
Projection::perspective(CAMERA_FOV),
)
}
fn turn(&mut self, by: Vec2) {
self.yaw -= by.x;
self.pitch = (self.pitch + by.y).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
fn zoom(&mut self, factor: f32) {
self.distance = (self.distance / factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
}
}
struct WatchRoom {
elapsed: Duration,
orbit: Orbit,
hailed: Option<StationKind>,
dialogue: Option<Dialogue>,
sheet_open: bool,
}
impl WatchRoom {
fn init(ctx: &mut InitContext<'_, Self>) -> Result<Self, Error> {
let startup = ctx.startup();
let mut fonts = egui::FontDefinitions::default();
for (family, names) in [
(egui::FontFamily::Proportional, &[PROPORTIONAL_FONT][..]),
(egui::FontFamily::Monospace, &[MONOSPACE_FONT][..]),
(
egui::FontFamily::Name(DISPLAY_FAMILY.into()),
&[DISPLAY_FONT][..],
),
(
egui::FontFamily::Name(PIXEL_ONLY_FAMILY.into()),
&[PROPORTIONAL_FONT][..],
),
(
egui::FontFamily::Name(PROMPT_FAMILY.into()),
&[PROMPT_FONT, PROPORTIONAL_FONT][..],
),
] {
install_family(&mut fonts, startup, family, names)?;
}
startup.set_fonts(fonts)?;
Ok(Self {
elapsed: Duration::ZERO,
orbit: Orbit::default(),
hailed: None,
dialogue: None,
sheet_open: false,
})
}
fn handle_hail(&mut self, ctx: &mut TickContext<'_, Self>) {
let ray = ctx
.last_camera()
.ray_through(ctx.pointer(), ctx.window_size());
let Some(station) = hit_station(ray) else {
return;
};
if self.hailed != Some(station) {
self.hailed = Some(station);
let look = station.look();
self.dialogue = Some(Dialogue::start(look.name, look.lines.map(str::to_owned)));
return;
}
let Some(dialogue) = &mut self.dialogue else {
return;
};
if !dialogue.advance() {
self.dialogue = None;
self.hailed = None;
}
}
fn draw_station(&self, ctx: &mut FrameContext<'_, Self>, station: StationKind) {
let look = station.look();
let center = station.center();
let front_offset =
STATION_SIZE.z * 0.5 - STATION_FRONT_SIZE.z * 0.5 + STATION_FRONT_OUTWARD;
let front = center - Vec3::new(0.0, 0.0, front_offset);
for (size, position, material) in [
(STATION_SIZE, center, Material::lit(look.color)),
(
STATION_FRONT_SIZE,
front,
Material::color(Color::BLACK).emissive(look.glow),
),
] {
ctx.draw(
Cube.at(Transform::from_scale_rotation_translation(
size,
Quat::IDENTITY,
position,
))
.material(material),
);
}
}
fn draw_bracket(&self, ctx: &mut FrameContext<'_, Self>, camera: Camera, station: StationKind) {
let top = station.center() + Vec3::Y * (STATION_SIZE.y * 0.5);
let window_size = ctx.window_size();
let Some(pixel) = camera.pixel_of(top, window_size) else {
return;
};
let at = logical(pixel, ctx.pixels_per_point());
let name = ctx.text_layout(station.look().name, egui::FontId::proportional(BODY_SIZE));
let (reading_text, number_text) = station.reading(self.elapsed.as_secs_f32());
let reading = ctx.text_layout(&reading_text, egui::FontId::monospace(BODY_SIZE));
let number = ctx.text_layout(
&number_text,
egui::FontId::new(NUMBER_SIZE, egui::FontFamily::Name(DISPLAY_FAMILY.into())),
);
ctx.ui(|ui| bracket(ui.painter(), at, name, reading, number));
}
fn draw_prompts(
&self,
ctx: &mut FrameContext<'_, Self>,
camera: Camera,
hovered: Option<StationKind>,
) {
let Some(binding) = ctx.bindings(Trigger::Hail).into_iter().next() else {
return;
};
let hint = prompt(&binding);
let glyph = ctx.text_layout(&hint.text(), egui::FontId::new(PROMPT_SIZE, hint.family()));
let window_size = ctx.window_size();
let pixels_per_point = ctx.pixels_per_point();
ctx.ui(|ui| {
let painter = ui.painter();
for station in StationKind::ALL {
if Some(station) == hovered {
continue;
}
let top = station.center() + Vec3::Y * (STATION_SIZE.y * 0.5);
let Some(pixel) = camera.pixel_of(top, window_size) else {
continue;
};
let at = logical(pixel, pixels_per_point);
let at = egui::pos2(at.x, at.y - PROMPT_LIFT);
prompt_at(painter, at, glyph.clone());
}
});
}
fn panel(&self, ctx: &mut FrameContext<'_, Self>) {
ctx.ui(|ui| {
ui.label(styled(
"a game's own fonts",
egui::FontId::proportional(HEADING_SIZE),
));
ui.label(styled(
"drawn in Pixel Operator, the game's proportional font",
egui::FontId::proportional(BODY_SIZE),
));
ui.label(styled(
"the readings above each station in Pixel Operator Mono",
egui::FontId::monospace(BODY_SIZE),
));
});
}
fn draw_dialogue(&self, ctx: &mut FrameContext<'_, Self>) {
let Some(dialogue) = &self.dialogue else {
return;
};
let whole = ctx.text_layout(
dialogue.current_line(),
egui::FontId::proportional(BODY_SIZE),
);
let size = whole.size();
ctx.ui(|ui| dialogue.draw(ui, size));
}
fn hovered(ctx: &FrameContext<'_, Self>) -> Option<StationKind> {
if ctx.ui_wants_pointer() {
return None;
}
hit_station(
ctx.last_camera()
.ray_through(ctx.pointer(), ctx.window_size()),
)
}
fn steer(&mut self, ctx: &mut FrameContext<'_, Self>) {
if !ctx.ui_wants_pointer() && ctx.down(Trigger::Hail) {
self.orbit.turn(ctx.axis2(Turn::Look));
}
let wheel = ctx.axis(Zoom::Wheel);
if !ctx.ui_wants_pointer() && wheel != 0.0 {
self.orbit.zoom(ZOOM_STEP.powf(wheel));
}
}
}
impl Game for WatchRoom {
type Meshes = Shape;
type Sounds = NoSounds;
type InputActions = Controls;
type Skyboxes = Sky;
type SurfaceStyles = NoSurfaceStyles;
type PostEffects = NoPostEffects;
fn tick(&mut self, ctx: &mut TickContext<'_, Self>) {
self.elapsed += ctx.dt();
self.orbit.yaw += AUTO_TURN_RATE * ctx.dt().as_secs_f32();
if let Some(dialogue) = &mut self.dialogue {
dialogue.tick();
}
if ctx.pressed(Trigger::Close) {
self.dialogue = None;
self.hailed = None;
}
if ctx.pressed(Trigger::Sheet) {
self.sheet_open = !self.sheet_open;
}
if ctx.pressed(Trigger::Hail) && !ctx.ui_wants_pointer() {
self.handle_hail(ctx);
}
}
fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
self.steer(ctx);
let camera = self.orbit.camera();
ctx.set_camera(camera);
ctx.set_skybox(Sky::Dusk);
ctx.light(Light::directional(SUN_DIRECTION, SUN_COLOR).shadow());
ctx.draw(
Plane
.at(Transform::from_scale(Vec3::new(
PLATFORM_SIZE,
1.0,
PLATFORM_SIZE,
)))
.material(Material::lit(PLATFORM_COLOR)),
);
for station in StationKind::ALL {
self.draw_station(ctx, station);
}
let hovered = Self::hovered(ctx);
if !self.sheet_open {
if let Some(station) = hovered {
ctx.set_cursor(Cursor::Pointer);
self.draw_bracket(ctx, camera, station);
}
self.draw_prompts(ctx, camera, hovered);
}
if self.dialogue.is_some() {
self.draw_dialogue(ctx);
}
if self.sheet_open {
ctx.ui(sheet);
}
self.panel(ctx);
}
}
fn main() {
run(
Config::new("Mirage: a game's own fonts")
.with_size(1280, 720)
.with_assets([
"examples/assets/pixel-operator.ttf",
"examples/assets/pixel-operator-mono.ttf",
"examples/assets/ferrum.otf",
"examples/assets/kenney-input-keyboard-mouse.ttf",
]),
WatchRoom::init,
);
}