use eframe::egui; use egui::{Align2, FontId, Key, Pos2, Rect, Sense, vec2}; mod game; mod map_file; use game::{Archetype, GameState, Glyph, ALL_ARCHETYPES}; /// Width of one board cell in pixels. const CELL_W: f32 = 14.0; /// Height of one board cell in pixels. const CELL_H: f32 = 20.0; /// Approximate height of the egui menu bar in pixels, used for window sizing. const MENU_H: f32 = 24.0; /// Default inner margin that egui's CentralPanel adds on all sides, in pixels. const PANEL_MARGIN: f32 = 8.0; /// Width of the editor palette side panel in pixels. const PALETTE_PANEL_W: f32 = 120.0; /// Default window width in pixels. Independent of map size. const DEFAULT_WINDOW_W: f32 = 840.0; /// Default window height in pixels. Independent of map size. const DEFAULT_WINDOW_H: f32 = 524.0; /// Minimum window width: enough room for 10 cells plus margins. const MIN_WINDOW_W: f32 = CELL_W * 10.0 + 2.0 * PANEL_MARGIN; /// Minimum window height: enough room for 5 cells plus menu and margins. const MIN_WINDOW_H: f32 = CELL_H * 5.0 + MENU_H + 2.0 * PANEL_MARGIN; /// Whether the application is in play mode or edit mode. /// /// In `Play` mode the game runs normally: arrow keys move the player. /// In `Edit` mode arrow keys are inactive, a side panel shows the archetype /// palette, and clicking on the viewport paints cells. #[derive(Copy, Clone, PartialEq, Eq)] enum AppMode { /// Normal game play. Arrow keys control the player. Play, /// Board editing. Side panel shows archetypes; click to paint cells. Edit, } /// Transient editor state. Only meaningful when [`AppMode::Edit`] is active. struct EditorState { /// The archetype that will be stamped when the user clicks a cell. selected: Archetype, } impl EditorState { fn new() -> Self { Self { selected: Archetype::Wall } } } fn main() -> eframe::Result<()> { let board = map_file::load("maps/start.toml").expect("failed to load maps/start.toml"); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([DEFAULT_WINDOW_W, DEFAULT_WINDOW_H]) .with_min_inner_size([MIN_WINDOW_W, MIN_WINDOW_H]), ..Default::default() }; eframe::run_native( "Viberogue", options, Box::new(move |_cc| Ok(Box::new(App::new(board)))), ) } /// 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. struct App { state: GameState, mode: AppMode, editor: EditorState, } impl App { fn new(board: game::Board) -> Self { Self { state: GameState::new(board), mode: AppMode::Play, editor: EditorState::new(), } } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // --- Input --- // Arrow keys move the player only in Play mode. if self.mode == AppMode::Play { 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); } }); } // --- Menu bar --- 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); } }); // Mode toggle: Play keeps the game running; Edit shows the palette panel. ui.separator(); ui.selectable_value(&mut self.mode, AppMode::Play, "Play"); ui.selectable_value(&mut self.mode, AppMode::Edit, "Edit"); }); }); // --- Editor palette panel --- // Must be declared before CentralPanel so egui allocates its space first. if self.mode == AppMode::Edit { egui::SidePanel::right("palette_panel") .resizable(false) .exact_width(PALETTE_PANEL_W) .show(ctx, |ui| { ui.heading("Elements"); ui.separator(); for archetype in ALL_ARCHETYPES { ui.selectable_value( &mut self.editor.selected, *archetype, archetype.name(), ); } }); } // --- Board rendering --- // The board is drawn using the egui Painter API (direct 2D drawing), // not egui widgets. This gives pixel-level control over placement. egui::CentralPanel::default().show(ctx, |ui| { let available = ui.available_rect_before_wrap(); // Extract board dimensions and player position before the immutable // borrow scope so `board_origin` can use them without conflicting // with the later mutable borrow for click-to-paint. let (board_w, board_h) = (self.state.board.width, self.state.board.height); let player = self.state.board.player; let origin = board_origin(available, board_w, board_h, player); // Draw board and player. Immutable borrow of `board` ends after // this block, freeing `self.state.board` for mutation below. { let board = &self.state.board; let painter = ui.painter(); // Draw each board cell: filled background rect, then the character. 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); } } // 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. let player_glyph = Glyph::player(); draw_glyph( painter, origin, board.player.x as usize, board.player.y as usize, &player_glyph, ); } // In Edit mode, capture clicks on the viewport and paint cells. if self.mode == AppMode::Edit { let response = ui.allocate_rect(available, Sense::click()); if response.clicked() { if let Some(pos) = response.interact_pointer_pos() { // Convert pixel position to cell coordinates using the // same origin computed for rendering above. let rel = pos - origin; let cx = (rel.x / CELL_W).floor() as i32; let cy = (rel.y / CELL_H).floor() as i32; // floor() keeps out-of-bounds negatives negative; caught here. if cx >= 0 && cy >= 0 { let cx = cx as usize; let cy = cy as usize; let board = &mut self.state.board; if cx < board.width && cy < board.height { let arch = self.editor.selected; *board.get_mut(cx, cy) = (arch.default_glyph(), arch); } } } } } }); // egui repaints automatically on input events, so no explicit // request_repaint() is needed while the game is purely event-driven. // Add it back (or call it from game logic) when continuous animation is needed. } } /// 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`]. 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); } /// Computes the pixel position of board cell (0, 0) within `available`. /// /// If the board fits along an axis, it is centered. If it overflows, the /// viewport is centered on the player and clamped so no empty space appears /// at the board edges. fn board_origin(available: Rect, board_w: usize, board_h: usize, player: game::Player) -> Pos2 { let board_px_w = board_w as f32 * CELL_W; let board_px_h = board_h as f32 * CELL_H; let origin_x = if board_px_w <= available.width() { // Board fits: center it horizontally. available.min.x + (available.width() - board_px_w) / 2.0 } else { // Board overflows: keep player at viewport center, clamp to board edges. let player_px_x = player.x as f32 * CELL_W + CELL_W / 2.0; (available.center().x - player_px_x) .max(available.max.x - board_px_w) // right edge must reach viewport right .min(available.min.x) // left edge must reach viewport left }; let origin_y = if board_px_h <= available.height() { available.min.y + (available.height() - board_px_h) / 2.0 } else { let player_px_y = player.y as f32 * CELL_H + CELL_H / 2.0; (available.center().y - player_px_y) .max(available.max.y - board_px_h) .min(available.min.y) }; Pos2::new(origin_x, origin_y) }