Replace box variants with a moveable adhesion property

Collapse the spiky/sticky/skewer box-variant assets into a single per-
instance `adhesion` property on any moveable block, chosen in the editor
via a dropdown next to the colour picker and stored in the .sav alongside
the class.

- spiky -> sticky: soft grab on every true edge (goopy shader)
- skewer -> gluey: rigid weld on every true edge (spiked outline)
- old trailing-face sticky is removed; its saves fold into new sticky

Move the spike geometry + drawGlueySprite into shaders.lua, fold drawing
and the adhesion flag into GenericMoveable, and delete the three
Generic{Sticky,Spiky,Skewer} classes and box_variants.lua. Room:createEntity
normalises legacy spiky_/skewer_/sticky_ class prefixes so old saves load,
and serialization emits `adhesion`. World/map previews and the movement
proxy follow the rename.

Backfill rooms/*.sav to the new format via tools/backfill_adhesion.lua
(41 objects across 8 files). Update tests to the two-mode model; the two
tests asserting the removed lateral-shear limitation are dropped and a
legacy-load migration test added. 27/27 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-08-21 16:34:01 -04:00
parent c348b81a93
commit 7d84f595e4
25 changed files with 4010 additions and 3797 deletions

View file

@ -63,6 +63,15 @@ local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = {}
-- Adhesion applied to any moveable block placed from the room view. "regular"
-- means no adhesion; the others attach as a per-instance property (see
-- Room:createEntity). `adhesionRect` is recomputed each frame in
-- layoutRoomControls so draw and hit-testing agree; the menu expands upward.
local adhesionOptions = { "regular", "sticky", "gluey" }
local selectedAdhesion = "regular"
local adhesionMenuOpen = false
local adhesionRect = nil
-- Note authoring state. `noteMode` is nil normally, "create" while placing a new
-- note (click a cell to drop it and finish), or "edit" while retyping an
-- existing note (with Save / Discard). `editingNote` is the note being edited.
@ -87,7 +96,10 @@ end
local function paintRoomCell(cell, button)
if button == 1 and selectedAsset then
currentRoom:attemptPlaceAtCell(selectedAsset, gameAssets[selectedAsset], cell, selectedColor)
-- adhesion is a no-op on non-moveable assets (Room:createEntity only reads
-- it in the moveable branch), so it is always safe to pass along.
local adhesion = selectedAdhesion ~= "regular" and selectedAdhesion or nil
currentRoom:attemptPlaceAtCell(selectedAsset, gameAssets[selectedAsset], cell, selectedColor, nil, adhesion)
elseif button == 2 then
currentRoom:attemptDeleteAtCell(cell, selectedColor)
end
@ -132,16 +144,16 @@ local function currentRoomList()
end
local function rebuildBoxPaletteSections()
-- Adhesion (sticky/gluey) is now a placement modifier chosen with the dropdown
-- next to the colour picker, not a separate asset, so the palette lists only
-- the base box shapes (plus the barrier).
boxPaletteSections = {}
for _, prefix in ipairs({ "box", "spiky", "sticky", "skewer" }) do
local names = {}
local pattern = prefix == "box" and "^box" or "^" .. prefix .. "_box"
for name in pairs(gameAssets) do
if name:match(pattern) then table.insert(names, name) end
end
table.sort(names)
table.insert(boxPaletteSections, { prefix = prefix, names = names })
local names = {}
for name in pairs(gameAssets) do
if name:match("^box") then table.insert(names, name) end
end
table.sort(names)
table.insert(boxPaletteSections, { prefix = "box", names = names })
if gameAssets.player_blocker then
table.insert(boxPaletteSections, { prefix = "barrier", names = {"player_blocker"} })
end
@ -213,15 +225,7 @@ local function drawPaletteAsset(name, x, y, size)
for color, path in pairs(props.sprites) do
local sprite = loadSprite(path)
love.graphics.setColor(gColor[color]:set())
if props.goopy and drawGoopySprite then
drawGoopySprite(sprite, drawX, drawY, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], drawX, drawY, scale)
elseif props.spiky and drawSpikySprite then
drawSpikySprite(sprite, props.cells and props.cells[color], drawX, drawY, scale)
else
love.graphics.draw(sprite, drawX, drawY, 0, scale, scale)
end
love.graphics.draw(sprite, drawX, drawY, 0, scale, scale)
end
end
@ -332,11 +336,89 @@ local function layoutRoomControls(c, s)
y = y + target + vgap
end
placeRow({ "white", "red", "green", "blue" }, 22)
-- Colour swatches with the adhesion dropdown packed to their right, centered
-- as one group. adhesionRect feeds both the dropdown draw and its hit-test.
do
local target = 22
local dropdownW = 78
local btns, total = {}, 0
for _, name in ipairs({ "white", "red", "green", "blue" }) do
local b = sizeBtn(name, target)
if b then table.insert(btns, b); total = total + b.w end
end
total = total + hgap * #btns + dropdownW
local x = c.x + (c.w - total) / 2
for _, b in ipairs(btns) do
b.x = x
b.y = y + (target - b.h) / 2
x = x + b.w + hgap
end
adhesionRect = { x = x, y = y, w = dropdownW, h = target }
y = y + target + vgap
end
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world", "wire", "note", "mark" }, 28)
end
-- Stacked option rows for the open adhesion menu, expanding upward from the
-- closed control so it never spills past the bottom edge. Shared by draw + click.
local function adhesionMenuRects()
if not adhesionRect then return {} end
local rects = {}
for index, name in ipairs(adhesionOptions) do
-- topmost option is the last one drawn highest above the closed control
local offset = (#adhesionOptions - index + 1) * adhesionRect.h
rects[index] = {
name = name,
x = adhesionRect.x,
y = adhesionRect.y - offset,
w = adhesionRect.w,
h = adhesionRect.h,
}
end
return rects
end
local function drawAdhesionDropdown()
if not adhesionRect then return end
local r = adhesionRect
love.graphics.setColor(gColor.white:set())
love.graphics.setLineWidth(2)
love.graphics.rectangle("line", r.x, r.y, r.w, r.h)
local caret = adhesionMenuOpen and " v" or " ^"
love.graphics.printf(selectedAdhesion:upper() .. caret, r.x, r.y + (r.h - font:getHeight()) / 2, r.w, "center")
if adhesionMenuOpen then
for _, option in ipairs(adhesionMenuRects()) do
love.graphics.setColor(0, 0, 0, 0.9)
love.graphics.rectangle("fill", option.x, option.y, option.w, option.h)
love.graphics.setColor(option.name == selectedAdhesion and 1 or 0.6, 0.85, option.name == selectedAdhesion and 0.2 or 0.6, 1)
love.graphics.rectangle("line", option.x, option.y, option.w, option.h)
love.graphics.setColor(gColor.white:set())
love.graphics.printf(option.name:upper(), option.x, option.y + (option.h - font:getHeight()) / 2, option.w, "center")
end
end
end
-- Returns true when the click was consumed by the dropdown (toggle or a choice).
local function adhesionDropdownClick(x, y)
if not adhesionRect then return false end
if adhesionMenuOpen then
for _, option in ipairs(adhesionMenuRects()) do
if Layout.hit(option, x, y) then
selectedAdhesion = option.name
adhesionMenuOpen = false
return true
end
end
end
if Layout.hit(adhesionRect, x, y) then
adhesionMenuOpen = not adhesionMenuOpen
return true
end
if adhesionMenuOpen then adhesionMenuOpen = false end
return false
end
-- Build the full room-view layout (sections + a scroll list + a back/search
-- header) without drawing. Shared by editorDraw and editorMouseHandler.
local function buildRoomLayout()
@ -402,10 +484,6 @@ local function rebuildAssetSearchButtons()
for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end
table.insert(assetSearchButtons, createUIElement{
sprites = sprites,
goopy = gameAssets[asset].goopy,
skewer = gameAssets[asset].skewer,
spiky = gameAssets[asset].spiky,
cells = gameAssets[asset].cells,
name = "asset",
text = asset,
x = 30,
@ -739,10 +817,6 @@ function _initLevelEditor()
_addUIButton(createUIElement{
name = "quick_" .. asset,
sprites = sprites,
goopy = properties.goopy,
skewer = properties.skewer,
spiky = properties.spiky,
cells = properties.cells,
onClic = { left = function() selectQuickAsset(asset) end },
x = 0,
y = 0,
@ -819,10 +893,6 @@ function selectFolder(folder)
-- each frame from the list section (see buildRoomLayout).
table.insert(assetListButtons, createUIElement{
sprites = sprites,
goopy = gameAssets[asset].goopy,
skewer = gameAssets[asset].skewer,
spiky = gameAssets[asset].spiky,
cells = gameAssets[asset].cells,
name = "asset",
text = asset,
x = 30,
@ -1147,10 +1217,6 @@ function editAsset(asset)
local assetButton = createUIElement{
name = asset,
sprites = assetSprites,
goopy = assetProperties.goopy,
skewer = assetProperties.skewer,
spiky = assetProperties.spiky,
cells = assetProperties.cells,
x = sb.w / 2 - 18,
y = 80,
}
@ -1517,6 +1583,10 @@ function editorMouseHandler(x, y, button)
end
end
-- adhesion dropdown (sits next to the colour picker; its open menu
-- overlaps the area above the controls, so test it before the buttons)
if button == 1 and adhesionDropdownClick(x, y) then return end
-- control cluster (only the buttons that live there)
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire", "note", "mark" }) do
local b = uiButtons[name]
@ -1833,6 +1903,9 @@ function editorDraw()
end
end
-- adhesion dropdown, drawn last so its open menu sits above the controls
drawAdhesionDropdown()
-- status strip between the list and the controls: Save / Discard while
-- editing an existing note, otherwise the ordinary status line.
if noteMode == "edit" then

View file

@ -9,10 +9,6 @@ function GUI:initialize(t)
self.offset = {x = 0, y = 0}
self.name = t.name
self.text = t.text
self.goopy = t.goopy or false
self.skewer = t.skewer or false
self.spiky = t.spiky or false
self.cells = t.cells
if not t.onClic then
t.onClic = {}
@ -103,15 +99,7 @@ function GUI:draw()
if self.sprites then
for color, sprite in pairs(self.sprites) do
love.graphics.setColor(gColor[color]:set())
if self.goopy and drawGoopySprite then
drawGoopySprite(sprite, box.x, box.y, self.scale)
elseif self.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, self.cells and self.cells[color], box.x, box.y, self.scale)
elseif self.spiky and drawSpikySprite then
drawSpikySprite(sprite, self.cells and self.cells[color], box.x, box.y, self.scale)
else
love.graphics.draw(sprite, box.x, box.y, 0, self.scale, self.scale)
end
love.graphics.draw(sprite, box.x, box.y, 0, self.scale, self.scale)
end
end

View file

@ -1,7 +1,5 @@
require "libs/TSerial"
local BoxVariants = require "box_variants"
local targetFolder = "art"
local reanalyzeCells = true
@ -77,5 +75,5 @@ function getAllAssets()
synthetic("wired_switch", "switch", "wired_switch")
synthetic("wired_door", "door", "wired_door")
return BoxVariants.addTo(globalAssetProperties)
return globalAssetProperties
end