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>
This commit is contained in:
2026-05-17 16:49:52 -05:00
parent f0c49295ea
commit c3d260266c
2 changed files with 49 additions and 18 deletions
+1
View File
@@ -190,6 +190,7 @@ pub struct PortalDef {
/// the player will eventually become a scripted object that responds to
/// input events, at which point this struct may be removed or made optional.
/// See `ARCHITECTURE.md` for details.
#[derive(Copy, Clone)]
pub struct Player {
/// Column position (0-indexed, increasing rightward).
pub x: i32,
+48 -18
View File
@@ -13,10 +13,17 @@ 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.
/// The minimum window size accounts for this so the board fits without clipping.
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.
///
@@ -44,18 +51,12 @@ impl EditorState {
}
fn main() -> eframe::Result<()> {
// Load the board before creating the window so its dimensions can drive
// the initial and minimum window size.
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;
// Window min-size is sized for Play mode. In Edit mode the palette panel
// steals PALETTE_PANEL_W pixels; the board may clip at minimum window size.
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]),
.with_inner_size([DEFAULT_WINDOW_W, DEFAULT_WINDOW_H])
.with_min_inner_size([MIN_WINDOW_W, MIN_WINDOW_H]),
..Default::default()
};
eframe::run_native(
@@ -138,15 +139,12 @@ impl eframe::App for App {
egui::CentralPanel::default().show(ctx, |ui| {
let available = ui.available_rect_before_wrap();
// 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.
let board_px = vec2(
self.state.board.width as f32 * CELL_W,
self.state.board.height as f32 * CELL_H,
);
let offset = ((available.size() - board_px) / 2.0).max(egui::Vec2::ZERO);
let origin = available.min + offset;
// 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.
@@ -222,3 +220,35 @@ fn draw_glyph(painter: &egui::Painter, origin: Pos2, x: usize, y: usize, glyph:
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)
}