object layer
This commit is contained in:
+27
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user