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

247 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `.sav`s, (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:
```lua
-- 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:
```lua
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:
```lua
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:
```lua
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:
```lua
-- 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
```lua
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
```lua
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 `.sav`s (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):
```lua
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.