Files
kiln/src/main.rs
T
randrews c3d260266c Add player-centered viewport scrolling for variable map sizes
Window size is now fixed (840×524) and independent of map dimensions.
Small maps center in the viewport; maps larger than the viewport keep
the player centered and clamp at board edges so no empty space shows.
Also derives Copy+Clone on Player to enable the per-frame origin calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:49:52 -05:00

255 lines
10 KiB
Rust

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)
}