feat(map-editor): v0.2.0c.1 — drag-paint strokes

LMB-hold strokes a line of paints (Auto-Tile: each hovered vertex;
Direct: each hovered cell as an override). RMB-hold strokes a line
of erases (clear_vertex / clear_override). On mouse-press inside the
UI (toolbar / palette / side-panel) the drag never enters paint-mode
so toolbar interaction stays unaffected.

State machine:
 - press(button) outside UI -> drag_paint = "paint"|"erase"
 - while drag_paint and corresponding button still down -> dispatch
   paint at current snap_vertex / mouse_cell each frame
 - button release -> drag_paint = nil

Idempotency: maps.set_vertex / set_override / clear_* are no-op when
the target state is unchanged, so the per-frame repaint is cheap and
doesn't grow the JSON on save.
This commit is contained in:
calic
2026-05-29 01:05:39 +02:00
parent 30f38d771f
commit 77832c07b2
2 changed files with 47 additions and 20 deletions

View File

@@ -1,4 +1,4 @@
-- sporel-module-map-editor v0.2.0c -- sporel-module-map-editor v0.2.0c.1
-- 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
@@ -12,6 +12,9 @@
-- {slot, rot, flip} on a material-bound terrain layer. Single -- {slot, rot, flip} on a material-bound terrain layer. Single
-- `flip` flag (0/1) combined with rot (0..3) covers all 8 D4 -- `flip` flag (0/1) combined with rot (0..3) covers all 8 D4
-- orientations; H+V buttons are redundant so we ship just one. -- orientations; H+V buttons are redundant so we ship just one.
-- 0.2.0c.1 adds drag-paint: LMB-hold strokes a line of paints, RMB-hold
-- a line of erases (both in Auto-Tile and Direct mode). UI clicks on
-- toolbar / palette / side-panel never enter drag-mode.
-- Decal/Entity sub-selectors + Material-Properties modal land in 0.2.0d. -- 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
@@ -59,6 +62,7 @@ local state = (function()
cheatsheet_open = false, -- 0.2.0a: ?-modal toggled? cheatsheet_open = false, -- 0.2.0a: ?-modal toggled?
mouse_cell = nil, -- {x, y} or nil if mouse off-map mouse_cell = nil, -- {x, y} or nil if mouse off-map
snap_vertex = nil, -- 0.2.0b: {vx, vy} or nil (auto-tile mode snap target) snap_vertex = nil, -- 0.2.0b: {vx, vy} or nil (auto-tile mode snap target)
drag_paint = nil, -- 0.2.0c.1: nil | "paint" | "erase" (active drag-stroke mode)
} }
-- ===================================================================== -- =====================================================================
@@ -147,6 +151,9 @@ local state = (function()
end end
function M.get_snap_vertex() return state.snap_vertex end function M.get_snap_vertex() return state.snap_vertex end
function M.set_drag_paint(mode) state.drag_paint = mode end -- 0.2.0c.1
function M.get_drag_paint() return state.drag_paint end
-- Used by tests to reset between asserts -- Used by tests to reset between asserts
function M.reset() function M.reset()
state.map_id = nil state.map_id = nil
@@ -162,6 +169,7 @@ local state = (function()
state.cheatsheet_open = false state.cheatsheet_open = false
state.mouse_cell = nil state.mouse_cell = nil
state.snap_vertex = nil state.snap_vertex = nil
state.drag_paint = nil
for name in pairs(state.layer_visible) do for name in pairs(state.layer_visible) do
state.layer_visible[name] = true state.layer_visible[name] = true
end end
@@ -400,9 +408,10 @@ local ui = (function()
{ key="H", action="Toggle tile flip 0<->1 (Direct-mode override)" }, { key="H", action="Toggle tile flip 0<->1 (Direct-mode override)" },
{ key="0", action="Reset transform (rot=0, flip=0)" }, { 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-drag canvas (Auto-Tile)", action="Stroke-paint vertices (any-corner fill)" },
{ key="RMB on canvas (Auto-Tile)", action="Clear vertex on active layer" }, { key="RMB-drag canvas (Auto-Tile)", action="Stroke-clear vertices" },
{ key="LMB on canvas (Direct)", action="Paint cell with active slot + rotation" }, { key="LMB-drag canvas (Direct)", action="Stroke-paint cells (override with slot+rot+flip)" },
{ key="RMB-drag canvas (Direct)", action="Stroke-clear overrides" },
{ key="LMB on palette swatch", action="Select slot (1-14) as active tile" }, { key="LMB on palette swatch", action="Select slot (1-14) as active tile" },
{ key="RMB on Layer row", action="Toggle layer visibility" }, { key="RMB on Layer row", action="Toggle layer visibility" },
{ key="LMB on Layer row", action="Set as active layer" }, { key="LMB on Layer row", action="Set as active layer" },
@@ -817,7 +826,7 @@ input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no
input.bind("flip", { "h" }) -- 0.2.0c input.bind("flip", { "h" }) -- 0.2.0c
input.bind("reset_transform", { "0" }) -- 0.2.0c input.bind("reset_transform", { "0" }) -- 0.2.0c
engine.print("map-editor v0.2.0c: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit") engine.print("map-editor v0.2.0c.1: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit")
-- ===================================================================== -- =====================================================================
-- Frame hooks -- Frame hooks
@@ -897,34 +906,52 @@ function update(ctx, dt)
actions.reset_transform() actions.reset_transform()
end end
-- Mouse clicks: hit-test UI first, then map area -- Mouse clicks + drag: hit-test UI on press; if consumed we never
-- enter drag-mode for this stroke. Otherwise the press initiates a
-- drag-stroke whose paint/erase action repeats for every subsequent
-- snap_vertex (or cell) while the button stays down. set_vertex /
-- set_override are idempotent + no-op when state is unchanged so
-- repeating per-frame is cheap.
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")
if not consumed then if not consumed then
if state.get_mode() == "auto-tile" then state.set_drag_paint("paint")
local snap = state.get_snap_vertex()
if snap then actions.paint_vertex_at(snap.vx, snap.vy, true) end
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()
actions.paint_override_at(cell.x, cell.y)
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 then if not consumed then
state.set_drag_paint("erase")
end
end
-- Per-frame drag-paint dispatch (Auto-Tile uses snap_vertex; Direct
-- uses mouse_cell). Stops as soon as the originating button releases.
local dp = state.get_drag_paint()
if dp then
local lmb_down = engine.input.is_mouse_down(engine.input.MOUSE_LEFT)
local rmb_down = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT)
local active = (dp == "paint" and lmb_down) or (dp == "erase" and rmb_down)
if not active then
state.set_drag_paint(nil)
else
if state.get_mode() == "auto-tile" 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
elseif state.get_mouse_cell() then actions.paint_vertex_at(snap.vx, snap.vy, dp == "paint")
-- 0.2.0c: Direct + RMB on canvas clears the override. end
else
local cell = state.get_mouse_cell() local cell = state.get_mouse_cell()
if cell then
if dp == "paint" then
actions.paint_override_at(cell.x, cell.y)
else
actions.erase_override_at(cell.x, cell.y) actions.erase_override_at(cell.x, cell.y)
end end
end end
end end
end
end
camera.update(dt) camera.update(dt)
end end

View File

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