Compare commits
41 Commits
6d2d3ea26d
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
451da04cbd | ||
|
|
bde1f7acfa | ||
|
|
2734efa1a9 | ||
|
|
340dd5f700 | ||
|
|
46d1e78758 | ||
|
|
51138e257f | ||
|
|
6eacde3eaa | ||
|
|
e745be8d07 | ||
|
|
f06ef5e2cb | ||
|
|
398d3b4dd9 | ||
|
|
1945e91441 | ||
|
|
107eade2fa | ||
|
|
f2123ba79c | ||
|
|
9213d3f91b | ||
|
|
211056ac8c | ||
|
|
b5afe35df8 | ||
|
|
2c3015685c | ||
|
|
2eebd3d2d5 | ||
|
|
1a9daa8ce1 | ||
|
|
6956919414 | ||
|
|
15159d7fd5 | ||
|
|
e373aa8a1f | ||
|
|
1bf5f46924 | ||
|
|
5a3f633ab2 | ||
|
|
7df21d62fa | ||
|
|
cf5771a573 | ||
|
|
c66e670336 | ||
|
|
70b841c8d6 | ||
|
|
c0a1bcf162 | ||
|
|
77832c07b2 | ||
|
|
30f38d771f | ||
|
|
497c139727 | ||
|
|
25e838c54f | ||
|
|
35d9940d63 | ||
|
|
8dbb5b9d9c | ||
|
|
34f8b85d57 | ||
|
|
8f5e59a9f7 | ||
|
|
e87948a3ca | ||
|
|
2e25833a93 | ||
|
|
b5fee40406 | ||
|
|
66997be86b |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Screenshots from debug-ipc screenshot.take method (written to <cwd>/screenshots/)
|
||||||
|
/screenshots/
|
||||||
145
README.md
145
README.md
@@ -1,10 +1,16 @@
|
|||||||
# sporel-module-map-editor
|
# sporel-module-map-editor
|
||||||
|
|
||||||
Interactive map editor for v2 Sporel map files. Click-to-paint, vertical layer-chip overlay on the left edge, collapsible atlas+tile picker bottom-left.
|
Interactive map editor for Sporel `.map.json` files. v0.2 series ships
|
||||||
|
the Rev 4 UX architecture (see design paper
|
||||||
|
`design/2026-05-28-autotile-blob-styles-design.md` §11 in `sporel-meta`):
|
||||||
|
top toolbar with mode-switch + actions, right Layers side-panel, bottom
|
||||||
|
palette strip of material slot thumbnails, mouse drag-paint, and two
|
||||||
|
mutually-exclusive paint modes (Auto-Tile vs Direct).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Default — edit the module's in-place work map:
|
Default — edit the module's in-place work map (`maps/work.map.json`,
|
||||||
|
a 16×16 schema-v3 map bound to the `blob_rect_stone` material):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/run-map-editor.sh
|
bash scripts/run-map-editor.sh
|
||||||
@@ -16,31 +22,134 @@ With a target — copy a real map in, edit, copy back out:
|
|||||||
bash scripts/run-map-editor.sh ~/Projects/Sporel/sporel-modules/spine-prototype/maps/demo.map.json
|
bash scripts/run-map-editor.sh ~/Projects/Sporel/sporel-modules/spine-prototype/maps/demo.map.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or via the install layer (per `install-reference.sh`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doubleclick C:/Games/Sporel/bin/Sporel.exe → launcher → map-editor
|
||||||
|
```
|
||||||
|
|
||||||
## UI
|
## UI
|
||||||
|
|
||||||
- **Layer chips** (left edge, vertical): top-of-stack down. `Rf` at top toggles roof-edit mode; the other eight chips select the active layer.
|
- **Top toolbar** (full-width): `Auto-Tile`/`Direct` mode buttons (mutually
|
||||||
- **Left-click** = set active layer (or toggle roof mode)
|
exclusive, Tab toggles), `Save` (S), `Erase` (E), `Rotate` (R),
|
||||||
- **Right-click** = toggle layer visibility (Rf chip ignores right-click)
|
`Flip` (H), `Reset` (0), `Help` (I). Each button shows its hotkey
|
||||||
- **Atlas + tile picker** (bottom-left): collapsed chip shows the active tile. Click to expand; click again or outside to collapse.
|
label inline. The active mode is highlighted.
|
||||||
- **Status chip** (bottom-right): current mouse-cell coordinate plus a `*` when the map has unsaved changes.
|
- **Right Layers side-panel** (8 rows top-down: `canopy`, `upper_wall`,
|
||||||
|
`wall`, `lower_wall`, `topsurface`, `surface`, `subsurface`,
|
||||||
|
`foundation`). Per row: visibility eye + active marker + layer name.
|
||||||
|
- **Left-click name** → set active layer
|
||||||
|
- **Left-click eye** → toggle layer visibility
|
||||||
|
- **Right-click row** → also toggles visibility
|
||||||
|
- **Bottom palette strip**: 14 swatches (slots 1-14). Real tile
|
||||||
|
thumbnails rendered from the active atlas via
|
||||||
|
`maps.atlas_tile_uv`. Active slot has a gold border.
|
||||||
|
- **Left-click** → set active slot
|
||||||
|
- **Status chip** (bottom-left of palette band): mouse-cell, dirty flag
|
||||||
|
(`*` when unsaved), and current transform indicator
|
||||||
|
(`slot=N rot=N flip=N`) for Direct-mode awareness.
|
||||||
|
- **Auto-Tile-mode world overlay**: cell grid (1 px), vertex dots (4 px
|
||||||
|
squares), and a snapped-vertex highlight ring (green) at the nearest
|
||||||
|
vertex to the cursor.
|
||||||
|
- **Cheatsheet modal** (I): centered dim-overlay listing all hotkeys
|
||||||
|
+ LMB/RMB drag semantics.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
| Mode | LMB on canvas | RMB on canvas | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Auto-Tile** | Paints vertex at snap target — `maps.set_vertex(layer, vx, vy, true)`. Any-corner rule flips up to 4 surrounding cells to material; renderer derives slot/rot/flip from neighbour bitmask. | Clears vertex — `set_vertex(..., false)` | Transform controls ignored (slot is derived from neighbours). |
|
||||||
|
| **Direct** | Writes per-cell override — `maps.set_override(layer, x, y, {slot, rot, flip})`. Bare slot int when canonical (rot=0+flip=0). | Clears override — `maps.clear_override(layer, x, y)` | Honours active Rot/Flip from toolbar. Use to force-place a specific orientation that the auto-derived slot wouldn't pick. |
|
||||||
|
|
||||||
|
Both modes support **drag-paint**: hold LMB (or RMB) and stroke across
|
||||||
|
the canvas to apply the action at every hovered vertex/cell. UI hits
|
||||||
|
on the toolbar / side-panel / palette never enter drag-mode.
|
||||||
|
|
||||||
## Hotkeys
|
## Hotkeys
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Left click` on map | Paint cell with active tile (or set roof flag in roof-mode) |
|
| `Tab` | Toggle mode (Auto-Tile ↔ Direct) |
|
||||||
| `E` | Erase the cell under the mouse |
|
| `I` | Show / hide cheatsheet |
|
||||||
| `R` | Cycle rotation 0 → 1 → 2 → 3 → 0 |
|
| `S` | Save current map to `work.map.json` |
|
||||||
| `S` | Save to disk (overwrites the work map) |
|
| `E` | Erase tile at mouse cell |
|
||||||
|
| `R` | Cycle rotation 0 → 1 → 2 → 3 → 0 (Direct-mode override) |
|
||||||
|
| `H` | Toggle flip 0 ↔ 1 (Direct-mode override) |
|
||||||
|
| `0` | Reset transform (rot=0, flip=0) |
|
||||||
| `Esc` | Quit |
|
| `Esc` | Quit |
|
||||||
|
| `LMB-drag` | Stroke-paint (mode-dependent) |
|
||||||
|
| `RMB-drag` | Stroke-clear |
|
||||||
|
|
||||||
## Limitations (v0.1.0)
|
## Headless / smoke
|
||||||
|
|
||||||
- Tile thumbnails in the picker render as gray placeholders with tile-IDs as text — lib-core.maps v0.4.0 does not expose per-tile UVs to Lua, so visual thumbnails are a v0.2.0 task.
|
`ci_frames = 30` in `manifest.module`. The editor reads
|
||||||
- Atlas list in the picker is purely informational; if a map declares multiple atlases, the active atlas is set from `state.lua` defaults at startup and cannot yet be switched via UI (v0.2.0 hit-tests inside the expanded picker).
|
`SPOREL_CI=1` and auto-exits after 30 frames so the smoke harness
|
||||||
- Engine bindings `engine.render.draw_rect_lines` and `engine.render.draw_line` are not yet registered — active-chip borders, hidden-layer strikethroughs, and the entity-slot divider will trigger runtime errors when their code paths execute. Adding the bindings is a small engine-side follow-up.
|
can run it without a window-close click.
|
||||||
- No undo/redo, no multi-tile brush, no entity placement, no heightmap editing — see the design spec §1 for the full out-of-scope list.
|
|
||||||
|
|
||||||
## Spec
|
```bash
|
||||||
|
SPOREL_CI=1 ./Sporel.exe --module=map-editor # exits with rc=0 after ~0.5 s
|
||||||
|
```
|
||||||
|
|
||||||
`docs/superpowers/specs/2026-05-23-sporel-module-map-editor-design.md` in the `sporel-meta` repo.
|
## Limitations (current scope)
|
||||||
|
|
||||||
|
- **Single-atlas palette**: the palette draws from `atlas_idx=0` only.
|
||||||
|
Multi-atlas selector is a future slice. Workaround: keep one material
|
||||||
|
atlas per map for now.
|
||||||
|
- **Single Flip flag**: design paper mentions Flip-H + Flip-V as
|
||||||
|
separate controls, but the override format only stores one `flip`
|
||||||
|
field; combined with `rot` (0-3) the single flip covers all 8 D4
|
||||||
|
orientations, so the second button would be redundant.
|
||||||
|
- **No modifier hotkeys**: `lib-core.input` doesn't expose modifier
|
||||||
|
combos, so Shift+R for CCW rotation isn't supported. Workaround:
|
||||||
|
press R three times.
|
||||||
|
- **No Decal / Entity sub-selectors**: Direct mode on non-terrain
|
||||||
|
layers is unimplemented (lands in `0.2.0d`).
|
||||||
|
- **No Material-Properties modal**: `base_color` re-bake trigger
|
||||||
|
+ tint editor are deferred (also `0.2.0d`).
|
||||||
|
- **No multi-vertex brush sizes / procedural fill / undo-redo** — see
|
||||||
|
design paper §11 "Out of scope" for the full list.
|
||||||
|
|
||||||
|
## Plan + spec
|
||||||
|
|
||||||
|
- Design paper: `docs/design/2026-05-28-autotile-blob-styles-design.md`
|
||||||
|
§11 in `sporel-meta`
|
||||||
|
- Plan: `docs/superpowers/plans/2026-05-28-map-editor-blob-v2.md`
|
||||||
|
|
||||||
|
## Menu UI (v0.3.0)
|
||||||
|
|
||||||
|
Top toolbar replaced with a seven-category menu bar (File, Edit, View,
|
||||||
|
Map, Tools, Window, Help) plus a sticky two-segment Mode-pill on the
|
||||||
|
right end. Item types: action, modal, toggle, submenu, separator;
|
||||||
|
items can be conditionally disabled. A single-stack modal framework
|
||||||
|
hosts the existing Cheatsheet (I) and three new modals: Save As, Map
|
||||||
|
Properties (read-only), About. The Esc key cascades modal -> menu ->
|
||||||
|
quit.
|
||||||
|
|
||||||
|
New Map and Open Map appear in the File menu as disabled placeholders
|
||||||
|
pending a v3-aware maps.create and an engine.asset.list_dir in a
|
||||||
|
future engine + lib slice.
|
||||||
|
|
||||||
|
All previous hotkeys (Tab, S, E, R, H, 0, D, G, I, Esc) continue to
|
||||||
|
work; their bindings are documented in the items where applicable
|
||||||
|
and in the in-editor Cheatsheet modal.
|
||||||
|
|
||||||
|
## Cell-Tile Mode (v0.3.1)
|
||||||
|
|
||||||
|
Third painting mechanic alongside Auto-Tile and Direct. LMB paints a
|
||||||
|
per-cell material flag at the cursor cell; RMB clears it. The
|
||||||
|
renderer uses the classical 47-blob 8-neighbour bitmask for
|
||||||
|
cell-tile cells, unlocking the 9 atlas slots that Auto-Tile's
|
||||||
|
dual-grid 4-corner rule structurally cannot reach (isolated, end,
|
||||||
|
corner_open, straight, tee_open, tee_half, cross_open, cross_q1,
|
||||||
|
cross_q2adj).
|
||||||
|
|
||||||
|
Auto-Tile (vertex-paint, 5 reachable slots, dual-grid look) and
|
||||||
|
Direct (manual override placement, all 14 slots via explicit pick)
|
||||||
|
are unchanged. Tab cycles through all three modes.
|
||||||
|
|
||||||
|
For clean visuals, use a single painting mode per layer. Mixing
|
||||||
|
Vertex-Tile and Cell-Tile material in the same layer is allowed but
|
||||||
|
produces an asymmetric seam at the boundary because vertex-tile
|
||||||
|
cells are structurally blind to their cell-tile neighbours.
|
||||||
|
|
||||||
|
See `sporel-meta/docs/superpowers/specs/2026-06-01-map-editor-cell-tile-mode-design.md`
|
||||||
|
for the full design rationale and the Boris-taxonomy background.
|
||||||
|
|||||||
17
manifest.launcher.md
Normal file
17
manifest.launcher.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
{"summary":"Map editor — vertical layer-chip overlay + atlas-tile picker","author":"calic","tags":["tool","map-editor"],"teaser_images":[]}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Map Editor
|
||||||
|
|
||||||
|
Click-to-paint tile editor with collapsible atlas/tile-picker. Vertical layer-chip overlay (left), atlas+tile picker (bottom-left), status chip (bottom-right).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- Mouse-left on grid: paint
|
||||||
|
- Mouse-left on layer-chip: switch active layer
|
||||||
|
- Mouse-right on layer-chip: toggle visibility
|
||||||
|
- E: erase mode
|
||||||
|
- R: rotate
|
||||||
|
- S: save
|
||||||
|
- ESC: quit to launcher
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"id": "sporel-module-map-editor",
|
"id": "map-editor",
|
||||||
"version": "0.1.0",
|
"version": "0.3.3",
|
||||||
"kind": "module",
|
"kind": "module",
|
||||||
"api": "^0.1",
|
"api": "^0.1",
|
||||||
"ci_frames": 30,
|
"ci_frames": 30,
|
||||||
"entry_point": "init.lua",
|
"entry_point": "init.lua",
|
||||||
"asset_aliases": {
|
"asset_aliases": {
|
||||||
"fa_terrain_v1": "lib-asset.prototype-fa-starter"
|
"blob_rect_stone": "lib-asset.prototype-blob-geom"
|
||||||
},
|
},
|
||||||
"deps": [
|
"deps": [
|
||||||
{"id":"lib-core.maps","version":"0.4.0"},
|
{"id":"lib-core.maps","version":"0.5.7"},
|
||||||
{"id":"lib-core.render","version":"0.1.0"},
|
{"id":"lib-core.render","version":"0.2.0"},
|
||||||
|
{"id":"lib-core.composition","version":"0.1.0"},
|
||||||
{"id":"lib-core.camera","version":"0.3.0"},
|
{"id":"lib-core.camera","version":"0.3.0"},
|
||||||
{"id":"lib-core.input","version":"0.4.0"},
|
{"id":"lib-core.input","version":"0.4.0"},
|
||||||
{"id":"lib-asset.prototype-fa-starter","version":"0.2.0"}
|
{"id":"lib-asset.prototype-blob-geom","version":"0.1.0"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 2,
|
"schema_version": 3,
|
||||||
"id": "work",
|
"id": "work",
|
||||||
"size": { "w": 4, "h": 4 },
|
"size": { "w": 16, "h": 16 },
|
||||||
"atlases": ["fa_terrain_v1"],
|
"atlases": [ "blob_rect_stone" ],
|
||||||
"layers": {
|
"layers": {
|
||||||
"surface": {
|
"surface": { "material": "blob_rect_stone" },
|
||||||
"tiles": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
"wall": { "material": "blob_rect_stone" }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
-- src/actions.lua
|
|
||||||
-- High-level edit operations. Combines state mutations with lib-core.maps writes.
|
|
||||||
|
|
||||||
local maps = require("lib-core.maps")
|
|
||||||
local state = require("src.state")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Cell painting
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
function M.paint_cell_at(cell_x, cell_y)
|
|
||||||
if state.is_roof_mode() then
|
|
||||||
maps.set_roof(cell_x, cell_y, 1)
|
|
||||||
else
|
|
||||||
local gid = maps.encode_gid(
|
|
||||||
state.get_active_atlas(),
|
|
||||||
state.get_active_tile(),
|
|
||||||
state.get_active_rot()
|
|
||||||
)
|
|
||||||
maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, gid)
|
|
||||||
end
|
|
||||||
state.mark_dirty()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.erase_cell_at(cell_x, cell_y)
|
|
||||||
if state.is_roof_mode() then
|
|
||||||
maps.set_roof(cell_x, cell_y, 0)
|
|
||||||
else
|
|
||||||
maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, 0)
|
|
||||||
end
|
|
||||||
state.mark_dirty()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Hotkey-driven actions
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
function M.cycle_rotation()
|
|
||||||
state.cycle_rotation()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.save()
|
|
||||||
local id = state.get_map_id()
|
|
||||||
local path = state.get_map_path()
|
|
||||||
if not id or not path then
|
|
||||||
engine.print("map-editor: cannot save — no map loaded")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
maps.save_to_disk(id, path)
|
|
||||||
state.mark_clean()
|
|
||||||
engine.print(string.format("map-editor: saved '%s' to %s", id, path))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- UI-driven actions (called by ui.lua hit-tests)
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
function M.set_active_layer(layer_name)
|
|
||||||
state.set_active_layer(layer_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.toggle_layer_visible(layer_name)
|
|
||||||
state.toggle_layer_visible(layer_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.toggle_roof_mode()
|
|
||||||
state.toggle_roof_mode()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.set_active_atlas(atlas_idx)
|
|
||||||
state.set_active_atlas(atlas_idx)
|
|
||||||
-- Reset to first tile of the new atlas
|
|
||||||
state.set_active_tile(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.set_active_tile(tile_id)
|
|
||||||
state.set_active_tile(tile_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.toggle_picker()
|
|
||||||
state.toggle_picker_open()
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
-- src/state.lua
|
|
||||||
-- Editor state singleton + accessors.
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- State (module-singleton)
|
|
||||||
-- =====================================================================
|
|
||||||
local state = {
|
|
||||||
map_id = nil, -- set by init.lua after maps.load()
|
|
||||||
map_path = "maps/work.map.json",
|
|
||||||
active_layer = "surface", -- one of VALID_LAYER_NAMES
|
|
||||||
active_atlas = 0, -- atlas_index 0..N-1
|
|
||||||
active_tile = 1, -- tile_id
|
|
||||||
active_rot = 0, -- 0..3
|
|
||||||
roof_mode = false, -- if true, paint targets roof
|
|
||||||
layer_visible = {
|
|
||||||
foundation=true, subsurface=true, surface=true, topsurface=true,
|
|
||||||
lower_wall=true, wall=true, upper_wall=true, canopy=true,
|
|
||||||
},
|
|
||||||
dirty = false,
|
|
||||||
picker_open = false, -- atlas+tile picker expanded?
|
|
||||||
mouse_cell = nil, -- {x, y} or nil if mouse off-map
|
|
||||||
}
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Setters / getters (encapsulate state for testability)
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
function M.set_map(map_id, path)
|
|
||||||
state.map_id = map_id
|
|
||||||
state.map_path = path
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.get_map_id() return state.map_id end
|
|
||||||
function M.get_map_path() return state.map_path end
|
|
||||||
|
|
||||||
function M.set_active_layer(name) state.active_layer = name end
|
|
||||||
function M.get_active_layer() return state.active_layer end
|
|
||||||
|
|
||||||
function M.set_active_atlas(idx) state.active_atlas = idx end
|
|
||||||
function M.get_active_atlas() return state.active_atlas end
|
|
||||||
|
|
||||||
function M.set_active_tile(id) state.active_tile = id end
|
|
||||||
function M.get_active_tile() return state.active_tile end
|
|
||||||
|
|
||||||
function M.cycle_rotation()
|
|
||||||
state.active_rot = (state.active_rot + 1) % 4
|
|
||||||
end
|
|
||||||
function M.get_active_rot() return state.active_rot end
|
|
||||||
|
|
||||||
function M.toggle_roof_mode()
|
|
||||||
state.roof_mode = not state.roof_mode
|
|
||||||
end
|
|
||||||
function M.is_roof_mode() return state.roof_mode end
|
|
||||||
|
|
||||||
function M.toggle_layer_visible(name)
|
|
||||||
state.layer_visible[name] = not state.layer_visible[name]
|
|
||||||
end
|
|
||||||
function M.is_layer_visible(name) return state.layer_visible[name] ~= false end
|
|
||||||
|
|
||||||
function M.mark_dirty() state.dirty = true end
|
|
||||||
function M.mark_clean() state.dirty = false end
|
|
||||||
function M.is_dirty() return state.dirty end
|
|
||||||
|
|
||||||
function M.toggle_picker_open()
|
|
||||||
state.picker_open = not state.picker_open
|
|
||||||
end
|
|
||||||
function M.is_picker_open() return state.picker_open end
|
|
||||||
|
|
||||||
function M.set_mouse_cell(x, y)
|
|
||||||
if x == nil then
|
|
||||||
state.mouse_cell = nil
|
|
||||||
else
|
|
||||||
state.mouse_cell = { x = x, y = y }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
function M.get_mouse_cell() return state.mouse_cell end
|
|
||||||
|
|
||||||
-- Used by tests to reset between asserts
|
|
||||||
function M.reset()
|
|
||||||
state.map_id = nil
|
|
||||||
state.active_layer = "surface"
|
|
||||||
state.active_atlas = 0
|
|
||||||
state.active_tile = 1
|
|
||||||
state.active_rot = 0
|
|
||||||
state.roof_mode = false
|
|
||||||
state.dirty = false
|
|
||||||
state.picker_open = false
|
|
||||||
state.mouse_cell = nil
|
|
||||||
for name in pairs(state.layer_visible) do
|
|
||||||
state.layer_visible[name] = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
324
src/ui.lua
324
src/ui.lua
@@ -1,324 +0,0 @@
|
|||||||
-- src/ui.lua
|
|
||||||
-- Immediate-mode overlay UI: layer-chips, atlas+tile picker, status-chip.
|
|
||||||
-- Renders + hit-tests per frame. No retained widget state — read straight
|
|
||||||
-- from state.lua on every render and dispatch via actions.lua.
|
|
||||||
|
|
||||||
local maps = require("lib-core.maps")
|
|
||||||
local state = require("src.state")
|
|
||||||
local actions = require("src.actions")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Constants
|
|
||||||
-- =====================================================================
|
|
||||||
local CHIP_SIZE = 32 -- layer chip W/H in pixels
|
|
||||||
local CHIP_MARGIN = 4 -- gap between chips
|
|
||||||
local CHIP_ALPHA_ACTIVE = 0xFF
|
|
||||||
local CHIP_ALPHA_INACTIVE = 0x78 -- 120/255
|
|
||||||
local CHIP_ALPHA_HOVER = 0xC8 -- 200/255
|
|
||||||
|
|
||||||
local PICKER_COLLAPSED_W = 80
|
|
||||||
local PICKER_COLLAPSED_H = 80
|
|
||||||
local PICKER_EXPANDED_W = 320
|
|
||||||
local PICKER_EXPANDED_H = 240
|
|
||||||
local PICKER_MARGIN = 8
|
|
||||||
local TILE_THUMB_SIZE = 48
|
|
||||||
|
|
||||||
local STATUS_W = 80
|
|
||||||
local STATUS_H = 24
|
|
||||||
local STATUS_MARGIN = 8
|
|
||||||
|
|
||||||
-- Layer order (top of screen = top of z-stack). Roof above with divider.
|
|
||||||
local LAYER_CHIPS = {
|
|
||||||
{ name="roof_toggle", label="Rf", is_roof=true },
|
|
||||||
{ name="canopy", label="Ca" },
|
|
||||||
{ name="upper_wall", label="Uw" },
|
|
||||||
{ name="wall", label="Wa" },
|
|
||||||
{ name="lower_wall", label="Lw" },
|
|
||||||
-- entity-slot divider rendered between Lw and Ts
|
|
||||||
{ name="topsurface", label="Ts" },
|
|
||||||
{ name="surface", label="Sf" },
|
|
||||||
{ name="subsurface", label="Sb" },
|
|
||||||
{ name="foundation", label="Fo" },
|
|
||||||
}
|
|
||||||
|
|
||||||
local COLOR_CHIP_BG = 0x202020 -- RGB (alpha added at draw)
|
|
||||||
local COLOR_CHIP_BG_ACT = 0xE0E0E0
|
|
||||||
local COLOR_CHIP_TEXT = 0xFFFFFF
|
|
||||||
local COLOR_CHIP_TEXT_ACT = 0x202020
|
|
||||||
local COLOR_CHIP_BORDER = 0xFFFFFF
|
|
||||||
local COLOR_DIVIDER = 0xFFFFFF
|
|
||||||
local COLOR_STRIKE = 0xFF4040 -- hidden-layer overlay line
|
|
||||||
local COLOR_PICKER_BG = 0x303030
|
|
||||||
local COLOR_STATUS_BG = 0x303030
|
|
||||||
local COLOR_STATUS_TEXT = 0xFFFFFF
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Helpers
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
local function rgba(rgb, alpha)
|
|
||||||
return (rgb << 8) | (alpha & 0xFF)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function in_rect(mx, my, x, y, w, h)
|
|
||||||
return mx >= x and mx < x + w and my >= y and my < y + h
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Layer-chip layout: vertical stack on the left edge, starting at y_top.
|
|
||||||
-- Returns: array of { name, label, is_roof, x, y, w, h, is_divider_after? }
|
|
||||||
local function chip_layout()
|
|
||||||
local out = {}
|
|
||||||
local x = CHIP_MARGIN
|
|
||||||
local y = CHIP_MARGIN
|
|
||||||
for i, chip in ipairs(LAYER_CHIPS) do
|
|
||||||
local entry = {
|
|
||||||
name=chip.name, label=chip.label, is_roof=chip.is_roof,
|
|
||||||
x=x, y=y, w=CHIP_SIZE, h=CHIP_SIZE,
|
|
||||||
}
|
|
||||||
-- Roof-Toggle gets a thicker gap below it
|
|
||||||
if chip.is_roof then
|
|
||||||
entry.gap_after = CHIP_SIZE / 2
|
|
||||||
elseif chip.name == "lower_wall" then
|
|
||||||
entry.gap_after = CHIP_SIZE / 2 -- entity-slot divider
|
|
||||||
entry.divider_after = true
|
|
||||||
else
|
|
||||||
entry.gap_after = CHIP_MARGIN
|
|
||||||
end
|
|
||||||
out[i] = entry
|
|
||||||
y = y + entry.h + entry.gap_after
|
|
||||||
end
|
|
||||||
return out
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right.
|
|
||||||
local function picker_layout()
|
|
||||||
local screen_w, screen_h = engine.window.size()
|
|
||||||
if state.is_picker_open() then
|
|
||||||
return {
|
|
||||||
x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, -- right of layer-chips
|
|
||||||
y = screen_h - PICKER_EXPANDED_H - PICKER_MARGIN,
|
|
||||||
w = PICKER_EXPANDED_W,
|
|
||||||
h = PICKER_EXPANDED_H,
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return {
|
|
||||||
x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN,
|
|
||||||
y = screen_h - PICKER_COLLAPSED_H - PICKER_MARGIN,
|
|
||||||
w = PICKER_COLLAPSED_W,
|
|
||||||
h = PICKER_COLLAPSED_H,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function status_layout()
|
|
||||||
local screen_w, screen_h = engine.window.size()
|
|
||||||
return {
|
|
||||||
x = screen_w - STATUS_W - STATUS_MARGIN,
|
|
||||||
y = screen_h - STATUS_H - STATUS_MARGIN,
|
|
||||||
w = STATUS_W,
|
|
||||||
h = STATUS_H,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Drawing
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
local function draw_chip(chip, mx, my)
|
|
||||||
-- State decisions
|
|
||||||
local is_active
|
|
||||||
if chip.is_roof then
|
|
||||||
is_active = state.is_roof_mode()
|
|
||||||
else
|
|
||||||
is_active = (state.get_active_layer() == chip.name)
|
|
||||||
end
|
|
||||||
local is_hover = in_rect(mx, my, chip.x, chip.y, chip.w, chip.h)
|
|
||||||
local alpha
|
|
||||||
if is_active then
|
|
||||||
alpha = CHIP_ALPHA_ACTIVE
|
|
||||||
elseif is_hover then
|
|
||||||
alpha = CHIP_ALPHA_HOVER
|
|
||||||
else
|
|
||||||
alpha = CHIP_ALPHA_INACTIVE
|
|
||||||
end
|
|
||||||
local bg_color = is_active and rgba(COLOR_CHIP_BG_ACT, alpha)
|
|
||||||
or rgba(COLOR_CHIP_BG, alpha)
|
|
||||||
local text_color = is_active and rgba(COLOR_CHIP_TEXT_ACT, 0xFF)
|
|
||||||
or rgba(COLOR_CHIP_TEXT, 0xFF)
|
|
||||||
-- Background
|
|
||||||
engine.render.draw_rect(chip.x, chip.y, chip.w, chip.h, bg_color)
|
|
||||||
-- Border (active only)
|
|
||||||
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
|
|
||||||
if is_active then
|
|
||||||
engine.render.draw_rect_lines(chip.x, chip.y, chip.w, chip.h, rgba(COLOR_CHIP_BORDER, 0xFF))
|
|
||||||
end
|
|
||||||
-- Label text — centered (approximate using fixed-width assumption)
|
|
||||||
local text_x = chip.x + (chip.w / 2) - 8
|
|
||||||
local text_y = chip.y + (chip.h / 2) - 8
|
|
||||||
engine.render.draw_text(chip.label, text_x, text_y, 14, text_color)
|
|
||||||
-- Hidden overlay (strikethrough for invisible layers)
|
|
||||||
-- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task)
|
|
||||||
if not chip.is_roof and not state.is_layer_visible(chip.name) then
|
|
||||||
engine.render.draw_line(chip.x, chip.y, chip.x + chip.w, chip.y + chip.h,
|
|
||||||
rgba(COLOR_STRIKE, 0xFF), 2)
|
|
||||||
end
|
|
||||||
-- Entity-slot divider below lower_wall
|
|
||||||
if chip.divider_after then
|
|
||||||
local dy = chip.y + chip.h + (chip.gap_after / 2)
|
|
||||||
-- Dashed: 4 short segments
|
|
||||||
-- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task)
|
|
||||||
for seg = 0, 3 do
|
|
||||||
local sx = chip.x + seg * 9
|
|
||||||
engine.render.draw_line(sx, dy, sx + 6, dy, rgba(COLOR_DIVIDER, 0xFF), 1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function draw_layer_chips(mx, my)
|
|
||||||
for _, chip in ipairs(chip_layout()) do
|
|
||||||
draw_chip(chip, mx, my)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Placeholder tile thumbnail (v0.1.0 limitation: lib-core.maps doesn't
|
|
||||||
-- expose per-tile UVs to Lua; v0.2.0 task).
|
|
||||||
local function draw_tile_thumb(atlas_idx, tile_id, x, y, size)
|
|
||||||
engine.render.draw_rect(x, y, size, size, rgba(0x606060, 0xFF))
|
|
||||||
engine.render.draw_text("t" .. tile_id, x + 4, y + size/2 - 6, 10, rgba(0xFFFFFF, 0xFF))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Returns a list of tile IDs for the given atlas. Placeholder for v0.1.0:
|
|
||||||
-- lib-core.maps does not yet expose atlas.tile_ids() — return just the
|
|
||||||
-- currently-active tile so the expanded picker has something to render.
|
|
||||||
local function atlas_tile_list(atlas_idx)
|
|
||||||
return { state.get_active_tile() }
|
|
||||||
end
|
|
||||||
|
|
||||||
local function draw_picker(mx, my)
|
|
||||||
local p = picker_layout()
|
|
||||||
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COLOR_PICKER_BG, 0xE0))
|
|
||||||
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
|
|
||||||
engine.render.draw_rect_lines(p.x, p.y, p.w, p.h, rgba(0xFFFFFF, 0xFF))
|
|
||||||
|
|
||||||
if not state.is_picker_open() then
|
|
||||||
-- Collapsed view: thumbnail of active tile + truncated labels
|
|
||||||
local atlas_idx = state.get_active_atlas()
|
|
||||||
local tile_id = state.get_active_tile()
|
|
||||||
local atlas_id = maps.atlas_id_at(atlas_idx) or "?"
|
|
||||||
-- Active tile thumbnail (top of chip)
|
|
||||||
draw_tile_thumb(atlas_idx, tile_id,
|
|
||||||
p.x + 16, p.y + 8, TILE_THUMB_SIZE)
|
|
||||||
-- Labels (bottom)
|
|
||||||
local atlas_label = atlas_id:sub(1, 8) .. (atlas_id:len() > 8 and "…" or "")
|
|
||||||
engine.render.draw_text(atlas_label, p.x + 4, p.y + 60, 10, rgba(0xCCCCCC, 0xFF))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Expanded view: atlas row at top, tile grid below
|
|
||||||
engine.render.draw_text("Atlas:", p.x + 8, p.y + 4, 11, rgba(0xFFFFFF, 0xFF))
|
|
||||||
local atlas_count = maps.atlas_count()
|
|
||||||
local atlas_x = p.x + 8
|
|
||||||
for i = 0, atlas_count - 1 do
|
|
||||||
local label = maps.atlas_id_at(i) or "?"
|
|
||||||
local short = label:sub(1, 6)
|
|
||||||
local color = (i == state.get_active_atlas())
|
|
||||||
and rgba(0xFFFFFF, 0xFF) or rgba(0x808080, 0xFF)
|
|
||||||
engine.render.draw_text("["..short.."]", atlas_x, p.y + 20, 11, color)
|
|
||||||
atlas_x = atlas_x + 60 -- crude spacing
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Tile grid (below the atlas row)
|
|
||||||
engine.render.draw_text("Tiles:", p.x + 8, p.y + 40, 11, rgba(0xFFFFFF, 0xFF))
|
|
||||||
local active_atlas = state.get_active_atlas()
|
|
||||||
local tiles = atlas_tile_list(active_atlas)
|
|
||||||
local grid_x = p.x + 8
|
|
||||||
local grid_y = p.y + 60
|
|
||||||
for i, tid in ipairs(tiles) do
|
|
||||||
local col = (i - 1) % 5
|
|
||||||
local row = math.floor((i - 1) / 5)
|
|
||||||
local tx = grid_x + col * (TILE_THUMB_SIZE + 4)
|
|
||||||
local ty = grid_y + row * (TILE_THUMB_SIZE + 4)
|
|
||||||
draw_tile_thumb(active_atlas, tid, tx, ty, TILE_THUMB_SIZE)
|
|
||||||
if tid == state.get_active_tile() then
|
|
||||||
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
|
|
||||||
engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE,
|
|
||||||
rgba(0xFFFF00, 0xFF))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function draw_status(mx, my)
|
|
||||||
local s = status_layout()
|
|
||||||
engine.render.draw_rect(s.x, s.y, s.w, s.h, rgba(COLOR_STATUS_BG, 0xC0))
|
|
||||||
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
|
|
||||||
engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 0xFF))
|
|
||||||
local cell = state.get_mouse_cell()
|
|
||||||
local cell_str
|
|
||||||
if cell then
|
|
||||||
cell_str = string.format("(%d,%d)", cell.x, cell.y)
|
|
||||||
else
|
|
||||||
cell_str = "(-,-)"
|
|
||||||
end
|
|
||||||
local dirty_str = state.is_dirty() and "*" or " "
|
|
||||||
engine.render.draw_text(cell_str .. " " .. dirty_str, s.x + 4, s.y + 4, 12,
|
|
||||||
rgba(COLOR_STATUS_TEXT, 0xFF))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- =====================================================================
|
|
||||||
-- Public API
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
function M.draw()
|
|
||||||
local mx, my = engine.input.get_mouse_pos()
|
|
||||||
draw_layer_chips(mx, my)
|
|
||||||
draw_picker(mx, my)
|
|
||||||
draw_status(mx, my)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Hit-test mouse click. Returns true if the click was consumed by UI.
|
|
||||||
-- button: "left" | "right"
|
|
||||||
function M.handle_click(mx, my, button)
|
|
||||||
local p = picker_layout()
|
|
||||||
-- Picker: check expanded panel first (largest area)
|
|
||||||
if in_rect(mx, my, p.x, p.y, p.w, p.h) then
|
|
||||||
if state.is_picker_open() then
|
|
||||||
-- TODO: hit-test individual tile/atlas slots inside the panel
|
|
||||||
-- For v0.1.0, any click inside expanded picker closes it.
|
|
||||||
actions.toggle_picker()
|
|
||||||
return true
|
|
||||||
else
|
|
||||||
actions.toggle_picker()
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Layer chips
|
|
||||||
for _, chip in ipairs(chip_layout()) do
|
|
||||||
if in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) then
|
|
||||||
if button == "left" then
|
|
||||||
if chip.is_roof then
|
|
||||||
actions.toggle_roof_mode()
|
|
||||||
else
|
|
||||||
actions.set_active_layer(chip.name)
|
|
||||||
end
|
|
||||||
elseif button == "right" then
|
|
||||||
if not chip.is_roof then
|
|
||||||
actions.toggle_layer_visible(chip.name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Status chip — no-op fallback
|
|
||||||
local s = status_layout()
|
|
||||||
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
|
|
||||||
return true -- consumed but no action
|
|
||||||
end
|
|
||||||
|
|
||||||
return false -- click falls through to map-area handler in init.lua
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
Reference in New Issue
Block a user