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>
12 KiB
Adhesion refactor — plan
Goal
Today "spiky", "sticky", "skewer" are separate box assets (distinct class
names, generated by box_variants.lua). Replace that with a single per-instance
adhesion property on any moveable entity, chosen at placement time via a
dropdown in the level editor.
Renames / semantics (final)
- old
spiky→ newsticky— soft grab on every true edge. When the block moves, it tries to drag every orthogonally-adjacent block along; if a dragged block is blocked, that branch just shears off (the move still succeeds). Renders with the goopy shader. - old
skewer→ newgluey— rigid weld on every true edge. Welded neighbors move as one component; if any can't move, the whole move fails. Renders with the spike/teeth outline. - old
sticky(trailing-face-only grab) → DELETED. Its distinct mechanic is gone. Legacysticky_*assets in saves are absorbed into newsticky. - regular — plain moveable, no adhesion.
Adhesion is stored as adhesion = "sticky" | "gluey" (nil/absent = regular) and
is only meaningful on moveable entities.
Contour requirement (already satisfied — no work)
Edges must follow the true profile of multi-cell shapes with holes.
- Spikes:
drawSpikes(currently ingeneric_skewer.lua) already walkscellShapeand only draws teeth on an edge with no neighbor cell. Keep as-is. - Goopy:
drawGoopySprite/goopShader(shaders.lua) sample the sprite's own alpha, so transparent cells stay transparent. Nothing to change.
Rationale / approach
The behavior already keys off boolean flags read through Entity methods
(isSticky/isSkewer/isSpiky), and the three Generic* subclasses only differ
by which flag they set + which draw helper they call. So collapsing them into one
GenericMoveable carrying an adhesion string is a small, mechanical change. The
real surface area is: (a) the movement rules, (b) serialization + backfilling
existing .savs, (c) deleting the synthetic box-variant assets, (d) the editor
dropdown. Do it in that order so tests stay runnable (lua tests/test_movement.lua)
throughout.
1. entity.lua
initialize(lines 17–19): delete theself.sticky/skewer/spiky = ...trio, replace with:-- Per-instance adhesion for moveable blocks: "sticky" (soft grab on every true -- edge) or "gluey" (rigid weld). nil = plain moveable. See docs/adhesion-*. self.adhesion = t.adhesion- Methods (lines 140–150): replace the three predicates with:
Removefunction Entity:isSticky() return self.adhesion == "sticky" end function Entity:isGluey() return self.adhesion == "gluey" endisSkewerandisSpiky.
2. movement.lua
Semantics: sticky = soft all-edge pull; gluey = rigid all-edge weld. The old
"trailing face only" behavior is deleted.
- Local helpers (13–27): keep
isSticky; renameisSkewer→isGluey(entity.isGluey and entity:isGluey()); deleteisSpiky. findRigidGroup(line 72):isSkewer(entity) or isSkewer(adjacentEntity)→isGluey(entity) or isGluey(adjacentEntity). Update the comment (61–62) to say "gluey welds every edge".- Pull loop (102–118): remove the
trailingcomputation and the old sticky branch.softContactbecomes just:
Update comments (2–3, 102–104) to describe sticky = soft on every edge, gluey = rigid.local softContact = adjacentEntity and (isSticky(entity) or isSticky(adjacentEntity))
3. Draw helpers → shaders.lua
drawGoopySprite already lives here. Move the spike geometry here too so the
Generic* box files can be deleted:
- Move
edgeDirections,shapeHasCell,drawSpikesfromgeneric_skewer.luaintoshaders.lua(verbatim; they're pure geometry). - Add
drawGlueySprite(sprite, cellShape, x, y, scale)= olddrawSkewerSprite(callsdrawSpikesthen draws the sprite). - Drop
drawSpikySpriteentirely (old spiky's draw just fell back to goopy; new sticky usesdrawGoopySpritedirectly).
4. generic_moveable.lua (the one surviving box class)
initialize(x, y, color, sprite, name, cellShape, adhesion)— passadhesion = adhesionintoEntity.initialize.- Add a
draw(channel)that branches:function GenericMoveable:draw(channel) local sprite = self.sprites[channel]; if not sprite then return end love.graphics.setColor(gColor[channel]:set()) local p = self:getDrawPos() local x = p.x + self.drawOffset.x * drawScale local y = p.y + self.drawOffset.y * drawScale if self.adhesion == "gluey" then drawGlueySprite(sprite, self:getCellShape(), x, y, drawScale) elseif self.adhesion == "sticky" and drawGoopySprite then drawGoopySprite(sprite, x, y, drawScale) else love.graphics.draw(sprite, x, y, 0, drawScale, drawScale) end end
5. Delete files + requires
- Delete
generic_sticky.lua,generic_spiky.lua,generic_skewer.lua. room.lua(15–17): remove the threerequire "generic_*"lines (keepgeneric_moveable).- Delete
box_variants.lua. level_editor/object_attributes.lua: remove line 3require "box_variants"; change line 80return BoxVariants.addTo(globalAssetProperties)→return globalAssetProperties.
6. room.lua createEntity (752–849)
Boxes are now always behavior == "moveable"; adhesion comes from data.adhesion.
- After the note/mark/npc early-returns and before
local props = ..., normalize legacy variant class names so old saves load:-- Legacy: adhesion used to be baked into separate asset classes -- (spiky_/skewer_/sticky_<box>). Collapse to the base asset + an adhesion string. local adhesion = data.adhesion local legacy = { spiky = "sticky", skewer = "gluey", sticky = "sticky" } for prefix, mapped in pairs(legacy) do local base = class:match("^" .. prefix .. "_(.+)") if base then class = base; adhesion = adhesion or mapped; break end end - In the sprite branch (831–841): keep only
Delete theelseif behavior == "moveable" then entity = GenericMoveable:new(x, y, color, sprite, class, cellShape, adhesion)"sticky"/"skewer"/"spiky"elseif branches. (adhesion is only passed here, so it can never attach to a non-moveable.) entity.saveClass = classat 847 now stores the base box name — good.
7. Serialization
serializeemit(1180–1185): addif entity.adhesion then object.adhesion = entity.adhesion enddeserialize(1114) already forwards the wholeobjasdata, sodata.adhesionis read automatically. No change.
8. Backfill existing .savs (one-off script)
rooms/*.sav reference old class names (spiky_box, skewer_box, sticky_box,
plus shape suffixes like spiky_box_2x1_11). TSerial is pure Lua and runs under
plain lua. Write tools/backfill_adhesion.lua:
- For every file in
rooms/,TSerial.unpackit, walksaveTable.objects, and for each object whoseclassmatches^spiky_(.+)/^skewer_(.+)/^sticky_(.+): setobject.class = base,object.adhesion = {spiky="sticky", skewer="gluey", sticky="sticky"}[prefix]. - Re-emit with
TSerial.pack(saveTable, nil, true)and overwrite the file. - Affected files (from grep):
village_1/2/3.sav,world.sav,spiky_test.sav,spiky_test_2.sav,spiky_test_3.sav,testing_sticky.sav,testing_sticky_3.sav,testing_sticky_4.sav(process all, harmless on others). - Run
lua tools/backfill_adhesion.lua; commit the rewritten saves. (Note: the.savfilenames likespiky_testare just level names — leave them, or rename separately; only the objectclassfields matter.)
9. Editor — adhesion dropdown next to the color picker
level_editor/editor.lua. Add module-level state near the other locals (~62):
local selectedAdhesion = "regular" -- "regular" | "sticky" | "gluey"
local adhesionMenuOpen = false
local adhesionRect = nil -- set each frame by layoutRoomControls
-
Palette sections
rebuildBoxPaletteSections(134–148): drop thespiky/sticky/skewerprefixes; keep only{ "box" }(+ thebarriersection). The single box section now lists just the base box shapes. -
Layout
layoutRoomControls(284–338): the color row isplaceRow({ "white","red","green","blue" }, 22). Put the dropdown to the right of it, on the same 22px row (vertical budget is tight:controlsH = 130). Easiest: beforeplaceRowfor colors, compute the color row's y, lay the 4 swatches, then setadhesionRect = { x = <right of swatches + gap>, y = rowY, w = 84, h = 22 }. If width is tight, bumpcontrolsHto ~150 inroomSections(261) and add a dedicated row instead — either is fine; "next to the color picker" is the intent. -
Draw (in
editorDraw, room branch, after the control cluster ~1834): drawadhesionRectas a labeled box showingselectedAdhesion:upper(). WhenadhesionMenuOpen, draw the 3 options stacked upward fromadhesionRect(it's near the bottom edge):regular,sticky,gluey. Highlight the current. -
Click (in
editorMouseHandler, room branch — add before the control-cluster loop ~1520): ifadhesionMenuOpenand click hits an option row → setselectedAdhesion, close, return. If click hitsadhesionRect→ toggleadhesionMenuOpen, return. Any other click closes the menu. -
Placement threading:
paintRoomCell(88–94) callsattemptPlaceAtCell(selectedAsset, gameAssets[selectedAsset], cell, selectedColor). Pass adhesion: add a 6th argselectedAdhesion ~= "regular" and selectedAdhesion or nil.room.lua Room:attemptPlaceAtCell(name, props, cell, color, text, adhesion)(1041): forward it into the per-channelcreateEntity:self:createEntity(name, c, cell.x, cell.y, { adhesion = adhesion })(1087). (adhesion is ignored by non-moveable classes since only the moveable branch reads it.)
-
Cleanup of dead flag plumbing (optional but tidy):
props.goopy/skewer/spikyno longer exist. Remove those branches indrawPaletteAsset(216–224) — draw the sprite plainly (the palette shows base boxes; adhesion is a placement mode, not a per-asset style). Removegoopy/skewer/spiky/cellsargs passed tocreateUIElementat the asset-button/quick-button/search sites (403–414, 744–749, 820–836, 1147–1156) and the matching fields + draw branches inlevel_editor/gui.lua(12–14, 103–116). Harmless if left (nil → falsy), so this can be deferred.
10. Tests (tests/test_movement.lua)
- Helper
movable(32–57): change signature tomovable(x, y, adhesion, shape); storeself.adhesion; replaceisSticky/isSkewer/isSpikywithisSticky() -> adhesion=="sticky",isGluey() -> adhesion=="gluey". Update the existing callers that passed positionalsticky/skewer/spiky. - Rename the "spiky" test (536–552): call
movable(4, 4, "sticky")for the grabby block; assertions unchanged (soft shear-off behavior is identical). - If there are
skewer-based tests elsewhere, pass"gluey". - Delete the "every moveable base box gets ... variants" test (554–604) — that
feature (
box_variants.lua) is gone. Update the finalprint(606) to drop "block-variant". lua tests/test_movement.luamust pass.
Sanity checklist
grep -rn "spiky\|skewer\|isSpiky\|isSkewer\|box_variants\|BoxVariants"returns nothing in.lua(except maybe level-name strings / this doc).grep -rn "adhesion"shows entity/movement/room/editor wired consistently.- Old saves load (backfilled) and re-save with
adhesionon the right objects. - Placing with the dropdown on
sticky/glueyproduces the goopy/spiked look and the correct push/pull behavior; on a non-moveable asset it's ignored.