object layer

This commit is contained in:
2026-05-30 19:25:10 -05:00
parent 96ec8e8e92
commit d19737c113
5 changed files with 207 additions and 53 deletions
+27 -6
View File
@@ -39,13 +39,15 @@ pub(crate) struct EditorState {
pub(crate) selected_object: Option<usize>,
/// Whether the glyph picker is open for editing the selected object's glyph.
pub(crate) object_glyph_picker_open: bool,
/// Local copy of the selected object's cell glyph for the picker to edit.
/// Local copy of the selected object's glyph for the picker to edit.
///
/// Kept separate from `board.cells` to avoid a borrow conflict: `glyph_picker::show`
/// takes both `&mut Glyph` (edit target) and `&[(Glyph, Archetype)]` (board palette),
/// and those can't both alias `board.cells`. This copy is written back to the board
/// each frame while an object is selected.
/// Kept separate from `board.objects` to avoid a borrow conflict while the
/// glyph picker holds `&mut Glyph` and the board is also borrowed. Written
/// back to `board.objects[i].glyph` each frame while an object is selected.
pub(crate) object_editing_glyph: Glyph,
/// When `true`, the next board click in the Objects tab places a new object.
/// Set by the "Add Object" button; cleared after placement or on tab switch.
pub(crate) placing_object: bool,
}
impl EditorState {
@@ -61,6 +63,7 @@ impl EditorState {
selected_object: None,
object_glyph_picker_open: false,
object_editing_glyph: Glyph::player(), // overwritten on first selection
placing_object: false,
}
}
}
@@ -134,9 +137,27 @@ pub(crate) fn show_editor_panel(
}
EditorTab::Objects => {
// "Add Object" toggles placement mode; next board click places a new object.
let add_label = if editor.placing_object {
"Cancel Add"
} else {
"Add Object"
};
if ui.button(add_label).clicked() {
editor.placing_object = !editor.placing_object;
editor.selected_object = None;
}
if editor.placing_object {
ui.label("Click on the board to place.");
}
ui.separator();
match editor.selected_object {
None => {
ui.label("Click an object on the board to select it.");
if !editor.placing_object {
ui.label("Click an object on the board to select it.");
}
}
Some(i) => {
// Collect sorted script names before taking a mutable borrow on objects.
+24 -10
View File
@@ -178,7 +178,8 @@ impl TryFrom<&str> for Archetype {
match name {
"empty" => Ok(Archetype::Empty),
"wall" => Ok(Archetype::Wall),
"object" => Ok(Archetype::Object),
// "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph.
_ => Err(format!("unknown archetype: {name}")),
}
}
@@ -197,22 +198,28 @@ pub const ALL_ARCHETYPES: &[Archetype] = &[Archetype::Empty, Archetype::Wall, Ar
/// tile. Objects are parsed from `[[objects]]` entries in `.toml` map files
/// and stored on [`Board`].
///
/// Objects are rendered as an overlay on top of the board grid — the grid
/// cell at `(x, y)` holds the background (floor) that is revealed if the
/// object moves away. `glyph` is owned by the object itself and may be
/// mutated by its Rhai script at runtime.
///
/// Script text lives in [`Board::scripts`]; this struct holds only the name
/// used to look it up. Two `ObjectDef`s with the same `script_name` share
/// source text but will run with independent Rhai scopes (independent local
/// variables) when the scripting runtime is wired.
/// source text but will run with independent Rhai scopes when the scripting
/// runtime is wired.
///
/// **Not yet runtime-wired.** Objects are loaded and stored but their scripts
/// are not yet executed. Scripting dispatch is a future feature.
#[derive(Deserialize, Serialize)]
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,
/// Visual representation of this object. Owned by the object (not derived
/// from the grid cell), so scripts can change tile, fg, and bg at runtime.
pub glyph: Glyph,
/// Name of the Rhai script in [`Board::scripts`] that drives this object.
/// `None` means this object has no script yet.
#[serde(default)]
pub script_name: Option<String>,
}
@@ -316,14 +323,21 @@ impl Board {
&mut self.cells[y * self.width + x]
}
/// Returns `true` if the archetype at `(x, y)` allows entities to pass through.
/// Returns `true` if the cell at `(x, y)` allows entities to pass through.
///
/// Objects block movement before the grid cell archetype is consulted.
/// Panics if `x` or `y` are out of bounds.
pub fn is_passable(&self, x: usize, y: usize) -> bool {
let (_, arch) = self.get(x, y);
// Derive passability from the archetype's behavior.
// Object cells will need special handling here once scripts are wired.
arch.behavior().passable
// An object on this cell always blocks (until scripting overrides this).
if self.object_at(x, y).is_some() {
return false;
}
self.get(x, y).1.behavior().passable
}
/// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any.
pub fn object_at(&self, x: usize, y: usize) -> Option<usize> {
self.objects.iter().position(|o| o.x == x && o.y == y)
}
}
+25 -27
View File
@@ -12,7 +12,7 @@ mod render;
use editor::{EditorState, EditorTab, show_editor_panel};
use font::BitmapFont;
use game::{Archetype, FontSpec, GameState, ObjectDef};
use game::{Archetype, FontSpec, GameState, Glyph, ObjectDef};
use render::{
DEFAULT_WINDOW_H, DEFAULT_WINDOW_W, MIN_WINDOW_H, MIN_WINDOW_W, board_origin, draw_board,
draw_object_overlays, pos_to_cell,
@@ -234,36 +234,36 @@ impl eframe::App for App {
let board = &mut self.state.board;
if cx < board.width && cy < board.height {
if self.editor.tab == EditorTab::Objects {
// Objects tab: clicks select an object rather than paint.
let found = board
.objects
.iter()
.position(|o| o.x == cx && o.y == cy);
self.editor.selected_object = found;
self.editor.object_glyph_picker_open = false;
if let Some(i) = found {
// Snapshot the object's cell glyph for the picker.
self.editor.object_editing_glyph =
board.get(board.objects[i].x, board.objects[i].y).0;
}
} else {
// Other tabs: paint the clicked cell and keep objects in sync.
let old_arch = board.get(cx, cy).1;
let arch = self.editor.selected;
*board.get_mut(cx, cy) = (self.editor.glyph, arch);
if arch == Archetype::Object {
// Add an ObjectDef if one doesn't already exist here.
if !board.objects.iter().any(|o| o.x == cx && o.y == cy) {
if self.editor.placing_object {
// Placement mode: create a new object here if none exists.
if board.object_at(cx, cy).is_none() {
board.objects.push(ObjectDef {
x: cx,
y: cy,
glyph: Glyph {
tile: Archetype::Object.default_glyph().tile,
fg: Archetype::Object.default_glyph().fg,
bg: Archetype::Object.default_glyph().bg,
},
script_name: None,
});
}
} else if old_arch == Archetype::Object {
// Overwriting an Object cell: remove its ObjectDef.
board.objects.retain(|o| !(o.x == cx && o.y == cy));
self.editor.placing_object = false;
} else {
// Selection mode: click to select an existing object.
let found = board.object_at(cx, cy);
self.editor.selected_object = found;
self.editor.object_glyph_picker_open = false;
if let Some(i) = found {
// Snapshot the object's glyph for the picker.
self.editor.object_editing_glyph =
board.objects[i].glyph;
}
}
} else {
// Other tabs: paint the grid cell (floor only).
// Objects on this cell are left untouched — they sit on top.
*board.get_mut(cx, cy) = (self.editor.glyph, self.editor.selected);
}
}
}
@@ -308,9 +308,7 @@ impl eframe::App for App {
// Any change the picker made to object_editing_glyph is flushed here.
if let Some(i) = self.editor.selected_object {
if i < self.state.board.objects.len() {
let x = self.state.board.objects[i].x;
let y = self.state.board.objects[i].y;
self.state.board.get_mut(x, y).0 = self.editor.object_editing_glyph;
self.state.board.objects[i].glyph = self.editor.object_editing_glyph;
}
}
}
+120 -5
View File
@@ -26,7 +26,7 @@ pub struct MapFile {
pub font: Option<FontHeader>,
/// Any `[[objects]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<ObjectDef>,
pub objects: Vec<MapFileObjectEntry>,
/// Any `[[portals]]` entries. Optional; defaults to empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portals: Vec<PortalDef>,
@@ -121,6 +121,28 @@ pub struct GridData {
pub content: String,
}
/// One `[[objects]]` entry in a map file.
///
/// Used only for TOML serialization/deserialization. Converted to/from
/// [`ObjectDef`] (which holds a [`Glyph`] directly) in the [`TryFrom`] /
/// [`From`] impls below.
#[derive(Deserialize, Serialize)]
pub(crate) struct MapFileObjectEntry {
/// Column of this object on the board (0-indexed).
x: usize,
/// Row of this object on the board (0-indexed).
y: usize,
/// Tile index into the board's bitmap font. Accepts integer or single-char string.
tile: TileIndex,
/// Foreground color as an `"#RRGGBB"` hex string.
fg: String,
/// Background color as an `"#RRGGBB"` hex string.
bg: String,
/// Name of the Rhai script in the `[scripts]` table, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
script_name: Option<String>,
}
/// Parses an `"#RRGGBB"` hex color string into a [`Color32`].
/// Returns black on any parse failure.
fn parse_color(hex: &str) -> Color32 {
@@ -222,6 +244,22 @@ impl TryFrom<MapFile> for Board {
tile_h: f.tile_h,
});
// Convert MapFileObjectEntry → ObjectDef, resolving tile index and color strings.
let objects = mf
.objects
.into_iter()
.map(|e| ObjectDef {
x: e.x,
y: e.y,
glyph: Glyph {
tile: e.tile.into_u32(),
fg: parse_color(&e.fg),
bg: parse_color(&e.bg),
},
script_name: e.script_name,
})
.collect();
Ok(Board {
name: mf.map.name,
width: w,
@@ -231,7 +269,7 @@ impl TryFrom<MapFile> for Board {
x: mf.map.player_start[0],
y: mf.map.player_start[1],
},
objects: mf.objects,
objects,
portals: mf.portals,
font,
scripts: mf.scripts,
@@ -317,13 +355,16 @@ impl From<&Board> for MapFile {
tile_h: f.tile_h,
});
// Reconstruct objects as plain structs (ObjectDef/PortalDef don't implement Clone).
let objects = board
// Convert ObjectDef → MapFileObjectEntry, encoding the glyph as TOML-compatible fields.
let objects: Vec<MapFileObjectEntry> = board
.objects
.iter()
.map(|o| ObjectDef {
.map(|o| MapFileObjectEntry {
x: o.x,
y: o.y,
tile: TileIndex::Num(o.glyph.tile),
fg: color_to_hex(o.glyph.fg),
bg: color_to_hex(o.glyph.bg),
script_name: o.script_name.clone(),
})
.collect();
@@ -429,4 +470,78 @@ content = """
assert!(msg.contains("3 characters"), "expected col count in error, got: {msg}");
assert!(msg.contains("width = 4"), "expected declared width in error, got: {msg}");
}
#[test]
fn object_archetype_in_palette_produces_error_block() {
// "object" is no longer a valid palette archetype; it should produce ErrorBlock.
let toml = r##"
[map]
name = "Test"
width = 1
height = 1
player_start = [0, 0]
[palette]
"O" = { archetype = "object", tile = 64, fg = "#00FFFF", bg = "#000000" }
[grid]
content = """
O
"""
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
let (_, arch) = board.get(0, 0);
assert_eq!(*arch, crate::game::Archetype::ErrorBlock);
}
#[test]
fn object_glyph_round_trips_through_toml() {
// Objects must survive a save→load cycle with their glyph intact.
use eframe::egui::Color32;
let toml = r##"
[map]
name = "Test"
width = 3
height = 3
player_start = [0, 0]
[palette]
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
[grid]
content = """
...
...
...
"""
[[objects]]
x = 1
y = 1
tile = 64
fg = "#00FFFF"
bg = "#000000"
"##;
let mf: MapFile = toml::from_str(toml).unwrap();
let board = Board::try_from(mf).unwrap();
assert_eq!(board.objects.len(), 1);
let obj = &board.objects[0];
assert_eq!(obj.x, 1);
assert_eq!(obj.y, 1);
assert_eq!(obj.glyph.tile, 64);
assert_eq!(obj.glyph.fg, Color32::from_rgb(0x00, 0xFF, 0xFF));
assert_eq!(obj.glyph.bg, Color32::BLACK);
// Save back to TOML and reload; glyph must survive.
let map_file = MapFile::from(&board);
let toml_out = toml::to_string_pretty(&map_file).unwrap();
let mf2: MapFile = toml::from_str(&toml_out).unwrap();
let board2 = Board::try_from(mf2).unwrap();
let obj2 = &board2.objects[0];
assert_eq!(obj2.glyph.tile, obj.glyph.tile);
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
}
}
+11 -5
View File
@@ -76,16 +76,23 @@ pub(crate) fn draw_glyph(
paint_glyph(painter, rect, glyph, font);
}
/// Draws all board cells then the player overlay at `origin`.
/// Draws all board cells, then the object overlay, then the player at `origin`.
///
/// Rendering order: grid (floor) → objects → player. Objects are drawn on top
/// of their grid cell's background, which is revealed if the object moves away.
pub(crate) fn draw_board(painter: &egui::Painter, origin: Pos2, board: &Board, font: &BitmapFont) {
// Pass 1: grid floor.
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, font);
}
}
// 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.
// Pass 2: objects drawn on top of the floor.
for obj in &board.objects {
draw_glyph(painter, origin, obj.x, obj.y, &obj.glyph, font);
}
// Pass 3: player overlay. Will be removed once the player becomes a scripted object.
let player_glyph = Glyph::player();
draw_glyph(
painter,
@@ -126,8 +133,7 @@ pub(crate) fn draw_object_overlays(
let rect = Rect::from_min_size(tl, vec2(tw, th));
// Redraw on top of the dim overlay so this cell remains bright.
let (glyph, _) = board.get(obj.x, obj.y);
paint_glyph(painter, rect, glyph, font);
paint_glyph(painter, rect, &obj.glyph, font);
// Yellow outline for the selected object, white for all others.
let outline = if selected == Some(i) {