feat(map-editor): v0.2.0c — transform controls + Direct-mode override paint

State + actions:
 - active_flip (0/1) joins active_rot (0..3). Combined the two cover
   all 8 D4 orientations; single Flip button is sufficient (H+V
   would be redundant).
 - toggle_flip / reset_transform / paint_override_at /
   erase_override_at.

Toolbar:
 - New Flip (H) + Reset (0) buttons next to Rotate (R).
 - Status chip extended with `slot=N rot=N flip=N` indicator.

Paint:
 - Direct-mode + LMB on canvas now calls maps.set_override on the
   active material-bound terrain layer. Bare slot int when canonical
   (rot=0+flip=0), {slot, rot, flip} object otherwise — matches the
   v0.5.1 override-format extension. Palette slot 1..14 maps to lib
   slot 0..13 (palette is user-friendly 1-based; lib API is 0-based).
 - Direct-mode + RMB on canvas calls maps.clear_override.
 - Auto-Tile mode unchanged; transform state is ignored there (the
   renderer derives slot/rot/flip from the vertex bitmask).

Hotkeys:
 - h -> flip (new)
 - 0 -> reset_transform (new)
 - r continues to cycle rotation CW; Shift+R for CCW is out because
   lib-core.input has no modifier-combo support (deferred).

Cheatsheet rows updated.
This commit is contained in:
calic
2026-05-29 00:46:08 +02:00
parent 497c139727
commit 30f38d771f
2 changed files with 79 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
-- sporel-module-map-editor v0.2.0b.1 -- sporel-module-map-editor v0.2.0c
-- Interactive map editor — Rev 4 UX architecture (see design paper -- Interactive map editor — Rev 4 UX architecture (see design paper
-- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub -- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub
-- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette -- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette
@@ -6,12 +6,13 @@
-- needs a lib-core.maps tile-UV getter) + Auto-Tile vertex painting -- needs a lib-core.maps tile-UV getter) + Auto-Tile vertex painting
-- via maps.set_vertex + a vertex-grid overlay. 0.2.0b.1 migrates -- via maps.set_vertex + a vertex-grid overlay. 0.2.0b.1 migrates
-- work.map.json to schema-v3 + binds surface/wall layers to the -- work.map.json to schema-v3 + binds surface/wall layers to the
-- blob_rect_stone material so Auto-Tile painting actually renders; -- blob_rect_stone material so Auto-Tile painting actually renders.
-- before that the legacy fa_terrain_v1 atlas was loaded which has no -- 0.2.0c adds Transform controls (Flip H, Reset) + Direct-mode
-- blob-14 slot layout, so paint had no visible effect. -- override paint that writes maps.set_override with the current
-- Direct-mode painting still uses the v0.1 cell-paint path. -- {slot, rot, flip} on a material-bound terrain layer. Single
-- Transform controls + Direct-Override + Decal/Entity sub-selectors -- `flip` flag (0/1) combined with rot (0..3) covers all 8 D4
-- land in 0.2.0c/d. -- orientations; H+V buttons are redundant so we ship just one.
-- Decal/Entity sub-selectors + Material-Properties modal land in 0.2.0d.
-- --
-- All editor logic in this single file: engine sandbox only allows -- All editor logic in this single file: engine sandbox only allows
-- require() for declared lib-deps; multi-file modules either use -- require() for declared lib-deps; multi-file modules either use
@@ -46,6 +47,7 @@ local state = (function()
active_atlas = 0, -- atlas_index 0..N-1 active_atlas = 0, -- atlas_index 0..N-1
active_tile = 1, -- tile_id active_tile = 1, -- tile_id
active_rot = 0, -- 0..3 active_rot = 0, -- 0..3
active_flip = 0, -- 0.2.0c: 0 or 1 (single flip; combined with rot covers all 8 D4 orientations)
mode = "auto-tile", -- 0.2.0a: "auto-tile" | "direct" mode = "auto-tile", -- 0.2.0a: "auto-tile" | "direct"
roof_mode = false, -- if true, paint targets roof roof_mode = false, -- if true, paint targets roof
layer_visible = { layer_visible = {
@@ -85,6 +87,14 @@ local state = (function()
end end
function M.get_active_rot() return state.active_rot end function M.get_active_rot() return state.active_rot end
-- 0.2.0c: transform state for Direct-mode override paint.
function M.toggle_flip() state.active_flip = (state.active_flip == 0) and 1 or 0 end
function M.get_active_flip() return state.active_flip end
function M.reset_transform()
state.active_rot = 0
state.active_flip = 0
end
function M.toggle_roof_mode() function M.toggle_roof_mode()
state.roof_mode = not state.roof_mode state.roof_mode = not state.roof_mode
end end
@@ -144,6 +154,7 @@ local state = (function()
state.active_atlas = 0 state.active_atlas = 0
state.active_tile = 1 state.active_tile = 1
state.active_rot = 0 state.active_rot = 0
state.active_flip = 0
state.mode = "auto-tile" state.mode = "auto-tile"
state.roof_mode = false state.roof_mode = false
state.dirty = false state.dirty = false
@@ -200,6 +211,27 @@ local actions = (function()
state.mark_dirty() state.mark_dirty()
end end
-- 0.2.0c: Direct-mode override paint on a material-bound terrain layer.
-- Converts palette tile (1..14) -> lib slot (0..13). Writes bare slot
-- in canonical orientation (rot=0+flip=0), else {slot, rot, flip} object.
function M.paint_override_at(cell_x, cell_y)
local slot = state.get_active_tile() - 1 -- palette 1..14 -> lib slot 0..13
local rot = state.get_active_rot()
local flip = state.get_active_flip()
if rot == 0 and flip == 0 then
maps.set_override(state.get_active_layer(), cell_x, cell_y, slot)
else
maps.set_override(state.get_active_layer(), cell_x, cell_y,
{ slot = slot, rot = rot, flip = flip })
end
state.mark_dirty()
end
function M.erase_override_at(cell_x, cell_y)
maps.clear_override(state.get_active_layer(), cell_x, cell_y)
state.mark_dirty()
end
-- ===================================================================== -- =====================================================================
-- Hotkey-driven actions -- Hotkey-driven actions
-- ===================================================================== -- =====================================================================
@@ -255,6 +287,10 @@ local actions = (function()
function M.set_mode(m) state.set_mode(m) end function M.set_mode(m) state.set_mode(m) end
function M.toggle_cheatsheet() state.toggle_cheatsheet() end function M.toggle_cheatsheet() state.toggle_cheatsheet() end
-- 0.2.0c actions
function M.toggle_flip() state.toggle_flip() end
function M.reset_transform() state.reset_transform() end
return M return M
end)() end)()
@@ -293,7 +329,7 @@ local ui = (function()
local PALETTE_SLOTS = 14 -- matches S-V2E2-RM-Blob baseline local PALETTE_SLOTS = 14 -- matches S-V2E2-RM-Blob baseline
-- Status chip (bottom-right, mouse cell + dirty) -- Status chip (bottom-right, mouse cell + dirty)
local STATUS_W = 200 local STATUS_W = 320
local STATUS_H = 22 local STATUS_H = 22
local STATUS_MARGIN = 8 local STATUS_MARGIN = 8
@@ -349,6 +385,8 @@ local ui = (function()
{ id="save", label="Save", hotkey="S" }, { id="save", label="Save", hotkey="S" },
{ id="erase", label="Erase", hotkey="E" }, { id="erase", label="Erase", hotkey="E" },
{ id="rotate", label="Rotate", hotkey="R" }, { id="rotate", label="Rotate", hotkey="R" },
{ id="flip", label="Flip", hotkey="H" },
{ id="reset_xform", label="Reset", hotkey="0" },
{ id="help", label="Help", hotkey="I" }, { id="help", label="Help", hotkey="I" },
} }
@@ -358,7 +396,9 @@ local ui = (function()
{ key="I", action="Show / hide this cheatsheet" }, { key="I", action="Show / hide this cheatsheet" },
{ key="S", action="Save current map to work.map.json" }, { key="S", action="Save current map to work.map.json" },
{ key="E", action="Erase tile at mouse cell" }, { key="E", action="Erase tile at mouse cell" },
{ key="R", action="Cycle tile rotation 0->90->180->270" }, { key="R", action="Cycle tile rotation 0->90->180->270 (Direct-mode override)" },
{ key="H", action="Toggle tile flip 0<->1 (Direct-mode override)" },
{ key="0", action="Reset transform (rot=0, flip=0)" },
{ key="Esc", action="Quit editor" }, { key="Esc", action="Quit editor" },
{ key="LMB on canvas (Auto-Tile)", action="Paint vertex on active layer (any-corner fill)" }, { key="LMB on canvas (Auto-Tile)", action="Paint vertex on active layer (any-corner fill)" },
{ key="RMB on canvas (Auto-Tile)", action="Clear vertex on active layer" }, { key="RMB on canvas (Auto-Tile)", action="Clear vertex on active layer" },
@@ -603,7 +643,10 @@ local ui = (function()
local cell = state.get_mouse_cell() local cell = state.get_mouse_cell()
local cell_str = cell and string.format("Mouse: (%d,%d)", cell.x, cell.y) or "Mouse: (-,-)" local cell_str = cell and string.format("Mouse: (%d,%d)", cell.x, cell.y) or "Mouse: (-,-)"
local dirty_str = state.is_dirty() and " * unsaved" or "" local dirty_str = state.is_dirty() and " * unsaved" or ""
engine.render.draw_text(cell_str .. dirty_str, s.x + 4, s.y + 4, 12, -- 0.2.0c: append the current direct-paint transform state.
local xform_str = string.format(" slot=%d rot=%d flip=%d",
state.get_active_tile(), state.get_active_rot(), state.get_active_flip())
engine.render.draw_text(cell_str .. dirty_str .. xform_str, s.x + 4, s.y + 4, 12,
rgba(COL_TEXT, 0xFF)) rgba(COL_TEXT, 0xFF))
end end
@@ -682,6 +725,8 @@ local ui = (function()
local cell = state.get_mouse_cell() local cell = state.get_mouse_cell()
if cell then actions.erase_cell_at(cell.x, cell.y) end if cell then actions.erase_cell_at(cell.x, cell.y) end
elseif id == "rotate" then actions.cycle_rotation() elseif id == "rotate" then actions.cycle_rotation()
elseif id == "flip" then actions.toggle_flip()
elseif id == "reset_xform" then actions.reset_transform()
elseif id == "help" then actions.toggle_cheatsheet() elseif id == "help" then actions.toggle_cheatsheet()
end end
end end
@@ -769,8 +814,10 @@ input.bind("rotate", { "r" })
input.bind("save_map", { "s" }) input.bind("save_map", { "s" })
input.bind("toggle_mode", { "tab" }) -- 0.2.0a input.bind("toggle_mode", { "tab" }) -- 0.2.0a
input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no shifted-key support in input lib) input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no shifted-key support in input lib)
input.bind("flip", { "h" }) -- 0.2.0c
input.bind("reset_transform", { "0" }) -- 0.2.0c
engine.print("map-editor v0.2.0b.1: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, ESC=quit") engine.print("map-editor v0.2.0c: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit")
-- ===================================================================== -- =====================================================================
-- Frame hooks -- Frame hooks
@@ -842,6 +889,14 @@ function update(ctx, dt)
actions.toggle_cheatsheet() actions.toggle_cheatsheet()
end end
-- 0.2.0c hotkeys: transform controls (Direct-mode override paint)
if input.was_action_pressed("flip") then
actions.toggle_flip()
end
if input.was_action_pressed("reset_transform") then
actions.reset_transform()
end
-- Mouse clicks: hit-test UI first, then map area -- Mouse clicks: hit-test UI first, then map area
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
local consumed = ui.handle_click(mx, my, "left") local consumed = ui.handle_click(mx, my, "left")
@@ -850,16 +905,24 @@ function update(ctx, dt)
local snap = state.get_snap_vertex() local snap = state.get_snap_vertex()
if snap then actions.paint_vertex_at(snap.vx, snap.vy, true) end if snap then actions.paint_vertex_at(snap.vx, snap.vy, true) end
elseif state.get_mouse_cell() then elseif state.get_mouse_cell() then
-- 0.2.0c: Direct-mode on a material-bound terrain layer
-- writes an override per cell, honouring active rot+flip.
local cell = state.get_mouse_cell() local cell = state.get_mouse_cell()
actions.paint_cell_at(cell.x, cell.y) actions.paint_override_at(cell.x, cell.y)
end end
end end
end end
if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then
local consumed = ui.handle_click(mx, my, "right") local consumed = ui.handle_click(mx, my, "right")
if not consumed and state.get_mode() == "auto-tile" then if not consumed then
if state.get_mode() == "auto-tile" then
local snap = state.get_snap_vertex() local snap = state.get_snap_vertex()
if snap then actions.paint_vertex_at(snap.vx, snap.vy, false) end if snap then actions.paint_vertex_at(snap.vx, snap.vy, false) end
elseif state.get_mouse_cell() then
-- 0.2.0c: Direct + RMB on canvas clears the override.
local cell = state.get_mouse_cell()
actions.erase_override_at(cell.x, cell.y)
end
end end
end end

View File

@@ -1,6 +1,6 @@
{ {
"id": "map-editor", "id": "map-editor",
"version": "0.2.0b.1", "version": "0.2.0c",
"kind": "module", "kind": "module",
"api": "^0.1", "api": "^0.1",
"ci_frames": 30, "ci_frames": 30,