2026-05-15 23:42:14 -05:00
|
|
|
use eframe::egui;
|
2026-05-16 01:10:04 -05:00
|
|
|
use egui::{Align2, FontId, Key, Pos2, Rect, vec2};
|
|
|
|
|
|
|
|
|
|
mod game;
|
2026-05-16 01:50:05 -05:00
|
|
|
mod map_file;
|
|
|
|
|
use game::{GameState, Glyph};
|
2026-05-16 01:10:04 -05:00
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
/// Width of one board cell in pixels.
|
2026-05-16 01:10:04 -05:00
|
|
|
const CELL_W: f32 = 14.0;
|
2026-05-17 12:48:52 -05:00
|
|
|
/// Height of one board cell in pixels.
|
2026-05-16 01:10:04 -05:00
|
|
|
const CELL_H: f32 = 20.0;
|
2026-05-15 23:42:14 -05:00
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
/// Approximate height of the egui menu bar in pixels, used for window sizing.
|
2026-05-16 01:18:02 -05:00
|
|
|
const MENU_H: f32 = 24.0;
|
2026-05-17 12:48:52 -05:00
|
|
|
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
|
|
|
|
|
/// The minimum window size accounts for this so the board fits without clipping.
|
2026-05-16 01:18:02 -05:00
|
|
|
const PANEL_MARGIN: f32 = 8.0;
|
|
|
|
|
|
2026-05-15 23:42:14 -05:00
|
|
|
fn main() -> eframe::Result<()> {
|
2026-05-17 12:48:52 -05:00
|
|
|
// Load the board before creating the window so its dimensions can drive
|
|
|
|
|
// the initial and minimum window size.
|
2026-05-16 01:50:05 -05:00
|
|
|
let board = map_file::load("maps/start.toml").expect("failed to load maps/start.toml");
|
|
|
|
|
let board_px_w = board.width as f32 * CELL_W + 2.0 * PANEL_MARGIN;
|
|
|
|
|
let board_px_h = board.height as f32 * CELL_H + MENU_H + 2.0 * PANEL_MARGIN;
|
|
|
|
|
|
2026-05-16 01:18:02 -05:00
|
|
|
let options = eframe::NativeOptions {
|
|
|
|
|
viewport: egui::ViewportBuilder::default()
|
|
|
|
|
.with_inner_size([board_px_w, board_px_h])
|
|
|
|
|
.with_min_inner_size([board_px_w, board_px_h]),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
2026-05-15 23:42:14 -05:00
|
|
|
eframe::run_native(
|
|
|
|
|
"Viberogue",
|
|
|
|
|
options,
|
2026-05-16 01:50:05 -05:00
|
|
|
Box::new(move |_cc| Ok(Box::new(App::new(board)))),
|
2026-05-15 23:42:14 -05:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
/// The eframe application. Owns the game state and drives the frame loop.
|
|
|
|
|
///
|
|
|
|
|
/// eframe calls [`App::update`] every frame. All input handling, game logic
|
|
|
|
|
/// updates, and rendering happen there. There is no separate game loop thread.
|
2026-05-16 01:10:04 -05:00
|
|
|
struct App {
|
|
|
|
|
state: GameState,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App {
|
2026-05-16 01:50:05 -05:00
|
|
|
fn new(board: game::Board) -> Self {
|
|
|
|
|
Self { state: GameState::new(board) }
|
2026-05-16 01:10:04 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-15 23:42:14 -05:00
|
|
|
|
|
|
|
|
impl eframe::App for App {
|
|
|
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
2026-05-17 12:48:52 -05:00
|
|
|
// --- Input ---
|
|
|
|
|
// Arrow keys are read before any panel is drawn. egui accumulates
|
|
|
|
|
// key events between frames; key_pressed returns true once per press.
|
2026-05-16 01:10:04 -05:00
|
|
|
ctx.input(|i| {
|
|
|
|
|
if i.key_pressed(Key::ArrowUp) { self.state.try_move( 0, -1); }
|
|
|
|
|
if i.key_pressed(Key::ArrowDown) { self.state.try_move( 0, 1); }
|
|
|
|
|
if i.key_pressed(Key::ArrowLeft) { self.state.try_move(-1, 0); }
|
|
|
|
|
if i.key_pressed(Key::ArrowRight) { self.state.try_move( 1, 0); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
// --- Menu bar ---
|
2026-05-15 23:42:14 -05:00
|
|
|
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
|
|
|
|
egui::MenuBar::new().ui(ui, |ui| {
|
|
|
|
|
ui.menu_button("File", |ui| {
|
|
|
|
|
if ui.button("Exit").clicked() {
|
|
|
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
// --- Board rendering ---
|
|
|
|
|
// The board is drawn using the egui Painter API (direct 2D drawing),
|
|
|
|
|
// not egui widgets. This gives pixel-level control over placement.
|
2026-05-15 23:42:14 -05:00
|
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
2026-05-16 01:10:04 -05:00
|
|
|
let board = &self.state.board;
|
2026-05-16 01:18:02 -05:00
|
|
|
let available = ui.available_rect_before_wrap();
|
2026-05-17 12:48:52 -05:00
|
|
|
|
|
|
|
|
// Center the board within the panel. If the window is at minimum
|
|
|
|
|
// size the offset is zero; if the window is larger the board floats
|
|
|
|
|
// in the middle.
|
2026-05-16 01:18:02 -05:00
|
|
|
let board_px = vec2(board.width as f32 * CELL_W, board.height as f32 * CELL_H);
|
|
|
|
|
let offset = ((available.size() - board_px) / 2.0).max(egui::Vec2::ZERO);
|
|
|
|
|
let origin = available.min + offset;
|
2026-05-17 12:48:52 -05:00
|
|
|
|
2026-05-16 01:10:04 -05:00
|
|
|
let painter = ui.painter();
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
// Draw each board cell: filled background rect, then the character.
|
2026-05-16 01:10:04 -05:00
|
|
|
for y in 0..board.height {
|
|
|
|
|
for x in 0..board.width {
|
|
|
|
|
let (glyph, _) = board.get(x, y);
|
|
|
|
|
draw_glyph(painter, origin, x, y, glyph);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
// Draw the player on top of the board. The player is not stored as
|
|
|
|
|
// a board cell, so it is rendered as a separate overlay. This will
|
|
|
|
|
// change once the player becomes a scripted object.
|
2026-05-16 01:10:04 -05:00
|
|
|
let player_glyph = Glyph::player();
|
|
|
|
|
draw_glyph(
|
|
|
|
|
painter,
|
|
|
|
|
origin,
|
2026-05-16 01:50:05 -05:00
|
|
|
self.state.board.player.x as usize,
|
|
|
|
|
self.state.board.player.y as usize,
|
2026-05-16 01:10:04 -05:00
|
|
|
&player_glyph,
|
|
|
|
|
);
|
2026-05-15 23:42:14 -05:00
|
|
|
});
|
2026-05-16 01:10:04 -05:00
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
// Force a repaint every frame so input is processed continuously.
|
|
|
|
|
// Remove this if the game becomes purely event-driven.
|
2026-05-16 01:10:04 -05:00
|
|
|
ctx.request_repaint();
|
2026-05-15 23:42:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-16 01:10:04 -05:00
|
|
|
|
2026-05-17 12:48:52 -05:00
|
|
|
/// Draws a single cell at grid position `(x, y)` using the given glyph.
|
|
|
|
|
///
|
|
|
|
|
/// Each cell is rendered as two layers:
|
|
|
|
|
/// 1. A filled rectangle in `glyph.bg` covering the full cell area.
|
|
|
|
|
/// 2. A monospace character (`glyph.ch`) in `glyph.fg`, centered in the cell.
|
|
|
|
|
///
|
|
|
|
|
/// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of
|
|
|
|
|
/// the board within the panel. Cell positions are computed from `origin` using
|
|
|
|
|
/// [`CELL_W`] and [`CELL_H`].
|
2026-05-16 01:10:04 -05:00
|
|
|
fn draw_glyph(painter: &egui::Painter, origin: Pos2, x: usize, y: usize, glyph: &Glyph) {
|
|
|
|
|
let tl = origin + vec2(x as f32 * CELL_W, y as f32 * CELL_H);
|
|
|
|
|
let rect = Rect::from_min_size(tl, vec2(CELL_W, CELL_H));
|
|
|
|
|
let center = rect.center();
|
|
|
|
|
painter.rect_filled(rect, 0.0, glyph.bg);
|
|
|
|
|
painter.text(center, Align2::CENTER_CENTER, glyph.ch, FontId::monospace(CELL_H), glyph.fg);
|
|
|
|
|
}
|