chroma_solstice/docs/adhesion-refactor-plan.md
Your Name 7d84f595e4 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>
2026-08-21 16:34:01 -04:00

12 KiB
Raw Blame History

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 → new sticky — 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 → new gluey — 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. Legacy sticky_* assets in saves are absorbed into new sticky.
  • 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 in generic_skewer.lua) already walks cellShape and 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 the self.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:
    function Entity:isSticky() return self.adhesion == "sticky" end
    function Entity:isGluey()  return self.adhesion == "gluey"  end
    
    Remove isSkewer and isSpiky.

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; rename isSkewer→isGluey (entity.isGluey and entity:isGluey()); delete isSpiky.
  • 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 trailing computation and the old sticky branch. softContact becomes just:
    local softContact = adjacentEntity and
        (isSticky(entity) or isSticky(adjacentEntity))
    
    Update comments (2–3, 102–104) to describe sticky = soft on every edge, gluey = rigid.

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, drawSpikes from generic_skewer.lua into shaders.lua (verbatim; they're pure geometry).
  • Add drawGlueySprite(sprite, cellShape, x, y, scale) = old drawSkewerSprite (calls drawSpikes then draws the sprite).
  • Drop drawSpikySprite entirely (old spiky's draw just fell back to goopy; new sticky uses drawGoopySprite directly).

4. generic_moveable.lua (the one surviving box class)

  • initialize(x, y, color, sprite, name, cellShape, adhesion) — pass adhesion = adhesion into Entity.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 three require "generic_*" lines (keep generic_moveable).
  • Delete box_variants.lua.
  • level_editor/object_attributes.lua: remove line 3 require "box_variants"; change line 80 return 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
    elseif behavior == "moveable" then
      entity = GenericMoveable:new(x, y, color, sprite, class, cellShape, adhesion)
    
    Delete the "sticky"/"skewer"/"spiky" elseif branches. (adhesion is only passed here, so it can never attach to a non-moveable.)
  • entity.saveClass = class at 847 now stores the base box name — good.

7. Serialization

  • serialize emit (1180–1185): add
    if entity.adhesion then object.adhesion = entity.adhesion end
    
  • deserialize (1114) already forwards the whole obj as data, so data.adhesion is 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.unpack it, walk saveTable.objects, and for each object whose class matches ^spiky_(.+)/^skewer_(.+)/^sticky_(.+): set object.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 .sav filenames like spiky_test are just level names — leave them, or rename separately; only the object class fields 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 the spiky/sticky/skewer prefixes; keep only { "box" } (+ the barrier section). The single box section now lists just the base box shapes.

  • Layout layoutRoomControls (284–338): the color row is placeRow({ "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: before placeRow for colors, compute the color row's y, lay the 4 swatches, then set adhesionRect = { x = <right of swatches + gap>, y = rowY, w = 84, h = 22 }. If width is tight, bump controlsH to ~150 in roomSections (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): draw adhesionRect as a labeled box showing selectedAdhesion:upper(). When adhesionMenuOpen, draw the 3 options stacked upward from adhesionRect (it's near the bottom edge): regular, sticky, gluey. Highlight the current.

  • Click (in editorMouseHandler, room branch — add before the control-cluster loop ~1520): if adhesionMenuOpen and click hits an option row → set selectedAdhesion, close, return. If click hits adhesionRect → toggle adhesionMenuOpen, return. Any other click closes the menu.

  • Placement threading: paintRoomCell (88–94) calls attemptPlaceAtCell(selectedAsset, gameAssets[selectedAsset], cell, selectedColor). Pass adhesion: add a 6th arg selectedAdhesion ~= "regular" and selectedAdhesion or nil.

    • room.lua Room:attemptPlaceAtCell(name, props, cell, color, text, adhesion) (1041): forward it into the per-channel createEntity: 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/spiky no longer exist. Remove those branches in drawPaletteAsset (216–224) — draw the sprite plainly (the palette shows base boxes; adhesion is a placement mode, not a per-asset style). Remove goopy/skewer/spiky/cells args passed to createUIElement at the asset-button/quick-button/search sites (403–414, 744–749, 820–836, 1147–1156) and the matching fields + draw branches in level_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 to movable(x, y, adhesion, shape); store self.adhesion; replace isSticky/isSkewer/isSpiky with isSticky() -> adhesion=="sticky", isGluey() -> adhesion=="gluey". Update the existing callers that passed positional sticky/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 final print (606) to drop "block-variant".
  • lua tests/test_movement.lua must 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 adhesion on the right objects.
  • Placing with the dropdown on sticky/gluey produces the goopy/spiked look and the correct push/pull behavior; on a non-moveable asset it's ignored.