Add editor mode with archetype palette and click-to-paint
AppMode enum (Play/Edit) gates arrow-key input; a mode toggle in the menu bar switches between them. Edit mode shows a SidePanel listing the available archetypes (Empty, Wall, Object); clicking one selects it. Clicking a board cell in Edit mode stamps it with the selected archetype's default glyph. Mouse-to-cell conversion mirrors the rendering origin math so clicks land on the correct cell. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+121
-31
@@ -1,9 +1,9 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use egui::{Align2, FontId, Key, Pos2, Rect, vec2};
|
use egui::{Align2, FontId, Key, Pos2, Rect, Sense, vec2};
|
||||||
|
|
||||||
mod game;
|
mod game;
|
||||||
mod map_file;
|
mod map_file;
|
||||||
use game::{GameState, Glyph};
|
use game::{Archetype, GameState, Glyph, ALL_ARCHETYPES};
|
||||||
|
|
||||||
/// Width of one board cell in pixels.
|
/// Width of one board cell in pixels.
|
||||||
const CELL_W: f32 = 14.0;
|
const CELL_W: f32 = 14.0;
|
||||||
@@ -15,6 +15,33 @@ const MENU_H: f32 = 24.0;
|
|||||||
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
|
/// 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.
|
/// The minimum window size accounts for this so the board fits without clipping.
|
||||||
const PANEL_MARGIN: f32 = 8.0;
|
const PANEL_MARGIN: f32 = 8.0;
|
||||||
|
/// Width of the editor palette side panel in pixels.
|
||||||
|
const PALETTE_PANEL_W: f32 = 120.0;
|
||||||
|
|
||||||
|
/// 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<()> {
|
fn main() -> eframe::Result<()> {
|
||||||
// Load the board before creating the window so its dimensions can drive
|
// Load the board before creating the window so its dimensions can drive
|
||||||
@@ -23,6 +50,8 @@ fn main() -> eframe::Result<()> {
|
|||||||
let board_px_w = board.width as f32 * CELL_W + 2.0 * PANEL_MARGIN;
|
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;
|
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 {
|
let options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_inner_size([board_px_w, board_px_h])
|
.with_inner_size([board_px_w, board_px_h])
|
||||||
@@ -42,25 +71,32 @@ fn main() -> eframe::Result<()> {
|
|||||||
/// updates, and rendering happen there. There is no separate game loop thread.
|
/// updates, and rendering happen there. There is no separate game loop thread.
|
||||||
struct App {
|
struct App {
|
||||||
state: GameState,
|
state: GameState,
|
||||||
|
mode: AppMode,
|
||||||
|
editor: EditorState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn new(board: game::Board) -> Self {
|
fn new(board: game::Board) -> Self {
|
||||||
Self { state: GameState::new(board) }
|
Self {
|
||||||
|
state: GameState::new(board),
|
||||||
|
mode: AppMode::Play,
|
||||||
|
editor: EditorState::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
// --- Input ---
|
// --- Input ---
|
||||||
// Arrow keys are read before any panel is drawn. egui accumulates
|
// Arrow keys move the player only in Play mode.
|
||||||
// key events between frames; key_pressed returns true once per press.
|
if self.mode == AppMode::Play {
|
||||||
ctx.input(|i| {
|
ctx.input(|i| {
|
||||||
if i.key_pressed(Key::ArrowUp) { self.state.try_move( 0, -1); }
|
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::ArrowDown) { self.state.try_move( 0, 1); }
|
||||||
if i.key_pressed(Key::ArrowLeft) { self.state.try_move(-1, 0); }
|
if i.key_pressed(Key::ArrowLeft) { self.state.try_move(-1, 0); }
|
||||||
if i.key_pressed(Key::ArrowRight) { self.state.try_move( 1, 0); }
|
if i.key_pressed(Key::ArrowRight) { self.state.try_move( 1, 0); }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Menu bar ---
|
// --- Menu bar ---
|
||||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||||
@@ -70,44 +106,98 @@ impl eframe::App for App {
|
|||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
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 ---
|
// --- Board rendering ---
|
||||||
// The board is drawn using the egui Painter API (direct 2D drawing),
|
// The board is drawn using the egui Painter API (direct 2D drawing),
|
||||||
// not egui widgets. This gives pixel-level control over placement.
|
// not egui widgets. This gives pixel-level control over placement.
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
let board = &self.state.board;
|
|
||||||
let available = ui.available_rect_before_wrap();
|
let available = ui.available_rect_before_wrap();
|
||||||
|
|
||||||
// Center the board within the panel. If the window is at minimum
|
// 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
|
// size the offset is zero; if the window is larger the board floats
|
||||||
// in the middle.
|
// in the middle.
|
||||||
let board_px = vec2(board.width as f32 * CELL_W, board.height as f32 * CELL_H);
|
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 offset = ((available.size() - board_px) / 2.0).max(egui::Vec2::ZERO);
|
||||||
let origin = available.min + offset;
|
let origin = available.min + offset;
|
||||||
|
|
||||||
let painter = ui.painter();
|
// 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.
|
// Draw each board cell: filled background rect, then the character.
|
||||||
for y in 0..board.height {
|
for y in 0..board.height {
|
||||||
for x in 0..board.width {
|
for x in 0..board.width {
|
||||||
let (glyph, _) = board.get(x, y);
|
let (glyph, _) = board.get(x, y);
|
||||||
draw_glyph(painter, origin, x, y, glyph);
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the player on top of the board. The player is not stored as
|
// In Edit mode, capture clicks on the viewport and paint cells.
|
||||||
// a board cell, so it is rendered as a separate overlay. This will
|
if self.mode == AppMode::Edit {
|
||||||
// change once the player becomes a scripted object.
|
let response = ui.allocate_rect(available, Sense::click());
|
||||||
let player_glyph = Glyph::player();
|
if response.clicked() {
|
||||||
draw_glyph(
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
painter,
|
// Convert pixel position to cell coordinates using the
|
||||||
origin,
|
// same origin computed for rendering above.
|
||||||
self.state.board.player.x as usize,
|
let rel = pos - origin;
|
||||||
self.state.board.player.y as usize,
|
let cx = (rel.x / CELL_W).floor() as i32;
|
||||||
&player_glyph,
|
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
|
// egui repaints automatically on input events, so no explicit
|
||||||
@@ -131,4 +221,4 @@ fn draw_glyph(painter: &egui::Painter, origin: Pos2, x: usize, y: usize, glyph:
|
|||||||
let center = rect.center();
|
let center = rect.center();
|
||||||
painter.rect_filled(rect, 0.0, glyph.bg);
|
painter.rect_filled(rect, 0.0, glyph.bg);
|
||||||
painter.text(center, Align2::CENTER_CENTER, glyph.ch, FontId::monospace(CELL_H), glyph.fg);
|
painter.text(center, Align2::CENTER_CENTER, glyph.ch, FontId::monospace(CELL_H), glyph.fg);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user