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

@ -1,37 +0,0 @@
local BoxVariants = {}
-- Add directional sticky-puller, rigid skewer, and soft all-edge spiky variants
-- for every authored moveable box. All share sprites/collision with their base.
function BoxVariants.addTo(assets)
local baseBoxes = {}
for name, properties in pairs(assets) do
if properties.class == "moveable" and name:match("^box") then
table.insert(baseBoxes, {name = name, properties = properties})
end
end
local function addVariant(base, prefix, behavior, directory, visualFlag)
local name = prefix .. base.name
local properties = {
class = behavior,
directory = directory,
sprites = base.properties.sprites,
cells = base.properties.cells,
preload = base.properties.preload,
synthetic = true,
baseAsset = base.name,
}
properties[visualFlag] = true
assets[name] = properties
end
for _, base in ipairs(baseBoxes) do
addVariant(base, "sticky_", "sticky", "sticky", "goopy")
addVariant(base, "skewer_", "skewer", "skewer", "skewer")
addVariant(base, "spiky_", "spiky", "spiky", "spiky")
end
return assets
end
return BoxVariants

View file

@ -0,0 +1,247 @@
# 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.

View file

@ -14,9 +14,10 @@ function Entity:initialize(t)
self.name = t.name
self.color = Color:new(t.color)
self.collision = t.collision --"none", "moveable", "unmoveable"
self.sticky = t.sticky or false
self.skewer = t.skewer or false
self.spiky = t.spiky or false
-- Per-instance adhesion for moveable blocks: "sticky" (soft grab on every true
-- edge) or "gluey" (rigid weld on every true edge). nil = plain moveable.
-- See docs/adhesion-refactor-plan.md.
self.adhesion = t.adhesion
self.sprite = repSprite
self.shaAmount = t.sha or 1
-- onBump is a method (see Entity:onBump) so mixins like Dialogable can
@ -138,15 +139,11 @@ function Entity:canMove()
end
function Entity:isSticky()
return self.sticky
return self.adhesion == "sticky"
end
function Entity:isSkewer()
return self.skewer
end
function Entity:isSpiky()
return self.spiky
function Entity:isGluey()
return self.adhesion == "gluey"
end
function Entity:canPassOver()

View file

@ -2,7 +2,7 @@ require "entity"
GenericMoveable = class("GenericMoveable", Entity)
function GenericMoveable:initialize(x, y, color, sprite, name, cellShape)
function GenericMoveable:initialize(x, y, color, sprite, name, cellShape, adhesion)
Entity.initialize(self, {
name = name,
x = x,
@ -10,7 +10,8 @@ function GenericMoveable:initialize(x, y, color, sprite, name, cellShape)
color = color,
collision = "moveable",
sprite = sprite,
cellShape = cellShape
cellShape = cellShape,
adhesion = adhesion
})
end
@ -18,6 +19,25 @@ function GenericMoveable:update()
end
-- Adhesion drives the look as well as the mechanic: gluey blocks get spiked
-- teeth on their true contour, sticky blocks get the wobbling goopy shader, and
-- plain moveables draw normally.
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" and drawGlueySprite 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
function GenericMoveable:tick()
end

View file

@ -1,92 +0,0 @@
require "entity"
GenericSkewer = class("GenericSkewer", Entity)
local edgeDirections = {
{x = 1, y = 0}, {x = -1, y = 0},
{x = 0, y = 1}, {x = 0, y = -1},
}
local function shapeHasCell(shape, x, y)
for _, cell in ipairs(shape) do
if cell.x == x and cell.y == y then return true end
end
return false
end
-- Draw two teeth on every exposed cell edge. The art remains synthetic: the
-- underlying box sprite and its collision shape are still the authored base.
function drawSpikes(cellShape, x, y, scale)
cellShape = cellShape or {{x = 1, y = 1}}
local cellSize = 16 * scale
local inset = scale
local reach = 3 * scale
local halfBase = 1.5 * scale
for _, cell in ipairs(cellShape) do
local left = x + (cell.x - 1) * cellSize
local top = y + (cell.y - 1) * cellSize
for _, direction in ipairs(edgeDirections) do
if not shapeHasCell(cellShape, cell.x + direction.x, cell.y + direction.y) then
for _, along in ipairs({5, 11}) do
if direction.x ~= 0 then
local edgeX = direction.x > 0 and left + cellSize - inset or left + inset
local centerY = top + along * scale
love.graphics.polygon("fill",
edgeX, centerY - halfBase,
edgeX + direction.x * reach, centerY,
edgeX, centerY + halfBase
)
else
local edgeY = direction.y > 0 and top + cellSize - inset or top + inset
local centerX = left + along * scale
love.graphics.polygon("fill",
centerX - halfBase, edgeY,
centerX, edgeY + direction.y * reach,
centerX + halfBase, edgeY
)
end
end
end
end
end
end
function drawSkewerSprite(sprite, cellShape, x, y, scale)
drawSpikes(cellShape, x, y, scale)
love.graphics.draw(sprite, x, y, 0, scale, scale)
end
function GenericSkewer:initialize(x, y, color, sprite, name, cellShape)
Entity.initialize(self, {
name = name,
x = x,
y = y,
color = color,
collision = "moveable",
skewer = true,
sprite = sprite,
cellShape = cellShape
})
end
function GenericSkewer:update()
end
function GenericSkewer:draw(channel)
local sprite = self.sprites[channel]
if not sprite then return end
love.graphics.setColor(gColor[channel]:set())
local p = self:getDrawPos()
drawSkewerSprite(
sprite, self:getCellShape(),
p.x + self.drawOffset.x * drawScale,
p.y + self.drawOffset.y * drawScale,
drawScale
)
end
function GenericSkewer:tick()
end

View file

@ -1,46 +0,0 @@
require "entity"
require "generic_skewer"
GenericSpiky = class("GenericSpiky", Entity)
function drawSpikySprite(sprite, _, x, y, scale)
if drawGoopySprite then
drawGoopySprite(sprite, x, y, scale)
else
love.graphics.draw(sprite, x, y, 0, scale, scale)
end
end
function GenericSpiky:initialize(x, y, color, sprite, name, cellShape)
Entity.initialize(self, {
name = name,
x = x,
y = y,
color = color,
collision = "moveable",
spiky = true,
sprite = sprite,
cellShape = cellShape
})
end
function GenericSpiky:update()
end
function GenericSpiky:draw(channel)
local sprite = self.sprites[channel]
if not sprite then return end
love.graphics.setColor(gColor[channel]:set())
local p = self:getDrawPos()
drawSpikySprite(
sprite, self:getCellShape(),
p.x + self.drawOffset.x * drawScale,
p.y + self.drawOffset.y * drawScale,
drawScale
)
end
function GenericSpiky:tick()
end

View file

@ -1,42 +0,0 @@
require "entity"
GenericSticky = class("GenericSticky", Entity)
function GenericSticky:initialize(x, y, color, sprite, name, cellShape)
Entity.initialize(self, {
name = name,
x = x,
y = y,
color = color,
collision = "moveable",
sticky = true,
sprite = sprite,
cellShape = cellShape
})
end
function GenericSticky:update()
end
function GenericSticky:draw(channel)
local sprite = self.sprites[channel]
if not sprite then return end
if not drawGoopySprite then
Entity.draw(self, channel)
return
end
love.graphics.setColor(gColor[channel]:set())
local p = self:getDrawPos()
drawGoopySprite(
sprite,
p.x + self.drawOffset.x * drawScale,
p.y + self.drawOffset.y * drawScale,
drawScale
)
end
function GenericSticky:tick()
end

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
if name:match("^box") then table.insert(names, name) end
end
table.sort(names)
table.insert(boxPaletteSections, { prefix = prefix, names = names })
end
table.insert(boxPaletteSections, { prefix = "box", names = names })
if gameAssets.player_blocker then
table.insert(boxPaletteSections, { prefix = "barrier", names = {"player_blocker"} })
end
@ -213,17 +225,9 @@ 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
end
end
local function drawBoxPicker(picker)
Layout.clip(picker.rect)
@ -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,17 +99,9 @@ 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
end
end
if self.text then
love.graphics.setColor(gColor.white:set())

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

View file

@ -217,12 +217,10 @@ function MapManager:drawRoomLayer(roomData, x, y, size, color, alpha)
love.graphics.setColor(r, g, b, alpha)
local sprite = loadSprite(props.sprites[color])
local dx, dy = x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale
if props.goopy and drawGoopySprite then
if object.adhesion == "gluey" and drawGlueySprite then
drawGlueySprite(sprite, props.cells and props.cells[color], dx, dy, scale)
elseif object.adhesion == "sticky" and drawGoopySprite then
drawGoopySprite(sprite, dx, dy, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], dx, dy, scale)
elseif props.spiky and drawSpikySprite then
drawSpikySprite(sprite, props.cells and props.cells[color], dx, dy, scale)
else
love.graphics.draw(sprite, dx, dy, 0, scale, scale)
end

View file

@ -1,6 +1,7 @@
-- Finds the complete set of blocks that must move together for one grid step.
-- A block joins the set when it is in another block's destination (ordinary
-- pushing), is softly grabbed by sticky/spiky, or touches a rigid skewer.
-- pushing), is softly grabbed by a sticky block, or is rigidly welded to a
-- gluey block.
local Movement = {}
local adjacentDirections = {
@ -18,15 +19,11 @@ local function isPassable(entity)
return entity.canPassOver and entity:canPassOver()
end
local function isSkewer(entity)
return entity.isSkewer and entity:isSkewer()
local function isGluey(entity)
return entity.isGluey and entity:isGluey()
end
local function isSpiky(entity)
return entity.isSpiky and entity:isSpiky()
end
-- Build one rigid component: ordinary pushes are required, and skewers join
-- Build one rigid component: ordinary pushes are required, and gluey blocks weld
-- along every edge. `alreadyMoving` contains cells that will be vacated during
-- this same step, so a later pulled component may move into them.
local function findRigidGroup(startCells, direction, entityAt, isInBounds, alreadyMoving)
@ -59,7 +56,7 @@ local function findRigidGroup(startCells, direction, entityAt, isInBounds, alrea
table.insert(pendingCells, projectedCell)
end
-- Skewers make every edge contact part of this rigid component.
-- Gluey blocks make every edge contact part of this rigid component.
for _, occupiedCell in ipairs(entity:getOccupiedCells()) do
for _, adjacentDirection in ipairs(adjacentDirections) do
local adjacentCell = {
@ -69,7 +66,7 @@ local function findRigidGroup(startCells, direction, entityAt, isInBounds, alrea
if isInBounds(adjacentCell) then
local adjacentEntity = entityAt(adjacentCell)
local attached = adjacentEntity and (
isSkewer(entity) or isSkewer(adjacentEntity)
isGluey(entity) or isGluey(adjacentEntity)
)
if attached and adjacentEntity ~= entity and adjacentEntity:canMove() and
not (alreadyMoving and alreadyMoving[adjacentEntity]) then
@ -99,9 +96,9 @@ function Movement.findMovableGroup(startCells, direction, entityAt, isInBounds)
local entity = movingQueue[nextMover]
nextMover = nextMover + 1
-- Sticky contact is soft only on the trailing face. Spiky contact is soft
-- on every face. Either kind simply releases a blocked rigid component,
-- without cancelling this entity or another independently pulled branch.
-- Sticky contact is soft on every face: it tries to drag each neighbour
-- along, but simply releases a blocked rigid component without cancelling
-- this entity or another independently pulled branch.
for _, occupiedCell in ipairs(entity:getOccupiedCells()) do
for _, adjacentDirection in ipairs(adjacentDirections) do
local adjacentCell = {
@ -110,11 +107,8 @@ function Movement.findMovableGroup(startCells, direction, entityAt, isInBounds)
}
if isInBounds(adjacentCell) then
local adjacentEntity = entityAt(adjacentCell)
local trailing = adjacentDirection.x == -direction.x and
adjacentDirection.y == -direction.y
local softContact = adjacentEntity and (
isSpiky(entity) or isSpiky(adjacentEntity) or
(trailing and (isSticky(entity) or isSticky(adjacentEntity)))
isSticky(entity) or isSticky(adjacentEntity)
)
if softContact and adjacentEntity ~= entity and
not entities[adjacentEntity] and not attemptedPulls[adjacentEntity] and

View file

@ -12,9 +12,6 @@ require "wired_switch"
require "wired_door"
require "dialogue_manager"
require "generic_moveable"
require "generic_sticky"
require "generic_skewer"
require "generic_spiky"
require "generic_unmoveable"
require "generic_player_blocker"
require "note"
@ -772,6 +769,17 @@ function Room:createEntity(class, color, x, y, data)
return entity
end
-- Legacy: adhesion used to be baked into separate asset classes
-- (spiky_/skewer_/sticky_<box>). Collapse them to the base asset carrying an
-- adhesion string so old .sav files still load. spiky/old-sticky both become
-- the new soft "sticky"; skewer becomes the rigid "gluey".
local adhesion = data.adhesion
local legacyAdhesion = { spiky = "sticky", skewer = "gluey", sticky = "sticky" }
for prefix, mapped in pairs(legacyAdhesion) do
local base = class:match("^" .. prefix .. "_(.+)")
if base then class = base; adhesion = adhesion or mapped; break end
end
local props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable"
local entity
@ -829,13 +837,7 @@ function Room:createEntity(class, color, x, y, data)
entity = GenericPlayerBlocker:new(x, y, color, sprite, cellShape)
self:registerPlayerBlocker(entity, color)
elseif behavior == "moveable" then
entity = GenericMoveable:new(x, y, color, sprite, class, cellShape)
elseif behavior == "sticky" then
entity = GenericSticky:new(x, y, color, sprite, class, cellShape)
elseif behavior == "skewer" then
entity = GenericSkewer:new(x, y, color, sprite, class, cellShape)
elseif behavior == "spiky" then
entity = GenericSpiky:new(x, y, color, sprite, class, cellShape)
entity = GenericMoveable:new(x, y, color, sprite, class, cellShape, adhesion)
else
entity = GenericUnmoveable:new(x, y, color, sprite, cellShape)
end
@ -1038,7 +1040,7 @@ function Room:attemptDelete(mouseX, mouseY, color)
return self:attemptDeleteAtCell(self:getCellFromMousePos(mouseX, mouseY), color)
end
function Room:attemptPlaceAtCell(name, props, cell, color, text)
function Room:attemptPlaceAtCell(name, props, cell, color, text, adhesion)
-- Notes are colourless, single-instance annotations (no per-channel sprites),
-- so they place once carrying their text rather than one copy per colour. One
-- note per cell: drop any note already here before adding the new one.
@ -1084,7 +1086,7 @@ function Room:attemptPlaceAtCell(name, props, cell, color, text)
-- additive blend combines the channels into full color
for _, c in ipairs(colors) do
if props.sprites[c] then
self:createEntity(name, c, cell.x, cell.y)
self:createEntity(name, c, cell.x, cell.y, { adhesion = adhesion })
end
end
self:tick()
@ -1181,6 +1183,7 @@ function Room:serialize(includePlayers)
local pos = entity:getGridPos()
local object = { class = class, color = color, x = pos.x, y = pos.y }
if entity.circuitId then object.circuit = entity.circuitId end
if entity.adhesion then object.adhesion = entity.adhesion end
table.insert(objects, object)
end

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,598 +3,605 @@ wires={
},
objects={
{
color="red",
x=1,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=11,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=1,
y=2,
class="static_wall",
y=2
color="red"
},
{
color="red",
x=11,
y=2,
class="static_wall",
y=2
color="red"
},
{
color="red",
x=1,
y=3,
class="static_wall",
y=3
color="red"
},
{
color="red",
x=11,
y=3,
class="static_wall",
y=3
color="red"
},
{
color="red",
x=1,
y=4,
class="static_wall",
y=4
color="red"
},
{
color="red",
x=11,
y=4,
class="static_wall",
y=4
color="red"
},
{
color="red",
x=1,
y=5,
class="static_wall",
y=5
color="red"
},
{
color="red",
x=11,
y=5,
class="static_wall",
y=5
color="red"
},
{
color="red",
x=1,
y=6,
class="static_wall",
y=6
color="red"
},
{
color="red",
x=11,
y=6,
class="static_wall",
y=6
color="red"
},
{
color="red",
x=1,
y=7,
class="static_wall",
y=7
color="red"
},
{
color="red",
x=11,
y=7,
class="static_wall",
y=7
color="red"
},
{
color="red",
x=1,
y=8,
class="static_wall",
y=8
color="red"
},
{
color="red",
x=11,
y=8,
class="static_wall",
y=8
color="red"
},
{
color="red",
x=1,
y=9,
class="static_wall",
y=9
color="red"
},
{
color="red",
x=11,
y=9,
class="static_wall",
y=9
color="red"
},
{
color="red",
x=1,
y=10,
class="static_wall",
y=10
color="red"
},
{
color="red",
x=11,
y=10,
class="static_wall",
y=10
color="red"
},
{
color="red",
x=1,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=2,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=3,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=4,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=5,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=7,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=8,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=9,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=10,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=11,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=9,
y=1,
class="box",
y=1
color="red"
},
{
x=7,
y=1,
adhesion="sticky",
color="red",
x=7,
class="spiky_box_2x1_11",
y=1
class="box_2x1_11"
},
{
x=5,
y=1,
adhesion="sticky",
color="red",
x=5,
class="spiky_box_2x1_11",
y=1
class="box_2x1_11"
},
{
color="green",
x=1,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=11,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=1,
y=2,
class="static_wall",
y=2
color="green"
},
{
color="green",
x=11,
y=2,
class="static_wall",
y=2
color="green"
},
{
color="green",
x=1,
y=3,
class="static_wall",
y=3
color="green"
},
{
color="green",
x=11,
y=3,
class="static_wall",
y=3
color="green"
},
{
color="green",
x=1,
y=4,
class="static_wall",
y=4
color="green"
},
{
color="green",
x=11,
y=4,
class="static_wall",
y=4
color="green"
},
{
color="green",
x=1,
y=5,
class="static_wall",
y=5
color="green"
},
{
color="green",
x=11,
y=5,
class="static_wall",
y=5
color="green"
},
{
color="green",
x=1,
y=6,
class="static_wall",
y=6
color="green"
},
{
color="green",
x=11,
y=6,
class="static_wall",
y=6
color="green"
},
{
color="green",
x=1,
y=7,
class="static_wall",
y=7
color="green"
},
{
color="green",
x=11,
y=7,
class="static_wall",
y=7
color="green"
},
{
color="green",
x=1,
y=8,
class="static_wall",
y=8
color="green"
},
{
color="green",
x=11,
y=8,
class="static_wall",
y=8
color="green"
},
{
color="green",
x=1,
y=9,
class="static_wall",
y=9
color="green"
},
{
color="green",
x=11,
y=9,
class="static_wall",
y=9
color="green"
},
{
color="green",
x=1,
y=10,
class="static_wall",
y=10
color="green"
},
{
color="green",
x=11,
y=10,
class="static_wall",
y=10
color="green"
},
{
color="green",
x=1,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=2,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=3,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=4,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=5,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=7,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=8,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=9,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=10,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=11,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="blue",
x=1,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=11,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=1,
y=2,
class="static_wall",
y=2
color="blue"
},
{
color="blue",
x=11,
y=2,
class="static_wall",
y=2
color="blue"
},
{
color="blue",
x=1,
y=3,
class="static_wall",
y=3
color="blue"
},
{
color="blue",
x=11,
y=3,
class="static_wall",
y=3
color="blue"
},
{
color="blue",
x=1,
y=4,
class="static_wall",
y=4
color="blue"
},
{
color="blue",
x=11,
y=4,
class="static_wall",
y=4
color="blue"
},
{
color="blue",
x=1,
y=5,
class="static_wall",
y=5
color="blue"
},
{
color="blue",
x=1,
y=6,
class="static_wall",
y=6
color="blue"
},
{
color="blue",
x=11,
y=6,
class="static_wall",
y=6
color="blue"
},
{
color="blue",
x=1,
y=7,
class="static_wall",
y=7
color="blue"
},
{
color="blue",
x=11,
y=7,
class="static_wall",
y=7
color="blue"
},
{
color="blue",
x=1,
y=8,
class="static_wall",
y=8
color="blue"
},
{
color="blue",
x=11,
y=8,
class="static_wall",
y=8
color="blue"
},
{
color="blue",
x=1,
y=9,
class="static_wall",
y=9
color="blue"
},
{
color="blue",
x=11,
y=9,
class="static_wall",
y=9
color="blue"
},
{
color="blue",
x=1,
y=10,
class="static_wall",
y=10
color="blue"
},
{
color="blue",
x=11,
y=10,
class="static_wall",
y=10
color="blue"
},
{
color="blue",
x=1,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=2,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=3,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=4,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=5,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=7,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=8,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=9,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=10,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=11,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=9,
class="spiky_box_2x1_11",
y=10
y=10,
adhesion="sticky",
color="blue",
class="box_2x1_11"
},
{
color="blue",
x=7,
class="spiky_box_2x1_11",
y=10
y=10,
adhesion="sticky",
color="blue",
class="box_2x1_11"
},
{
color="blue",
x=9,
class="spiky_box_2x1_11",
y=9
y=9,
adhesion="sticky",
color="blue",
class="box_2x1_11"
},
{
color="blue",
x=8,
y=8,
class="box_2x2_11-10",
y=8
color="blue"
},
{
color="blue",
x=3,
class="spiky_box_2x1_11",
y=10
y=10,
adhesion="sticky",
color="blue",
class="box_2x1_11"
},
{
color="blue",
x=5,
class="spiky_box_2x1_11",
y=10
y=10,
adhesion="sticky",
color="blue",
class="box_2x1_11"
},
{
color="blue",
x=2,
y=9,
class="box_2x2_11-10",
y=9
color="blue"
}
}
}

View file

@ -1,370 +1,383 @@
{
objects={
{
class="door",
color="blue",
x=6,
y=1
y=1,
color="blue",
class="door"
},
{
class="static_wall",
color="blue",
x=1,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=2,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=3,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=4,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=5,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=7,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=8,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=9,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=10,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=11,
y=1
y=1,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=1,
y=11
y=11,
color="blue",
class="static_wall"
},
{
class="static_wall",
color="blue",
x=11,
y=11
y=11,
color="blue",
class="static_wall"
},
{
class="spiky_box_2x1_11",
color="blue",
x=1,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="blue"
},
{
class="box",
color="blue",
x=3,
y=6
y=6,
color="blue",
class="box"
},
{
class="box_1x2_1-1",
color="blue",
x=4,
y=6
y=6,
color="blue",
class="box_1x2_1-1"
},
{
class="spiky_box_1x2_1-1",
color="blue",
x=5,
y=6
y=6,
adhesion="sticky",
class="box_1x2_1-1",
color="blue"
},
{
class="box_2x2_11-10",
color="blue",
x=6,
y=5
y=5,
color="blue",
class="box_2x2_11-10"
},
{
class="spiky_box_2x1_11",
color="blue",
x=7,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="blue"
},
{
class="spiky_box_2x1_11",
color="blue",
x=9,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="blue"
},
{
class="door",
color="red",
x=6,
y=1
y=1,
color="red",
class="door"
},
{
class="static_wall",
color="red",
x=1,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=2,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=3,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=4,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=5,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=7,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=8,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=9,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=10,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=11,
y=1
y=1,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=1,
y=11
y=11,
color="red",
class="static_wall"
},
{
class="static_wall",
color="red",
x=11,
y=11
y=11,
color="red",
class="static_wall"
},
{
class="spiky_box_2x1_11",
color="red",
x=1,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="red"
},
{
class="box",
color="red",
x=3,
y=6
y=6,
color="red",
class="box"
},
{
class="box_1x2_1-1",
color="red",
x=4,
y=6
y=6,
color="red",
class="box_1x2_1-1"
},
{
class="spiky_box_1x2_1-1",
color="red",
x=5,
y=6
y=6,
adhesion="sticky",
class="box_1x2_1-1",
color="red"
},
{
class="box_2x2_11-10",
color="red",
x=6,
y=5
y=5,
color="red",
class="box_2x2_11-10"
},
{
class="spiky_box_2x1_11",
color="red",
x=7,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="red"
},
{
class="spiky_box_2x1_11",
color="red",
x=9,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="red"
},
{
class="door",
color="green",
x=6,
y=1
y=1,
color="green",
class="door"
},
{
class="static_wall",
color="green",
x=1,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=2,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=3,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=4,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=5,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=7,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=8,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=9,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=10,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=11,
y=1
y=1,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=1,
y=11
y=11,
color="green",
class="static_wall"
},
{
class="static_wall",
color="green",
x=11,
y=11
y=11,
color="green",
class="static_wall"
},
{
class="spiky_box_2x1_11",
color="green",
x=3,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="green"
},
{
class="spiky_box_2x1_11",
color="green",
x=1,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="green"
},
{
class="box",
color="green",
x=5,
y=6
y=6,
color="green",
class="box"
},
{
class="box_1x2_1-1",
color="green",
x=6,
y=6
y=6,
color="green",
class="box_1x2_1-1"
},
{
class="spiky_box_1x2_1-1",
color="green",
x=7,
y=6
y=6,
adhesion="sticky",
class="box_1x2_1-1",
color="green"
},
{
class="box_2x2_11-10",
color="green",
x=8,
y=5
y=5,
color="green",
class="box_2x2_11-10"
},
{
class="spiky_box_2x1_11",
color="green",
x=9,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="green"
},
{
class="spiky_box_2x1_11",
color="green",
x=11,
y=6
y=6,
adhesion="sticky",
class="box_2x1_11",
color="green"
}
}
}

View file

@ -3,585 +3,588 @@ wires={
},
objects={
{
color="red",
x=1,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=2,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=3,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=4,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=5,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=6,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=7,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=8,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=9,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=10,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=11,
y=1,
class="static_wall",
y=1
color="red"
},
{
color="red",
x=11,
y=2,
class="static_wall",
y=2
color="red"
},
{
color="red",
x=11,
y=3,
class="static_wall",
y=3
color="red"
},
{
color="red",
x=11,
y=4,
class="static_wall",
y=4
color="red"
},
{
color="red",
x=11,
y=5,
class="static_wall",
y=5
color="red"
},
{
color="red",
x=11,
y=6,
class="static_wall",
y=6
color="red"
},
{
color="red",
x=11,
y=7,
class="static_wall",
y=7
color="red"
},
{
color="red",
x=11,
y=8,
class="static_wall",
y=8
color="red"
},
{
color="red",
x=11,
y=9,
class="static_wall",
y=9
color="red"
},
{
color="red",
x=11,
y=10,
class="static_wall",
y=10
color="red"
},
{
color="red",
x=1,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=2,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=3,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=5,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=6,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=7,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=8,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=9,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=10,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=11,
y=11,
class="static_wall",
y=11
color="red"
},
{
color="red",
x=8,
y=4,
class="box",
y=4
color="red"
},
{
color="red",
x=7,
class="spiky_box_2x1_11",
y=3
y=3,
adhesion="sticky",
color="red",
class="box_2x1_11"
},
{
color="red",
x=6,
class="spiky_box_2x1_11",
y=4
y=4,
adhesion="sticky",
color="red",
class="box_2x1_11"
},
{
color="red",
x=4,
class="spiky_box_2x1_11",
y=4
y=4,
adhesion="sticky",
color="red",
class="box_2x1_11"
},
{
color="red",
x=3,
y=3,
class="box_2x2_11-10",
y=3
color="red"
},
{
color="green",
x=1,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=2,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=3,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=4,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=5,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=6,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=7,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=8,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=9,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=10,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=11,
y=1,
class="static_wall",
y=1
color="green"
},
{
color="green",
x=11,
y=2,
class="static_wall",
y=2
color="green"
},
{
color="green",
x=11,
y=3,
class="static_wall",
y=3
color="green"
},
{
color="green",
x=11,
y=4,
class="static_wall",
y=4
color="green"
},
{
color="green",
x=11,
y=5,
class="static_wall",
y=5
color="green"
},
{
color="green",
x=11,
y=6,
class="static_wall",
y=6
color="green"
},
{
color="green",
x=11,
y=7,
class="static_wall",
y=7
color="green"
},
{
color="green",
x=11,
y=8,
class="static_wall",
y=8
color="green"
},
{
color="green",
x=11,
y=9,
class="static_wall",
y=9
color="green"
},
{
color="green",
x=11,
y=10,
class="static_wall",
y=10
color="green"
},
{
color="green",
x=1,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=2,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=3,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=5,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=6,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=7,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=8,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=9,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=10,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=11,
y=11,
class="static_wall",
y=11
color="green"
},
{
color="green",
x=4,
y=3,
class="box",
y=3
color="green"
},
{
color="blue",
x=1,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=2,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=3,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=4,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=5,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=6,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=7,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=8,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=9,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=10,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=11,
y=1,
class="static_wall",
y=1
color="blue"
},
{
color="blue",
x=11,
y=2,
class="static_wall",
y=2
color="blue"
},
{
color="blue",
x=11,
y=3,
class="static_wall",
y=3
color="blue"
},
{
color="blue",
x=11,
y=4,
class="static_wall",
y=4
color="blue"
},
{
color="blue",
x=11,
y=5,
class="static_wall",
y=5
color="blue"
},
{
color="blue",
x=11,
y=6,
class="static_wall",
y=6
color="blue"
},
{
color="blue",
x=11,
y=7,
class="static_wall",
y=7
color="blue"
},
{
color="blue",
x=11,
y=8,
class="static_wall",
y=8
color="blue"
},
{
color="blue",
x=11,
y=9,
class="static_wall",
y=9
color="blue"
},
{
color="blue",
x=11,
y=10,
class="static_wall",
y=10
color="blue"
},
{
color="blue",
x=1,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=2,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=3,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=5,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=6,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=7,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=8,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=9,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=10,
y=11,
class="static_wall",
y=11
color="blue"
},
{
color="blue",
x=11,
y=11,
class="static_wall",
y=11
color="blue"
},
{
x=5,
class="npc",
y=2
y=2,
class="npc"
}
}
}

View file

@ -183,6 +183,64 @@ function drawGoopySprite(sprite, x, y, scale)
love.graphics.setShader(previousShader)
end
-- Gluey blocks draw teeth on every *true* edge of their collision shape: a cell
-- edge only gets spikes when no sibling cell sits on the other side of it, so an
-- L/notched shape spikes its real contour rather than its bounding box.
local glueyEdgeDirections = {
{x = 1, y = 0}, {x = -1, y = 0},
{x = 0, y = 1}, {x = 0, y = -1},
}
local function shapeHasCell(shape, x, y)
for _, cell in ipairs(shape) do
if cell.x == x and cell.y == y then return true end
end
return false
end
-- Draw two teeth on every exposed cell edge. The art remains synthetic: the
-- underlying box sprite and its collision shape are still the authored base.
function drawSpikes(cellShape, x, y, scale)
cellShape = cellShape or {{x = 1, y = 1}}
local cellSize = 16 * scale
local inset = scale
local reach = 3 * scale
local halfBase = 1.5 * scale
for _, cell in ipairs(cellShape) do
local left = x + (cell.x - 1) * cellSize
local top = y + (cell.y - 1) * cellSize
for _, direction in ipairs(glueyEdgeDirections) do
if not shapeHasCell(cellShape, cell.x + direction.x, cell.y + direction.y) then
for _, along in ipairs({5, 11}) do
if direction.x ~= 0 then
local edgeX = direction.x > 0 and left + cellSize - inset or left + inset
local centerY = top + along * scale
love.graphics.polygon("fill",
edgeX, centerY - halfBase,
edgeX + direction.x * reach, centerY,
edgeX, centerY + halfBase
)
else
local edgeY = direction.y > 0 and top + cellSize - inset or top + inset
local centerX = left + along * scale
love.graphics.polygon("fill",
centerX - halfBase, edgeY,
centerX, edgeY + direction.y * reach,
centerX + halfBase, edgeY
)
end
end
end
end
end
end
function drawGlueySprite(sprite, cellShape, x, y, scale)
drawSpikes(cellShape, x, y, scale)
love.graphics.draw(sprite, x, y, 0, scale, scale)
end
-- Accessibility post-process. The puzzle's RGB channels are normally also
-- its visual language, so this runs only after the entire scene has been
-- composed. It leaves neutral UI/text alone and remaps saturated colours to

View file

@ -29,19 +29,16 @@ local function assertNotIn(group, entity, message)
if group[entity] then error(message or "expected entity outside movement group", 2) end
end
local function movable(x, y, sticky, shape, skewer, spiky)
local function movable(x, y, adhesion, shape)
local entity = {
x = x, y = y,
sticky = sticky or false,
skewer = skewer or false,
spiky = spiky or false,
adhesion = adhesion,
shape = shape or {{x = 0, y = 0}},
}
function entity:canMove() return true end
function entity:canPassOver() return false end
function entity:isSticky() return self.sticky end
function entity:isSkewer() return self.skewer end
function entity:isSpiky() return self.spiky end
function entity:isSticky() return self.adhesion == "sticky" end
function entity:isGluey() return self.adhesion == "gluey" end
function entity:bump() end
function entity:move(axis, amount) self[axis] = self[axis] + amount end
function entity:getGridPos() return {x = self.x, y = self.y} end
@ -94,7 +91,7 @@ end)
test("a trailing sticky block joins a horizontal pull", function()
-- sticky, regular, player; moving right
local sticky = movable(2, 3, true)
local sticky = movable(2, 3, "sticky")
local regular = movable(3, 3)
local possible, group = plan({sticky, regular}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
@ -105,7 +102,7 @@ end)
test("a moving sticky block pulls a regular block behind it", function()
-- regular, sticky, player; moving right
local regular = movable(2, 3)
local sticky = movable(3, 3, true)
local sticky = movable(3, 3, "sticky")
local possible, group = plan({regular, sticky}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, sticky)
@ -114,7 +111,7 @@ end)
test("sticky pull chains work vertically", function()
-- player moves down, pulling the two blocks above it
local sticky = movable(4, 2, true)
local sticky = movable(4, 2, "sticky")
local regular = movable(4, 3)
local possible, group = plan({sticky, regular}, {x = 4, y = 3}, {x = 0, y = 1})
assertTrue(possible)
@ -129,7 +126,7 @@ test("sticky pulls are symmetric in all four directions", function()
}) do
local player = {x = 4, y = 4}
local regular = movable(player.x - direction.x, player.y - direction.y)
local sticky = movable(player.x - direction.x * 2, player.y - direction.y * 2, true)
local sticky = movable(player.x - direction.x * 2, player.y - direction.y * 2, "sticky")
local possible, group = plan(
{sticky, regular},
{x = regular.x, y = regular.y},
@ -141,19 +138,20 @@ test("sticky pulls are symmetric in all four directions", function()
end
end)
test("a sticky block shears off during orthogonal movement", function()
-- The regular block moves right; the sticky block above it stays behind.
test("a sticky block grabs a free lateral block during orthogonal movement", function()
-- The regular block moves right; the sticky block above it is soft-grabbed on
-- that lateral face (sticky pulls on every edge) and follows along.
local regular = movable(3, 4)
local stickyBranch = movable(3, 3, true)
local stickyBranch = movable(3, 3, "sticky")
local possible, group = plan({regular, stickyBranch}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertNotIn(group, stickyBranch)
assertIn(group, stickyBranch)
end)
test("a blocked sheared sticky block does not stop movement", function()
test("a blocked sticky lateral grab does not stop movement", function()
local regular = movable(3, 4)
local stickyBranch = movable(3, 3, true)
local stickyBranch = movable(3, 3, "sticky")
local blockingWall = wall(4, 3)
local possible, group = plan({regular, stickyBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
@ -161,49 +159,49 @@ test("a blocked sheared sticky block does not stop movement", function()
assertNotIn(group, stickyBranch)
end)
test("a skewer carries an orthogonal branch", function()
test("a gluey block welds an orthogonal branch", function()
local regular = movable(3, 4)
local skewerBranch = movable(3, 3, false, nil, true)
local possible, group = plan({regular, skewerBranch}, {x = 3, y = 4}, {x = 1, y = 0})
local glueyBranch = movable(3, 3, "gluey")
local possible, group = plan({regular, glueyBranch}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertIn(group, skewerBranch)
assertIn(group, glueyBranch)
end)
test("a blocked skewer branch stops its rigid movement group", function()
test("a blocked gluey branch stops its rigid movement group", function()
local regular = movable(3, 4)
local skewerBranch = movable(3, 3, false, nil, true)
local glueyBranch = movable(3, 3, "gluey")
local blockingWall = wall(4, 3)
local possible = plan({regular, skewerBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0})
local possible = plan({regular, glueyBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0})
assertFalse(possible)
end)
test("a spiky block pulls both orthogonal side blocks", function()
local spiky = movable(4, 4, false, nil, false, true)
test("a sticky block pulls both orthogonal side blocks", function()
local sticky = movable(4, 4, "sticky")
local upper = movable(4, 3)
local lower = movable(4, 5)
local possible, group = plan(
{spiky, upper, lower}, {x = 4, y = 4}, {x = 1, y = 0}
{sticky, upper, lower}, {x = 4, y = 4}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spiky)
assertIn(group, sticky)
assertIn(group, upper)
assertIn(group, lower)
end)
test("a spiky block releases one blocked side while pulling the other", function()
local spiky = movable(4, 4, false, nil, false, true)
test("a sticky block releases one blocked side while pulling the other", function()
local sticky = movable(4, 4, "sticky")
local blockedUpper = movable(4, 3)
local freeLower = movable(4, 5)
local blockingWall = wall(5, 3)
local possible, group = plan(
{spiky, blockedUpper, freeLower, blockingWall},
{sticky, blockedUpper, freeLower, blockingWall},
{x = 4, y = 4}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spiky)
assertIn(group, sticky)
assertNotIn(group, blockedUpper)
assertIn(group, freeLower)
end)
@ -219,16 +217,16 @@ end)
test("diagonal blocks do not stick", function()
local regular = movable(3, 3)
local diagonalSticky = movable(2, 2, true)
local diagonalSticky = movable(2, 2, "sticky")
local possible, group = plan({regular, diagonalSticky}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertNotIn(group, diagonalSticky)
end)
test("multi-cell sticky blocks pull from every trailing occupied cell", function()
local tallRegular = movable(3, 3, false, {{x = 0, y = 0}, {x = 0, y = 1}})
local sticky = movable(2, 4, true)
test("multi-cell sticky blocks pull from every occupied cell", function()
local tallRegular = movable(3, 3, nil, {{x = 0, y = 0}, {x = 0, y = 1}})
local sticky = movable(2, 4, "sticky")
local possible, group = plan({tallRegular, sticky}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, tallRegular)
@ -245,7 +243,7 @@ test("an L-shaped sticky pulls from its trailing face in every direction", funct
}
for _, case in ipairs(cases) do
local stickyL = movable(3, 3, true, lShape)
local stickyL = movable(3, 3, "sticky", lShape)
local trailing = movable(case.trailing.x, case.trailing.y)
local possible, group = plan(
{stickyL, trailing}, {x = 3, y = 3}, case.direction
@ -259,10 +257,10 @@ end)
test("an L-shaped sticky can release one blocked pull while completing another", function()
-- Moving right, the two exposed trailing cells touch separate objects. The
-- upper object is pinned against a wall; the lower one is free to follow.
local stickyL = movable(3, 3, true, {
local stickyL = movable(3, 3, "sticky", {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
})
local pinned = movable(2, 2, false, {{x = 0, y = 0}, {x = 0, y = 1}})
local pinned = movable(2, 2, nil, {{x = 0, y = 0}, {x = 0, y = 1}})
local free = movable(2, 4)
local blockingWall = wall(3, 2)
local possible, group = plan(
@ -278,7 +276,7 @@ end)
test("a T-shaped sticky pulls independently across a notched trailing face", function()
-- Moving up, three exposed lower faces pull into cells vacated by the T.
local stickyT = movable(3, 3, true, {
local stickyT = movable(3, 3, "sticky", {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 2, y = 0},
{x = 1, y = 1},
})
@ -297,54 +295,46 @@ test("a T-shaped sticky pulls independently across a notched trailing face", fun
assertIn(group, trailingStem)
end)
test("an L-shaped spiky block grabs moveables across several exposed faces", function()
local spikyL = movable(3, 3, false, {
test("an L-shaped sticky block grabs moveables across several exposed faces", function()
local stickyL = movable(3, 3, "sticky", {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
}, false, true)
})
local above = movable(3, 2)
local below = movable(3, 5)
local behind = movable(2, 3)
local possible, group = plan(
{spikyL, above, below, behind},
{stickyL, above, below, behind},
{x = 3, y = 3}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spikyL)
assertIn(group, stickyL)
assertIn(group, above)
assertIn(group, below)
assertIn(group, behind)
end)
test("production sticky, skewer, and spiky blocks advertise distinct behavior", function()
test("production moveables advertise adhesion (regular, sticky, gluey)", function()
class = require "libs/middleclass"
Color = class("TestColor")
function Color:initialize(name) self.name = name end
function Color:getname() return self.name end
require "generic_moveable"
require "generic_sticky"
require "generic_skewer"
require "generic_spiky"
local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end}
local regular = GenericMoveable:new(2, 2, "red", sprite, "box")
local sticky = GenericSticky:new(3, 2, "red", sprite, "sticky_box")
local skewer = GenericSkewer:new(4, 2, "red", sprite, "skewer_box")
local spiky = GenericSpiky:new(5, 2, "red", sprite, "spiky_box")
local sticky = GenericMoveable:new(3, 2, "red", sprite, "box", nil, "sticky")
local gluey = GenericMoveable:new(4, 2, "red", sprite, "box", nil, "gluey")
assertTrue(regular:canMove())
assertFalse(regular:isSticky())
assertFalse(regular:isGluey())
assertTrue(sticky:canMove())
assertTrue(sticky:isSticky())
assertFalse(sticky:isSkewer())
assertTrue(skewer:canMove())
assertFalse(skewer:isSticky())
assertTrue(skewer:isSkewer())
assertFalse(skewer:isSpiky())
assertTrue(spiky:canMove())
assertFalse(spiky:isSticky())
assertFalse(spiky:isSkewer())
assertTrue(spiky:isSpiky())
assertFalse(sticky:isGluey())
assertTrue(gluey:canMove())
assertFalse(gluey:isSticky())
assertTrue(gluey:isGluey())
end)
local function loadRoomClass()
@ -442,7 +432,7 @@ end)
test("Room applies the sticky-regular-player pull", function()
loadRoomClass()
local sticky = movable(2, 3, true)
local sticky = movable(2, 3, "sticky")
local regular = movable(3, 3)
local player = movable(4, 3)
local room = roomWith(player, {sticky, regular})
@ -462,8 +452,8 @@ test("Room pulls sticky out of a pinned concave moveable", function()
-- mm_ mm_
local player = movable(1, 2)
local wall = wall(2, 2)
local sticky = movable(1, 3, true)
local concave = movable(1, 3, false, {
local sticky = movable(1, 3, "sticky")
local concave = movable(1, 3, nil, {
{x = 1, y = 0},
{x = 0, y = 1}, {x = 1, y = 1},
})
@ -480,7 +470,7 @@ end)
test("Room moves an L-shaped sticky and both available trailing pulls", function()
loadRoomClass()
local player = movable(3, 2)
local stickyL = movable(3, 3, true, {
local stickyL = movable(3, 3, "sticky", {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
})
local trailingLeft = movable(3, 5)
@ -495,27 +485,13 @@ test("Room moves an L-shaped sticky and both available trailing pulls", function
assertTrue(trailingRight.x == 4 and trailingRight.y == 3)
end)
test("Room shears a lateral sticky contact", function()
loadRoomClass()
local sticky = movable(3, 2, true)
local regular = movable(3, 3)
local player = movable(4, 3)
local room = roomWith(player, {sticky, regular})
room:movePlayer("right")
assertTrue(sticky.x == 3, "lateral sticky block should shear and stay behind")
assertTrue(regular.x == 4)
assertTrue(player.x == 5)
end)
test("Room never moves a component found by push and pull twice", function()
loadRoomClass()
local player = movable(4, 4)
local front = movable(5, 4)
local bridgeRight = movable(5, 3, false, nil, true)
local bridgeMiddle = movable(4, 3, false, nil, true)
local bridgeLeft = movable(3, 3, false, nil, true)
local bridgeRight = movable(5, 3, "gluey")
local bridgeMiddle = movable(4, 3, "gluey")
local bridgeLeft = movable(3, 3, "gluey")
local rear = movable(3, 4)
local component = {front, bridgeRight, bridgeMiddle, bridgeLeft, rear}
local room = roomWith(player, component)
@ -533,74 +509,53 @@ test("Room never moves a component found by push and pull twice", function()
assertTrue(player.x == 5)
end)
test("Room pushes spiky onward while a blocked side shears off", function()
test("Room pushes sticky onward while a blocked side shears off", function()
loadRoomClass()
local player = movable(3, 4)
local spiky = movable(4, 4, false, nil, false, true)
local sticky = movable(4, 4, "sticky")
local blockedUpper = movable(4, 3)
local freeLower = movable(4, 5)
local blockingWall = wall(5, 3)
local room = roomWith(player, {spiky, blockedUpper, freeLower, blockingWall})
local room = roomWith(player, {sticky, blockedUpper, freeLower, blockingWall})
room:movePlayer("right")
assertTrue(player.x == 4 and player.y == 4)
assertTrue(spiky.x == 5 and spiky.y == 4)
assertTrue(sticky.x == 5 and sticky.y == 4)
assertTrue(blockedUpper.x == 4 and blockedUpper.y == 3)
assertTrue(freeLower.x == 5 and freeLower.y == 5)
assertTrue(blockingWall.x == 5 and blockingWall.y == 3)
end)
test("every moveable base box gets all three synthetic behavior variants", function()
local BoxVariants = require "box_variants"
local boxSprites = {red = "box_r.png"}
local boxCells = {red = {{x = 1, y = 1}}}
local shapeSprites = {blue = "box_shape_b.png"}
local shapeCells = {blue = {{x = 1, y = 1}, {x = 2, y = 1}}}
local lSprites = {green = "box_l_g.png"}
local lCells = {green = {{x = 1, y = 1}, {x = 1, y = 2}, {x = 2, y = 2}}}
local assets = {
box = {class = "moveable", sprites = boxSprites, cells = boxCells},
["box_2x1_11"] = {class = "moveable", sprites = shapeSprites, cells = shapeCells},
["box_2x2_10-11"] = {class = "moveable", sprites = lSprites, cells = lCells},
box_statue = {class = "immoveable", sprites = {}},
beer_mug = {class = "moveable", sprites = {}},
test("legacy box-variant classes load as adhesion on a plain moveable", function()
loadRoomClass()
local oldAssets, oldLoadSprite = globalAssetProperties, loadSprite
local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end}
globalAssetProperties = {
box = { class = "moveable", sprites = {red = "box_r.png"}, cells = {red = {{x = 1, y = 1}}} },
}
loadSprite = function() return sprite end
local room = setmetatable({
collideables = {red = {}, green = {}, blue = {}},
playerBlockers = {red = {}, green = {}, blue = {}},
}, {__index = Room})
BoxVariants.addTo(assets)
-- Old .sav files baked adhesion into the class name; loading must collapse them
-- onto the base box carrying the new adhesion property.
local fromSpiky = room:createEntity("spiky_box", "red", 2, 2)
local fromSkewer = room:createEntity("skewer_box", "red", 3, 2)
local fromSticky = room:createEntity("sticky_box", "red", 4, 2)
assertTrue(assets.sticky_box.class == "sticky")
assertTrue(assets.sticky_box.directory == "sticky")
assertTrue(assets.sticky_box.synthetic)
assertTrue(assets.sticky_box.goopy)
assertTrue(assets.sticky_box.sprites == boxSprites, "variant should share base sprites")
assertTrue(assets.sticky_box.cells == boxCells, "variant should share collision cells")
assertTrue(assets["sticky_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["sticky_box_2x1_11"].cells == shapeCells)
assertTrue(assets["sticky_box_2x2_10-11"].sprites == lSprites)
assertTrue(assets["sticky_box_2x2_10-11"].cells == lCells)
assertTrue(assets.skewer_box.class == "skewer")
assertTrue(assets.skewer_box.directory == "skewer")
assertTrue(assets.skewer_box.synthetic)
assertTrue(assets.skewer_box.skewer)
assertTrue(assets.skewer_box.sprites == boxSprites)
assertTrue(assets.skewer_box.cells == boxCells)
assertTrue(assets["skewer_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["skewer_box_2x2_10-11"].cells == lCells)
assertTrue(assets.spiky_box.class == "spiky")
assertTrue(assets.spiky_box.directory == "spiky")
assertTrue(assets.spiky_box.synthetic)
assertTrue(assets.spiky_box.spiky)
assertTrue(assets.spiky_box.sprites == boxSprites)
assertTrue(assets.spiky_box.cells == boxCells)
assertTrue(assets["spiky_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["spiky_box_2x2_10-11"].cells == lCells)
assertFalse(assets.sticky_box_statue)
assertFalse(assets.sticky_beer_mug)
assertFalse(assets.skewer_box_statue)
assertFalse(assets.skewer_beer_mug)
assertFalse(assets.spiky_box_statue)
assertFalse(assets.spiky_beer_mug)
assertTrue(fromSpiky:isSticky(), "legacy spiky_ should load as sticky")
assertTrue(fromSkewer:isGluey(), "legacy skewer_ should load as gluey")
assertTrue(fromSticky:isSticky(), "legacy sticky_ should load as sticky")
assertTrue(fromSpiky.saveClass == "box", "class should normalize to the base box")
-- An explicit adhesion in the object data wins and rides through on a plain box.
local placed = room:createEntity("box", "red", 5, 2, { adhesion = "gluey" })
assertTrue(placed:isGluey())
assertTrue(placed.saveClass == "box")
globalAssetProperties, loadSprite = oldAssets, oldLoadSprite
end)
print("movement/block-variant tests passed: " .. passed)
print("movement tests passed: " .. passed)

View file

@ -0,0 +1,60 @@
-- One-off migration: rewrite rooms/*.sav from the old baked-in adhesion classes
-- (spiky_/skewer_/sticky_<box>) to the new model of a plain base class carrying
-- an `adhesion` property. spiky and old-sticky both become the new soft "sticky";
-- skewer becomes the rigid "gluey". Run once from the project root: lua tools/backfill_adhesion.lua
loadstring = loadstring or load -- TSerial.unpack still calls loadstring (removed in Lua 5.4+)
package.path = "./?.lua;" .. package.path
require "libs/TSerial"
local dir = "rooms"
local adhesionForPrefix = { spiky = "sticky", skewer = "gluey", sticky = "sticky" }
local function savFiles()
local files = {}
local pipe = io.popen('ls "' .. dir .. '"')
for name in pipe:lines() do
if name:match("%.sav$") then table.insert(files, name) end
end
pipe:close()
return files
end
local function readFile(path)
local handle = assert(io.open(path, "r"))
local contents = handle:read("*a")
handle:close()
return contents
end
local function writeFile(path, contents)
local handle = assert(io.open(path, "w"))
handle:write(contents)
handle:close()
end
local totalFiles, totalObjects = 0, 0
for _, file in ipairs(savFiles()) do
local path = dir .. "/" .. file
local saveTable = TSerial.unpack(readFile(path))
local migrated = 0
for _, object in ipairs((saveTable and saveTable.objects) or {}) do
if type(object.class) == "string" then
for prefix, adhesion in pairs(adhesionForPrefix) do
local base = object.class:match("^" .. prefix .. "_(.+)")
if base then
object.class = base
if not object.adhesion then object.adhesion = adhesion end
migrated = migrated + 1
break
end
end
end
end
if migrated > 0 then
writeFile(path, TSerial.pack(saveTable, nil, true))
totalFiles = totalFiles + 1
totalObjects = totalObjects + migrated
print(string.format("%-28s migrated %d object(s)", file, migrated))
end
end
print(string.format("done: %d object(s) across %d file(s)", totalObjects, totalFiles))

View file

@ -637,8 +637,7 @@ function World:movementContext(room, color)
function proxy:canMove() return self.entity:canMove() end
function proxy:canPassOver() return self.entity:canPassOver() end
function proxy:isSticky() return self.entity:isSticky() end
function proxy:isSkewer() return self.entity:isSkewer() end
function proxy:isSpiky() return self.entity:isSpiky() end
function proxy:isGluey() return self.entity:isGluey() end
function proxy:bump() return self.entity:bump() end
function proxy:getOccupiedCells(offset)
offset = offset or {x = 0, y = 0}
@ -1110,14 +1109,13 @@ function World:drawRoomPreview(name, x, y, size, roomData)
if path then
love.graphics.setColor(gColor[color]:set())
local sprite = loadSprite(path)
if props.goopy and drawGoopySprite then
drawGoopySprite(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
elseif props.spiky and drawSpikySprite then
drawSpikySprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
local dx, dy = x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale
if object.adhesion == "gluey" and drawGlueySprite then
drawGlueySprite(sprite, props.cells and props.cells[color], dx, dy, scale)
elseif object.adhesion == "sticky" and drawGoopySprite then
drawGoopySprite(sprite, dx, dy, scale)
else
love.graphics.draw(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
love.graphics.draw(sprite, dx, dy, 0, scale, scale)
end
end
end