From 03d6337f0463eb1fe634e052a3aebfed7c546f1f Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 17 May 2026 19:09:16 -0500 Subject: [PATCH] Refactor main.rs into focused modules: render, editor, glyph_picker Splits the flat 432-line main.rs into four modules so each file has one clear job: render.rs owns layout constants and drawing primitives, editor.rs owns EditorTab/EditorState and the side panel, glyph_picker.rs owns the floating picker dialog with a decoupled (open, glyph) interface, and main.rs is reduced to AppMode, App, and the frame loop (~150 lines). Co-Authored-By: Claude Sonnet 4.6 --- src/editor.rs | 117 ++++++++++++++++ src/glyph_picker.rs | 111 ++++++++++++++++ src/main.rs | 317 +++----------------------------------------- src/render.rs | 96 ++++++++++++++ 4 files changed, 344 insertions(+), 297 deletions(-) create mode 100644 src/editor.rs create mode 100644 src/glyph_picker.rs create mode 100644 src/render.rs diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..29eb6e9 --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,117 @@ +use eframe::egui; +use egui::{Align2, FontId, Sense, vec2}; + +use crate::game::{Archetype, Board, Glyph, ALL_ARCHETYPES}; +use crate::render::{CELL_H, CELL_W, PALETTE_PANEL_W}; + +/// Which tab is active in the editor side panel. +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum EditorTab { + /// Archetype + glyph palette for painting cells. + Palette, + /// Board-level properties (placeholder). + Board, + /// World-level properties (placeholder). + World, +} + +/// Transient editor state. Only meaningful when the app is in Edit mode. +pub(crate) struct EditorState { + /// The archetype that will be stamped when the user clicks a cell. + pub(crate) selected: Archetype, + /// The glyph (character + colors) to stamp. Independent of the archetype's default. + pub(crate) glyph: Glyph, + /// Whether the glyph picker dialog is currently open. + pub(crate) glyph_picker_open: bool, + /// Which side-panel tab is currently shown. + pub(crate) tab: EditorTab, +} + +impl EditorState { + /// Creates editor state with `Wall` selected and the picker closed. + pub(crate) fn new() -> Self { + Self { + selected: Archetype::Wall, + glyph: Archetype::Wall.default_glyph(), + glyph_picker_open: false, + tab: EditorTab::Palette, + } + } +} + +/// Renders the editor side panel (must be called before `CentralPanel`). +/// +/// Shows tab bar (Palette / Board / World) and, on the Palette tab, the +/// scrollable archetype list and current-glyph preview button. +pub(crate) fn show_editor_panel(ctx: &egui::Context, editor: &mut EditorState, _board: &Board) { + egui::SidePanel::right("palette_panel") + .resizable(true) + .default_width(PALETTE_PANEL_W) + .show(ctx, |ui| { + // Tab bar + ui.horizontal(|ui| { + ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette"); + ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board"); + ui.selectable_value(&mut editor.tab, EditorTab::World, "World"); + }); + ui.separator(); + + match editor.tab { + EditorTab::Palette => { + ui.label("Element"); + + // Scrollable archetype list; shows at most 5 rows before scrolling. + let row_h = CELL_H + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical() + .max_height(row_h * 5.0) + .id_salt("arch_list") + .show(ui, |ui| { + for &archetype in ALL_ARCHETYPES { + let is_selected = editor.selected == archetype; + let glyph = archetype.default_glyph(); + ui.horizontal(|ui| { + let (r, cell_resp) = ui.allocate_exact_size( + vec2(CELL_W, CELL_H), Sense::click()); + { + let p = ui.painter(); + p.rect_filled(r, 0.0, glyph.bg); + p.text(r.center(), Align2::CENTER_CENTER, + glyph.ch, FontId::monospace(CELL_H), glyph.fg); + } + let label_resp = + ui.selectable_label(is_selected, archetype.name()); + if cell_resp.clicked() || label_resp.clicked() { + editor.selected = archetype; + editor.glyph = archetype.default_glyph(); + } + }); + } + }); + + ui.separator(); + + ui.label("Glyph"); + // Paint a preview cell showing the current glyph, sized like a board cell. + let (cell_rect, glyph_btn) = + ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); + { + let p = ui.painter(); + p.rect_filled(cell_rect, 0.0, editor.glyph.bg); + p.text( + cell_rect.center(), + Align2::CENTER_CENTER, + editor.glyph.ch, + FontId::monospace(CELL_H), + editor.glyph.fg, + ); + } + if glyph_btn.clicked() { + editor.glyph_picker_open = true; + } + } + EditorTab::Board => {} + EditorTab::World => {} + } + }); +} + diff --git a/src/glyph_picker.rs b/src/glyph_picker.rs new file mode 100644 index 0000000..4a848c5 --- /dev/null +++ b/src/glyph_picker.rs @@ -0,0 +1,111 @@ +use eframe::egui; +use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, vec2}; + +use crate::game::{Archetype, Glyph}; +use crate::render::{CELL_H, CELL_W, pos_to_cell}; + +/// Renders the floating glyph-picker dialog and updates `glyph` in place. +/// +/// Shows three sections: board palette (unique glyphs on the current board), +/// FG/BG color pickers, and a 16×6 printable-ASCII character grid. +/// Closes when the window's X button is clicked, writing `false` to `open`. +pub(crate) fn show( + ctx: &egui::Context, + open: &mut bool, + glyph: &mut Glyph, + board_cells: &[(Glyph, Archetype)], +) { + // Collect unique glyphs from the board before the window closure borrows + // `glyph` mutably, to avoid a borrow conflict with `board_cells`. + let board_glyphs: Vec = { + let mut seen: Vec = Vec::new(); + for (g, _) in board_cells { + if !seen.contains(g) { + seen.push(*g); + } + } + seen + }; + + egui::Window::new("Glyph") + .collapsible(false) + .resizable(false) + .open(open) + .show(ctx, |ui| { + // ----- Board palette ----- + ui.label("Board palette"); + ui.horizontal_wrapped(|ui| { + for &g in &board_glyphs { + let (r, resp) = + ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); + { + let p = ui.painter(); + p.rect_filled(r, 0.0, g.bg); + p.text(r.center(), Align2::CENTER_CENTER, g.ch, + FontId::monospace(CELL_H), g.fg); + if g == *glyph { + p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), + egui::epaint::StrokeKind::Middle); + } + } + if resp.clicked() { + *glyph = g; + } + } + }); + + ui.separator(); + + // ----- Colors ----- + ui.label("Colors"); + ui.horizontal(|ui| { + ui.label("FG"); + egui::color_picker::color_edit_button_srgba( + ui, &mut glyph.fg, egui::color_picker::Alpha::Opaque); + ui.label("BG"); + egui::color_picker::color_edit_button_srgba( + ui, &mut glyph.bg, egui::color_picker::Alpha::Opaque); + }); + + ui.separator(); + + // ----- Character grid ----- + // 16 columns × 6 rows covering printable ASCII 0x20–0x7E. + ui.label("Character"); + const CHAR_COLS: usize = 16; + let chars: Vec = + (0x20u32..=0x7eu32).filter_map(char::from_u32).collect(); + let rows = chars.len().div_ceil(CHAR_COLS); + let grid_size = vec2(CHAR_COLS as f32 * CELL_W, rows as f32 * CELL_H); + let (grid_rect, grid_resp) = + ui.allocate_exact_size(grid_size, Sense::click()); + let fg = glyph.fg; + let bg = glyph.bg; + let cur_ch = glyph.ch; + { + let p = ui.painter(); + for (i, &ch) in chars.iter().enumerate() { + let col = i % CHAR_COLS; + let row = i / CHAR_COLS; + let tl = grid_rect.min + + vec2(col as f32 * CELL_W, row as f32 * CELL_H); + let cell = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); + p.rect_filled(cell, 0.0, bg); + p.text(cell.center(), Align2::CENTER_CENTER, ch, + FontId::monospace(CELL_H), fg); + if ch == cur_ch { + p.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::WHITE), + egui::epaint::StrokeKind::Middle); + } + } + } + if grid_resp.clicked() + && let Some(pos) = grid_resp.interact_pointer_pos() { + let (col, row) = pos_to_cell(grid_rect.min, pos); + let idx = row as usize * CHAR_COLS + col as usize; + if let Some(&ch) = chars.get(idx) { + glyph.ch = ch; + } + } + }); +} diff --git a/src/main.rs b/src/main.rs index 0071b79..182bb9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,18 @@ use eframe::egui; -use egui::{Align2, Color32, FontId, Key, Pos2, Rect, Sense, Stroke, vec2}; +use egui::{Key, Sense, vec2}; +mod editor; mod game; +mod glyph_picker; mod map_file; -use game::{Archetype, GameState, Glyph, ALL_ARCHETYPES}; +mod render; -/// 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; -/// Default width of the editor side panel in pixels. The panel is resizable. -const PALETTE_PANEL_W: f32 = 200.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; +use editor::{EditorState, show_editor_panel}; +use game::GameState; +use render::{ + DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, + board_origin, draw_board, pos_to_cell, CELL_H, CELL_W, +}; /// Whether the application is in play mode or edit mode. /// @@ -38,40 +27,6 @@ enum AppMode { Edit, } -/// Which tab is active in the editor side panel. -#[derive(Copy, Clone, PartialEq, Eq)] -enum EditorTab { - /// Archetype + glyph palette for painting cells. - Palette, - /// Board-level properties (placeholder). - Board, - /// World-level properties (placeholder). - World, -} - -/// 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, - /// The glyph (character + colors) to stamp. Independent of the archetype's default. - glyph: Glyph, - /// Whether the glyph picker dialog is currently open. - glyph_picker_open: bool, - /// Which side-panel tab is currently shown. - tab: EditorTab, -} - -impl EditorState { - fn new() -> Self { - Self { - selected: Archetype::Wall, - glyph: Archetype::Wall.default_glyph(), - glyph_picker_open: false, - tab: EditorTab::Palette, - } - } -} - fn main() -> eframe::Result<()> { let board = map_file::load("maps/start.toml").expect("failed to load maps/start.toml"); @@ -99,6 +54,7 @@ struct App { } impl App { + /// Creates the app with the given board, starting in Play mode. fn new(board: game::Board) -> Self { Self { state: GameState::new(board), @@ -139,75 +95,7 @@ impl eframe::App for App { // --- Editor side panel --- // Must be declared before CentralPanel so egui allocates its space first. if self.mode == AppMode::Edit { - egui::SidePanel::right("palette_panel") - .resizable(true) - .default_width(PALETTE_PANEL_W) - .show(ctx, |ui| { - // Tab bar - ui.horizontal(|ui| { - ui.selectable_value(&mut self.editor.tab, EditorTab::Palette, "Palette"); - ui.selectable_value(&mut self.editor.tab, EditorTab::Board, "Board"); - ui.selectable_value(&mut self.editor.tab, EditorTab::World, "World"); - }); - ui.separator(); - - match self.editor.tab { - EditorTab::Palette => { - ui.label("Element"); - - // Scrollable archetype list; shows at most 5 rows before scrolling. - let row_h = CELL_H + ui.spacing().item_spacing.y; - egui::ScrollArea::vertical() - .max_height(row_h * 5.0) - .id_salt("arch_list") - .show(ui, |ui| { - for &archetype in ALL_ARCHETYPES { - let is_selected = self.editor.selected == archetype; - let glyph = archetype.default_glyph(); - ui.horizontal(|ui| { - let (r, cell_resp) = ui.allocate_exact_size( - vec2(CELL_W, CELL_H), Sense::click()); - { - let p = ui.painter(); - p.rect_filled(r, 0.0, glyph.bg); - p.text(r.center(), Align2::CENTER_CENTER, - glyph.ch, FontId::monospace(CELL_H), glyph.fg); - } - let label_resp = - ui.selectable_label(is_selected, archetype.name()); - if cell_resp.clicked() || label_resp.clicked() { - self.editor.selected = archetype; - self.editor.glyph = archetype.default_glyph(); - } - }); - } - }); - - ui.separator(); - - ui.label("Glyph"); - // Paint a preview cell showing the current glyph, sized like a board cell. - let (cell_rect, glyph_btn) = - ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); - { - let p = ui.painter(); - p.rect_filled(cell_rect, 0.0, self.editor.glyph.bg); - p.text( - cell_rect.center(), - Align2::CENTER_CENTER, - self.editor.glyph.ch, - FontId::monospace(CELL_H), - self.editor.glyph.fg, - ); - } - if glyph_btn.clicked() { - self.editor.glyph_picker_open = true; - } - } - EditorTab::Board => {} - EditorTab::World => {} - } - }); + show_editor_panel(ctx, &mut self.editor, &self.state.board); } // --- Board rendering --- @@ -224,8 +112,8 @@ impl eframe::App for App { egui::ScrollArea::new([true, true]).show(ui, |ui| { let (rect, response) = ui.allocate_exact_size(board_px, Sense::click()); draw_board(ui.painter(), rect.min, &self.state.board); - if response.clicked() { - if let Some(pos) = response.interact_pointer_pos() { + if response.clicked() + && let Some(pos) = response.interact_pointer_pos() { let (cx, cy) = pos_to_cell(rect.min, pos); // floor() keeps out-of-bounds negatives negative; caught here. if cx >= 0 && cy >= 0 { @@ -238,7 +126,6 @@ impl eframe::App for App { } } } - } }); } else { // Play mode: player-centered viewport, no scroll bars. @@ -252,104 +139,12 @@ impl eframe::App for App { // --- Glyph picker dialog --- // Rendered after all panels so it floats on top. if self.editor.glyph_picker_open { - // Collect unique glyphs from the board before the window closure - // borrows self mutably, to avoid a conflict with self.state.board. - let board_glyphs: Vec = { - let mut seen: Vec = Vec::new(); - for (glyph, _) in &self.state.board.cells { - if !seen.contains(glyph) { - seen.push(*glyph); - } - } - seen - }; - - // Use a local bool so the window's close button can write to it - // without conflicting with the closure's mutable borrow of self. - let mut picker_open = true; - egui::Window::new("Glyph") - .collapsible(false) - .resizable(false) - .open(&mut picker_open) - .show(ctx, |ui| { - // ----- Board palette ----- - ui.label("Board palette"); - ui.horizontal_wrapped(|ui| { - for &g in &board_glyphs { - let (r, resp) = - ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); - { - let p = ui.painter(); - p.rect_filled(r, 0.0, g.bg); - p.text(r.center(), Align2::CENTER_CENTER, g.ch, - FontId::monospace(CELL_H), g.fg); - if g == self.editor.glyph { - p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), - egui::epaint::StrokeKind::Middle); - } - } - if resp.clicked() { - self.editor.glyph = g; - } - } - }); - - ui.separator(); - - // ----- Colors ----- - ui.label("Colors"); - ui.horizontal(|ui| { - ui.label("FG"); - egui::color_picker::color_edit_button_srgba( - ui, &mut self.editor.glyph.fg, egui::color_picker::Alpha::Opaque); - ui.label("BG"); - egui::color_picker::color_edit_button_srgba( - ui, &mut self.editor.glyph.bg, egui::color_picker::Alpha::Opaque); - }); - - ui.separator(); - - // ----- Character grid ----- - // 16 columns × 6 rows covering printable ASCII 0x20–0x7E. - ui.label("Character"); - const CHAR_COLS: usize = 16; - let chars: Vec = - (0x20u32..=0x7eu32).filter_map(char::from_u32).collect(); - let rows = (chars.len() + CHAR_COLS - 1) / CHAR_COLS; - let grid_size = vec2(CHAR_COLS as f32 * CELL_W, rows as f32 * CELL_H); - let (grid_rect, grid_resp) = - ui.allocate_exact_size(grid_size, Sense::click()); - let fg = self.editor.glyph.fg; - let bg = self.editor.glyph.bg; - let cur_ch = self.editor.glyph.ch; - { - let p = ui.painter(); - for (i, &ch) in chars.iter().enumerate() { - let col = i % CHAR_COLS; - let row = i / CHAR_COLS; - let tl = grid_rect.min - + vec2(col as f32 * CELL_W, row as f32 * CELL_H); - let cell = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); - p.rect_filled(cell, 0.0, bg); - p.text(cell.center(), Align2::CENTER_CENTER, ch, - FontId::monospace(CELL_H), fg); - if ch == cur_ch { - p.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::WHITE), - egui::epaint::StrokeKind::Middle); - } - } - } - if grid_resp.clicked() { - if let Some(pos) = grid_resp.interact_pointer_pos() { - let (col, row) = pos_to_cell(grid_rect.min, pos); - let idx = row as usize * CHAR_COLS + col as usize; - if let Some(&ch) = chars.get(idx) { - self.editor.glyph.ch = ch; - } - } - } - }); - self.editor.glyph_picker_open = picker_open; + glyph_picker::show( + ctx, + &mut self.editor.glyph_picker_open, + &mut self.editor.glyph, + &self.state.board.cells, + ); } // egui repaints automatically on input events, so no explicit @@ -357,75 +152,3 @@ impl eframe::App for App { // 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); -} - -/// Draws all board cells then the player overlay at `origin`. -fn draw_board(painter: &egui::Painter, origin: Pos2, board: &game::Board) { - 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); - } - } - // The player is not a board cell; it is rendered on top as an overlay. - // This separate draw will be removed 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); -} - -/// Converts a pixel position to cell coordinates relative to `origin`. -/// -/// Uses floor so positions above or left of `origin` produce negative values — -/// callers must guard `>= 0` before treating the result as a valid cell index. -fn pos_to_cell(origin: Pos2, pos: Pos2) -> (i32, i32) { - let rel = pos - origin; - ((rel.x / CELL_W).floor() as i32, (rel.y / CELL_H).floor() as i32) -} - -/// 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) -} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..28d730d --- /dev/null +++ b/src/render.rs @@ -0,0 +1,96 @@ +use eframe::egui; +use egui::{Align2, FontId, Pos2, Rect, vec2}; + +use crate::game::{Board, Glyph, Player}; + +/// Width of one board cell in pixels. +pub(crate) const CELL_W: f32 = 14.0; +/// Height of one board cell in pixels. +pub(crate) const CELL_H: f32 = 20.0; + +/// Approximate height of the egui menu bar in pixels, used for window sizing. +pub(crate) const MENU_H: f32 = 24.0; +/// Default inner margin that egui's CentralPanel adds on all sides, in pixels. +pub(crate) const PANEL_MARGIN: f32 = 8.0; +/// Default width of the editor side panel in pixels. The panel is resizable. +pub(crate) const PALETTE_PANEL_W: f32 = 200.0; +/// Default window width in pixels. Independent of map size. +pub(crate) const DEFAULT_WINDOW_W: f32 = 840.0; +/// Default window height in pixels. Independent of map size. +pub(crate) const DEFAULT_WINDOW_H: f32 = 524.0; +/// Minimum window width: enough room for 10 cells plus margins. +pub(crate) 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. +pub(crate) const MIN_WINDOW_H: f32 = CELL_H * 5.0 + MENU_H + 2.0 * PANEL_MARGIN; + +/// 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`]. +pub(crate) 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); +} + +/// Draws all board cells then the player overlay at `origin`. +pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board) { + 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); + } + } + // The player is not a board cell; it is rendered on top as an overlay. + // This separate draw will be removed 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); +} + +/// Converts a pixel position to cell coordinates relative to `origin`. +/// +/// Uses floor so positions above or left of `origin` produce negative values — +/// callers must guard `>= 0` before treating the result as a valid cell index. +pub(crate) fn pos_to_cell(origin: Pos2, pos: Pos2) -> (i32, i32) { + let rel = pos - origin; + ((rel.x / CELL_W).floor() as i32, (rel.y / CELL_H).floor() as i32) +} + +/// 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. +pub(crate) fn board_origin(available: Rect, board_w: usize, board_h: usize, player: 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) +}