From 453c7baf4ed1a26ca6da8dce11360a8ac2023030 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 17 May 2026 12:48:52 -0500 Subject: [PATCH] Add rustdoc and inline comments throughout; update CLAUDE.md style guide All public types, fields, and functions in game.rs and map_file.rs now have /// doc comments. main.rs has /// on App and draw_glyph plus inline comments explaining the input/render pipeline. CLAUDE.md records the expectation that generated code includes comments. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 +++ src/game.rs | 121 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 38 +++++++++++++++ src/map_file.rs | 73 +++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d005958..ad70bf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `viberogue` is a ZZT-inspired game-making system written in Rust (edition 2024). The goal is a system where players can create games using a scripting language, similar to the classic DOS game ZZT. It uses **Rhai** for scripting (WASM-compatible, sandboxed, pure Rust). +## Code style + +- Add `///` rustdoc comments to every `pub` type, field, and function. The user reads rustdoc in their IDE to understand the code while making changes. +- Add inline `//` comments inside non-trivial function bodies to explain the *why* of each logical step — especially in `update` loops, rendering math, and conversion logic. +- Keep comments accurate: update them when the code they describe changes. + ## Commands ```bash diff --git a/src/game.rs b/src/game.rs index 0c912f6..00791c4 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,57 +1,152 @@ use eframe::egui::Color32; use serde::Deserialize; +/// The visual representation of a single board cell. +/// +/// `Glyph` holds everything needed to draw one cell on screen: which character +/// to display and what colors to use. It is stored per-cell (not per element +/// type), so individual cells can vary their appearance independently — for +/// example, a field of "fire" tiles where each flame has a slightly different +/// color while all sharing the same [`Element`] behavior. +/// +/// `Glyph` values come from the map file palette and are set at load time. +/// The player is the only entity whose glyph is hardcoded at runtime +/// (see [`Glyph::player`]). #[derive(Clone, Copy)] pub struct Glyph { + /// The character to display in this cell. pub ch: char, + /// Foreground (text) color. pub fg: Color32, + /// Background color, drawn as a filled rectangle behind the character. pub bg: Color32, } impl Glyph { + /// Returns the glyph used to render the player: `@` in light blue on black. + /// + /// This is the only hardcoded glyph; all other glyphs come from the map + /// file palette. It will be removed once the player becomes a scripted + /// object with its own palette entry. pub fn player() -> Self { Self { ch: '@', fg: Color32::LIGHT_BLUE, bg: Color32::BLACK } } } +/// The behavioral definition of a tile type. +/// +/// `Element` describes how a tile *behaves*, independent of how it looks. +/// Behavior is shared: many cells on the board can reference the same +/// `Element` by index (see [`Board::elements`]), so changing one `Element` +/// affects all cells that use it. +/// +/// The visual side is handled separately by [`Glyph`], which is stored +/// per-cell and can vary even among cells of the same element type. pub struct Element { + /// Whether the player (or other entities) can walk onto this tile. pub passable: bool, } +/// A scripted object placed on the board, loaded from a map file. +/// +/// `ObjectDef` represents a tile that has Rhai script attached to it. +/// Scripts can respond to events like the player touching or shooting the +/// tile. Objects are parsed from `[[objects]]` entries in `.toml` map files +/// and stored on [`Board`]. +/// +/// **Not yet runtime-wired.** Objects are loaded and stored but their scripts +/// are not yet executed. Scripting dispatch is a future feature. #[derive(Deserialize)] #[allow(dead_code)] pub struct ObjectDef { + /// Column of this object on the board (0-indexed). pub x: usize, + /// Row of this object on the board (0-indexed). pub y: usize, + /// The Rhai script source for this object. pub script: String, } +/// A portal that teleports the player to a named entry point on another board. +/// +/// Portals are loaded from `[[portals]]` entries in `.toml` map files and +/// stored on [`Board`]. When the player steps onto a portal's cell, they +/// should be moved to `target_entry` on the board named by `target_map`. +/// +/// **Not yet runtime-wired.** Portal navigation (multi-board loading and +/// switching) is a future feature. #[derive(Deserialize)] #[allow(dead_code)] pub struct PortalDef { + /// Column of this portal on the board (0-indexed). pub x: usize, + /// Row of this portal on the board (0-indexed). pub y: usize, + /// File name (without extension) of the target board, e.g. `"cave"`. pub target_map: String, + /// Named entry point on the target board where the player arrives. pub target_entry: String, } +/// The player's current position on the board. +/// +/// The player is currently a special entity rendered on top of the board +/// rather than being stored as a board cell. This is expected to change: +/// 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. pub struct Player { + /// Column position (0-indexed, increasing rightward). pub x: i32, + /// Row position (0-indexed, increasing downward). pub y: i32, } +/// The complete state of one game board (a single room or screen). +/// +/// `Board` is the central data structure of the engine, equivalent to a +/// "board" in ZZT. It contains everything needed to represent and run one +/// self-contained area of the game world: +/// +/// - A grid of cells, each with a visual ([`Glyph`]) and a behavior index +/// - A palette of [`Element`] behavior definitions shared across cells +/// - The current player position +/// - Scripted objects and portals (loaded but not yet active) +/// +/// ## Cell storage +/// +/// Cells are stored as `(Glyph, usize)` tuples in a row-major `Vec`. The +/// `usize` is an index into [`Board::elements`]. It is an anonymous tuple +/// field intentionally — the index has no meaning outside the context of +/// a specific `Board`'s element palette, so it cannot be accidentally +/// detached and misused. +/// +/// Access cells with [`Board::get`] and [`Board::get_mut`] using `(x, y)` +/// coordinates. Use [`Board::is_passable`] for collision checks. #[allow(dead_code)] pub struct Board { + /// Width of the board in cells. pub width: usize, + /// Height of the board in cells. pub height: usize, + /// Palette of element behavior definitions. Cells reference these by index. pub elements: Vec, + /// Row-major grid of `(Glyph, element_index)` pairs. Use [`Board::get`] + /// to access by coordinates. pub(crate) cells: Vec<(Glyph, usize)>, + /// Current player position. See [`Player`] for caveats about its future. pub player: Player, + /// Scripted objects on this board. Parsed from the map file; not yet active. pub objects: Vec, + /// Portals on this board. Parsed from the map file; not yet active. pub portals: Vec, } impl Board { + /// Adds an element to this board's palette and returns its index. + /// + /// The returned index can be used as the second field of a cell tuple. + /// Indices are stable for the lifetime of the board. #[allow(dead_code)] pub fn add_element(&mut self, element: Element) -> usize { let idx = self.elements.len(); @@ -59,30 +154,54 @@ impl Board { idx } + /// Returns a reference to the cell at `(x, y)`. + /// + /// The cell is a `(Glyph, usize)` tuple where the `usize` is an index + /// into [`Board::elements`]. Panics if `x` or `y` are out of bounds. pub fn get(&self, x: usize, y: usize) -> &(Glyph, usize) { &self.cells[y * self.width + x] } + /// Returns a mutable reference to the cell at `(x, y)`. + /// + /// Panics if `x` or `y` are out of bounds. #[allow(dead_code)] pub fn get_mut(&mut self, x: usize, y: usize) -> &mut (Glyph, usize) { &mut self.cells[y * self.width + x] } + /// Returns `true` if the element at `(x, y)` is passable. + /// + /// Panics if `x` or `y` are out of bounds. pub fn is_passable(&self, x: usize, y: usize) -> bool { let (_, elem_idx) = self.get(x, y); self.elements[*elem_idx].passable } } +/// Holds the active game board and provides game-logic operations. +/// +/// `GameState` is the boundary between the engine (rendering, input) and the +/// game data ([`Board`]). It owns the current board and exposes methods for +/// actions that involve game rules — currently just player movement. +/// +/// As scripting and event dispatch are added, `GameState` will grow to handle +/// routing input events to the appropriate Rhai scripts. pub struct GameState { + /// The currently active board. pub board: Board, } impl GameState { + /// Creates a `GameState` from a pre-loaded [`Board`]. pub fn new(board: Board) -> Self { Self { board } } + /// Attempts to move the player by `(dx, dy)` cells. + /// + /// The move is ignored if the target cell is out of bounds or its element + /// is not passable. No-ops silently (the caller does not need to check). pub fn try_move(&mut self, dx: i32, dy: i32) { let nx = self.board.player.x + dx; let ny = self.board.player.y + dy; @@ -95,4 +214,4 @@ impl GameState { } } } -} +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index de89683..f2b3858 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,20 @@ mod game; mod map_file; use game::{GameState, Glyph}; +/// 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. +/// The minimum window size accounts for this so the board fits without clipping. const PANEL_MARGIN: f32 = 8.0; 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; @@ -29,6 +36,10 @@ fn main() -> eframe::Result<()> { ) } +/// 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, } @@ -41,6 +52,9 @@ impl App { impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // --- Input --- + // Arrow keys are read before any panel is drawn. egui accumulates + // key events between frames; key_pressed returns true once per press. 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); } @@ -48,6 +62,7 @@ impl eframe::App for App { 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| { @@ -58,14 +73,23 @@ impl eframe::App for App { }); }); + // --- 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 board = &self.state.board; 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(board.width as f32 * CELL_W, board.height as f32 * CELL_H); let offset = ((available.size() - board_px) / 2.0).max(egui::Vec2::ZERO); let origin = available.min + offset; + 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); @@ -73,6 +97,9 @@ impl eframe::App for App { } } + // 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, @@ -83,10 +110,21 @@ impl eframe::App for App { ); }); + // Force a repaint every frame so input is processed continuously. + // Remove this if the game becomes purely event-driven. ctx.request_repaint(); } } +/// 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)); diff --git a/src/map_file.rs b/src/map_file.rs index 9277ac9..2cd5d95 100644 --- a/src/map_file.rs +++ b/src/map_file.rs @@ -3,39 +3,88 @@ use serde::Deserialize; use eframe::egui::Color32; use crate::game::{Board, Element, Glyph, ObjectDef, Player, PortalDef}; +/// The top-level deserialization type for a `.toml` map file. +/// +/// This struct mirrors the TOML file structure exactly and is used only +/// during loading — it is immediately converted into a [`Board`] via +/// [`From`] and then discarded. It is never used at runtime. +/// +/// See `maps/start.toml` for a complete example of the file format. #[derive(Deserialize)] pub struct MapFile { + /// The `[map]` section: name, dimensions, player start position. pub map: MapHeader, + /// The `[palette]` section: maps single-character keys to tile definitions. + /// Each entry defines both the visual ([`Glyph`]) and behavior ([`Element`]) + /// for all cells that use that character in the grid. pub palette: HashMap, + /// The `[grid]` section: the multi-line string that defines cell layout. pub grid: GridData, + /// Any `[[objects]]` entries. Optional; defaults to empty. #[serde(default)] pub objects: Vec, + /// Any `[[portals]]` entries. Optional; defaults to empty. #[serde(default)] pub portals: Vec, } +/// The `[map]` header section of a map file. #[derive(Deserialize)] pub struct MapHeader { + /// Human-readable name for this board, e.g. `"Opening Room"`. + /// Not yet displayed anywhere at runtime. #[allow(dead_code)] pub name: String, + /// Width of the board in cells. Must match the length of every row in + /// `[grid] content`. pub width: usize, + /// Height of the board in cells. Must match the number of rows in + /// `[grid] content`. pub height: usize, + /// Starting position of the player as `[x, y]` (0-indexed, origin + /// top-left). This field will become optional once the player is a + /// scripted object rather than a hardcoded entity. pub player_start: [i32; 2], } +/// One entry in the `[palette]` table. +/// +/// Each palette entry is keyed by a single character (e.g. `"#"` or `" "`). +/// That character is used in the `[grid]` content string to place tiles. +/// The entry defines both how the tile looks (`ch`, `fg`, `bg`) and how it +/// behaves (`passable`). +/// +/// Note that `ch` (the display character) does not have to match the palette +/// key. The key is just a label for the grid; `ch` is what actually gets +/// rendered. This allows, for example, using `"W"` as the palette key for a +/// wall tile that displays as `#`. #[derive(Deserialize)] pub struct PaletteEntry { + /// Whether entities can walk onto this tile. pub passable: bool, + /// The character displayed in this cell. pub ch: char, + /// Foreground color as an `"#RRGGBB"` hex string. pub fg: String, + /// Background color as an `"#RRGGBB"` hex string. pub bg: String, } +/// The `[grid]` section of a map file. +/// +/// `content` is a TOML multi-line string. Each line is one row of the board; +/// each character in a line is looked up in the palette to determine the +/// tile for that cell. TOML automatically strips the newline immediately +/// following the opening `"""`, so no special handling is needed for a +/// leading blank line. #[derive(Deserialize)] pub struct GridData { + /// The raw grid content. Split by [`str::lines`] during loading. pub content: String, } +/// Parses an `"#RRGGBB"` hex color string into a [`Color32`]. +/// Returns black on any parse failure. fn parse_color(hex: &str) -> Color32 { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { @@ -47,11 +96,26 @@ fn parse_color(hex: &str) -> Color32 { Color32::from_rgb(r, g, b) } +/// Converts a parsed map file into a runtime [`Board`]. +/// +/// The conversion proceeds in two passes: +/// +/// 1. **Palette pass** — each palette entry becomes an [`Element`] (pushed +/// into `elements`) and a [`Glyph`] (stored in a temporary `HashMap` +/// keyed by the palette character). The element index is recorded +/// alongside the glyph so cells can reference it. +/// +/// 2. **Grid pass** — `grid.content` is split into lines; each character +/// is looked up in the palette map. Unknown characters are silently +/// skipped (they produce no cell), so a malformed grid will result in +/// a `cells` vec shorter than `width * height`. No explicit validation +/// is done here yet. impl From for Board { fn from(mf: MapFile) -> Self { let w = mf.map.width; let h = mf.map.height; + // Pass 1: build the element palette and a char→(Glyph, idx) lookup. let mut elements: Vec = Vec::new(); let mut palette: HashMap = HashMap::new(); @@ -66,6 +130,7 @@ impl From for Board { }, idx)); } + // Pass 2: walk the grid string and build cells. let mut cells: Vec<(Glyph, usize)> = Vec::with_capacity(w * h); for line in mf.grid.content.lines() { for ch in line.chars() { @@ -90,6 +155,14 @@ impl From for Board { } } +/// Loads a map file from disk and returns a ready-to-use [`Board`]. +/// +/// Reads the file at `path`, deserializes it as TOML into a [`MapFile`], +/// then converts it via [`From`]. Propagates both I/O errors and +/// TOML parse errors through the `Box` return. +/// +/// Called from `main()` before the window is created so that board +/// dimensions are available for window sizing. pub fn load(path: &str) -> Result> { let content = std::fs::read_to_string(path)?; let map_file: MapFile = toml::from_str(&content)?;