Add directional sticky and skewer box variants

This commit is contained in:
Your Name 2026-08-11 15:07:11 -04:00
parent c3f5aafd77
commit f549eaf523
13 changed files with 723 additions and 101 deletions

View file

@ -4,12 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
CHROMA SOLSTICE — a grid-based color-puzzle game written in Lua for the [LÖVE](https://love2d.org) framework. There is no build step, no test suite, and no package manifest; the whole game is the tree of `.lua` files loaded by `main.lua`.
CHROMA SOLSTICE — a grid-based color-puzzle game written in Lua for the [LÖVE](https://love2d.org) framework. There is no build step or package manifest; the whole game is the tree of `.lua` files loaded by `main.lua`.
## Running
```sh
love . # run from the project root
lua tests/test_movement.lua # run the pure matrix-movement tests
```
`love` is not installed in this environment — you cannot run the game here; reason about the code statically.

36
box_variants.lua Normal file
View file

@ -0,0 +1,36 @@
local BoxVariants = {}
-- Add directional sticky-puller and all-edge skewer variants for every authored
-- moveable box. Both share sprite paths and collision cells 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")
end
return assets
end
return BoxVariants

View file

@ -14,6 +14,8 @@ 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.sprite = repSprite
self.shaAmount = t.sha or 1
-- onBump is a method (see Entity:onBump) so mixins like Dialogable can
@ -134,6 +136,14 @@ function Entity:canMove()
return false
end
function Entity:isSticky()
return self.sticky
end
function Entity:isSkewer()
return self.skewer
end
function Entity:canPassOver()
if self:getCollision() == "none" then
return true

89
generic_skewer.lua Normal file
View file

@ -0,0 +1,89 @@
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 drawSkewerSprite(sprite, 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
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

42
generic_sticky.lua Normal file
View file

@ -0,0 +1,42 @@
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

@ -233,6 +233,9 @@ 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,
cells = gameAssets[asset].cells,
name = "asset",
text = asset,
x = 30,
@ -529,13 +532,18 @@ 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,
cells = gameAssets[asset].cells,
name = "asset",
text = asset,
x = 30,
y = 0,
onClic = {
left = function() selectAsset(asset) end,
right = function() editAsset(asset) end,
right = function()
if not gameAssets[asset].synthetic then editAsset(asset) end
end,
}
})
end
@ -592,6 +600,9 @@ function editAsset(asset)
local assetButton = createUIElement{
name = asset,
sprites = assetSprites,
goopy = assetProperties.goopy,
skewer = assetProperties.skewer,
cells = assetProperties.cells,
x = sb.w / 2 - 18,
y = 80,
}

View file

@ -9,6 +9,9 @@ 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.cells = t.cells
if not t.onClic then
t.onClic = {}
@ -99,7 +102,13 @@ function GUI:draw()
if self.sprites then
for color, sprite in pairs(self.sprites) do
love.graphics.setColor(gColor[color]:set())
love.graphics.draw(sprite, box.x, box.y, 0, self.scale, self.scale)
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)
else
love.graphics.draw(sprite, box.x, box.y, 0, self.scale, self.scale)
end
end
end

View file

@ -1,5 +1,7 @@
require "libs/TSerial"
local BoxVariants = require "box_variants"
local targetFolder = "art"
local reanalyzeCells = true
@ -57,5 +59,5 @@ function getAllAssets()
end
end
return globalAssetProperties
return BoxVariants.addTo(globalAssetProperties)
end

83
movement.lua Normal file
View file

@ -0,0 +1,83 @@
-- 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 behind a directional sticky puller, or touches a skewer block.
local Movement = {}
local adjacentDirections = {
{x = 1, y = 0},
{x = -1, y = 0},
{x = 0, y = 1},
{x = 0, y = -1},
}
local function isSticky(entity)
return entity.isSticky and entity:isSticky()
end
local function isPassable(entity)
return entity.canPassOver and entity:canPassOver()
end
local function isSkewer(entity)
return entity.isSkewer and entity:isSkewer()
end
function Movement.findMovableGroup(startCells, direction, entityAt, isInBounds)
local entities = {}
local pendingCells = {}
for _, cell in ipairs(startCells) do
table.insert(pendingCells, cell)
end
local nextCell = 1
while nextCell <= #pendingCells do
local cell = pendingCells[nextCell]
nextCell = nextCell + 1
if not isInBounds(cell) then
return false, entities
end
local entity = entityAt(cell)
if entity and not isPassable(entity) and not entities[entity] then
if not entity:canMove() then
return false, entities
end
entities[entity] = entity
-- Anything occupying this block's destination must be pushed too.
for _, projectedCell in ipairs(entity:getOccupiedCells(direction)) do
table.insert(pendingCells, projectedCell)
end
-- Sticky blocks pull only from the trailing face, like the player. A
-- lateral move therefore shears the contact. Skewers retain the older
-- all-edge behaviour and make everything they touch one rigid group.
for _, occupiedCell in ipairs(entity:getOccupiedCells()) do
for _, adjacentDirection in ipairs(adjacentDirections) do
local adjacentCell = {
x = occupiedCell.x + adjacentDirection.x,
y = occupiedCell.y + adjacentDirection.y,
}
if isInBounds(adjacentCell) then
local adjacentEntity = entityAt(adjacentCell)
local trailing = adjacentDirection.x == -direction.x and
adjacentDirection.y == -direction.y
local attached = adjacentEntity and (
isSkewer(entity) or isSkewer(adjacentEntity) or
(trailing and (isSticky(entity) or isSticky(adjacentEntity)))
)
if attached and adjacentEntity ~= entity and adjacentEntity:canMove() then
table.insert(pendingCells, adjacentCell)
end
end
end
end
end
end
return true, entities
end
return Movement

124
room.lua
View file

@ -10,8 +10,12 @@ require "switch"
require "door"
require "dialogue_manager"
require "generic_moveable"
require "generic_sticky"
require "generic_skewer"
require "generic_unmoveable"
local Movement = require "movement"
function Room:initialize(t)
self.player = {}
self.switches = { red = {}, green = {}, blue = {} }
@ -202,7 +206,7 @@ function Room:movePlayer(key)
end
local move = true
-- PUSHHHHHHHHHHHHHHHHHHHHHH---------------------
-- PUSH ---------------------------------------------------------------
local initialTargetCell = {
x = pos.x + dir.x, y = pos.y + dir.y
@ -219,104 +223,24 @@ function Room:movePlayer(key)
if not self:isInBounds(initialTargetCell) then
-- let's us move out of bounds to Change Rooms without pulling a bloc_
move = true
elseif initialTargetEntity and initialTargetEntity:canPassOver() then
--we should put this in the else statement actually
move = true
else
local targetCells
targetCells = {initialTargetCell} -- ...we start with our attempted movement...
local doneSearching
while not doneSearching do
nextTargetCells = {} -- ...then we create this array to save our next group of cells
local emptyCells = {}
for _, cell in pairs(targetCells) do
if self:isInBounds(cell) then
local entity = self:getCellInMatrix(self.collidableMatrices[color], cell)
if entity then
--something is here!!
if not entitiesToPush[entity] then --if the entity hasnt been found already
if entity:canMove() then
local projectedCells = entity:getOccupiedCells(dir) -- we project the movement here
entitiesToPush[entity] = entity
for _, projectedCell in ipairs(projectedCells) do
table.insert(nextTargetCells, projectedCell)
end
else
move = false
doneSearching = true
end
end
else
--there's nothin there!!
table.insert(emptyCells, cell)
end
else
--we are out of bounds now
move = false
doneSearching = true
end
end
--if every projected cell is empty then we can do the move
if #emptyCells == #targetCells then
move = true
doneSearching = true
end
targetCells = nextTargetCells
end
--let's get out of this loop b/c if we can't move one color we can't move at all
move, entitiesToPush = Movement.findMovableGroup(
{initialTargetCell}, dir,
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
function(cell) return self:isInBounds(cell) end
)
end
---PULL CHEC
-- PULL ---------------------------------------------------------------
local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y}
local targetCells
local pull
--for each color...
targetCells = {initialTargetCell} -- ...we start with our attempted movement...
local doneSearching
while not doneSearching do
nextTargetCells = {} -- ...then we create this array to save our next group of cells
local emptyCells = {}
for _, cell in pairs(targetCells) do
if self:isInBounds(cell) then
local entity = self:getCellInMatrix(self.collidableMatrices[color], cell)
if entity then
--something is here!!
if not entitiesToPull[entity] then --if the entity hasnt been found already
if entity:canMove() then
local projectedCells = entity:getOccupiedCells(dir) -- we project the movement here
entitiesToPull[entity] = entity
for _, projectedCell in ipairs(projectedCells) do
table.insert(nextTargetCells, projectedCell)
end
else
pull = false
doneSearching = true
end
end
else
--there's nothin there!!
table.insert(emptyCells, cell)
end
else
--we are out of bounds now
doneSearching = true
end
end
--if every projected cell is empty then we can do the move
if #emptyCells == #targetCells then
pull = true
doneSearching = true
end
targetCells = nextTargetCells
local pull = false
if self:isInBounds(initialTargetCell) then
pull, entitiesToPull = Movement.findMovableGroup(
{initialTargetCell}, dir,
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
function(cell) return self:isInBounds(cell) end
)
end
if move then
@ -325,7 +249,11 @@ function Room:movePlayer(key)
end
if pull == true then
for _, entity in pairs(entitiesToPull) do
entity:move(axis, dir[axis])
-- A connected component can wrap around the player and be found by
-- both searches. Every entity still moves exactly one grid cell.
if not entitiesToPush[entity] then
entity:move(axis, dir[axis])
end
end
end
self.player[color]:move(axis, dir[axis])
@ -537,7 +465,7 @@ end
-- Instantiate one object from the flat {class,color,x,y} schema. `class` names an
-- art asset, and its .meta declares the behaviour ("door"/"switch"/"player"/
-- "moveable"); anything without a .meta defaults to immoveable. npc is the one
-- "moveable"/"sticky"/"skewer"); anything without a .meta defaults to immoveable. npc is the one
-- keyword handled directly since it has no art.
function Room:createEntity(class, color, x, y)
if class == "npc" then
@ -577,6 +505,10 @@ function Room:createEntity(class, color, x, y)
if cellShape and #cellShape == 0 then cellShape = nil end
if 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)
else
entity = GenericUnmoveable:new(x, y, color, sprite, cellShape)
end

View file

@ -59,6 +59,53 @@ glow = love.graphics.newShader[[
}
]]
-- Sticky boxes reuse the authored box textures. This shader only wobbles their
-- sampling; the draw helper adds a tiny squash-and-stretch bounce around center.
goopShader = love.graphics.newShader[[
extern number goopTime;
extern vec2 sourceSize;
float goopHash(float value) {
return fract(sin(value * 91.3458) * 47453.5453);
}
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
float sourceY = textureCoords.y;
float pixelWidth = 1.0 / sourceSize.x;
float pixelHeight = 1.0 / sourceSize.y;
float column = floor(textureCoords.x * sourceSize.x / 2.0);
float seed = goopHash(column);
// Two gentle, out-of-phase waves make the surface feel soft and viscous.
// Both stay sub-pixel so the authored silhouette remains recognizable.
float horizontalWave = sin(goopTime * 1.7 + sourceY * 10.0 + seed * 6.2831);
float verticalWave = sin(goopTime * 1.3 + textureCoords.x * 12.0 + seed * 3.1415);
float sourceX = clamp(textureCoords.x + horizontalWave * pixelWidth * 0.45,
pixelWidth * 0.5, 1.0 - pixelWidth * 0.5);
float wobbledY = clamp(sourceY + verticalWave * pixelHeight * 0.28,
pixelHeight * 0.5, 1.0 - pixelHeight * 0.5);
return Texel(texture, vec2(sourceX, wobbledY)) * color;
}
]]
function drawGoopySprite(sprite, x, y, scale)
local w, h = sprite:getWidth(), sprite:getHeight()
local time = love.timer.getTime()
local bounce = math.sin(time * 2.4 + w * .17 + h * .11) * .035
local scaleX = scale * (1 + bounce)
local scaleY = scale * (1 - bounce)
local drawX = x - w * (scaleX - scale) / 2
local drawY = y - h * (scaleY - scale) / 2
goopShader:send("goopTime", time)
goopShader:send("sourceSize", {w, h})
local previousShader = love.graphics.getShader()
love.graphics.setShader(goopShader)
love.graphics.draw(sprite, drawX, drawY, 0, scaleX, scaleY)
love.graphics.setShader(previousShader)
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

353
tests/test_movement.lua Normal file
View file

@ -0,0 +1,353 @@
package.path = "./?.lua;" .. package.path
local Movement = require "movement"
local passed = 0
local function test(name, fn)
local ok, err = pcall(fn)
if not ok then
io.stderr:write("FAIL: " .. name .. "\n" .. err .. "\n")
os.exit(1)
end
passed = passed + 1
end
local function assertTrue(value, message)
if not value then error(message or "expected true", 2) end
end
local function assertFalse(value, message)
if value then error(message or "expected false", 2) end
end
local function assertIn(group, entity, message)
if group[entity] ~= entity then error(message or "expected entity in movement group", 2) end
end
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)
local entity = {
x = x, y = y,
sticky = sticky or false,
skewer = skewer or false,
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:bump() end
function entity:move(axis, amount) self[axis] = self[axis] + amount end
function entity:getGridPos() return {x = self.x, y = self.y} end
function entity:getOccupiedCells(offset)
offset = offset or {x = 0, y = 0}
local cells = {}
for _, cell in ipairs(self.shape) do
table.insert(cells, {x = self.x + cell.x + offset.x, y = self.y + cell.y + offset.y})
end
return cells
end
return entity
end
local function wall(x, y)
local entity = movable(x, y)
function entity:canMove() return false end
return entity
end
local function world(entities, width, height)
width, height = width or 8, height or 8
local cells = {}
for _, entity in ipairs(entities) do
for _, cell in ipairs(entity:getOccupiedCells()) do
cells[cell.y .. ":" .. cell.x] = entity
end
end
return function(cell)
return cells[cell.y .. ":" .. cell.x]
end, function(cell)
return cell.x >= 1 and cell.x <= width and cell.y >= 1 and cell.y <= height
end
end
local function plan(entities, startCell, direction, width, height)
local entityAt, inBounds = world(entities, width, height)
return Movement.findMovableGroup({startCell}, direction, entityAt, inBounds)
end
test("ordinary pull only drags the block touching the player", function()
-- regular, regular, player; moving right
local rear = movable(2, 3)
local touching = movable(3, 3)
local possible, group = plan({rear, touching}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, touching)
assertNotIn(group, rear)
end)
test("a trailing sticky block joins a horizontal pull", function()
-- sticky, regular, player; moving right
local sticky = movable(2, 3, true)
local regular = movable(3, 3)
local possible, group = plan({sticky, regular}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertIn(group, sticky)
end)
test("a sticky block touching the player pulls a regular block behind it", function()
-- regular, sticky, player; moving right
local regular = movable(2, 3)
local sticky = movable(3, 3, true)
local possible, group = plan({regular, sticky}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, sticky)
assertIn(group, regular)
end)
test("sticky pull chains work vertically", function()
-- player moves down, pulling the two blocks above it
local sticky = movable(4, 2, true)
local regular = movable(4, 3)
local possible, group = plan({sticky, regular}, {x = 4, y = 3}, {x = 0, y = 1})
assertTrue(possible)
assertIn(group, regular)
assertIn(group, sticky)
end)
test("sticky pulls are symmetric in all four directions", function()
for _, direction in ipairs({
{x = 1, y = 0}, {x = -1, y = 0},
{x = 0, y = 1}, {x = 0, y = -1},
}) 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 possible, group = plan(
{sticky, regular},
{x = regular.x, y = regular.y},
direction
)
assertTrue(possible)
assertIn(group, regular)
assertIn(group, sticky)
end
end)
test("a sticky block shears off during orthogonal movement", function()
-- The regular block moves right; the sticky block above it stays behind.
local regular = movable(3, 4)
local stickyBranch = movable(3, 3, true)
local possible, group = plan({regular, stickyBranch}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertNotIn(group, stickyBranch)
end)
test("a blocked sheared sticky block does not stop movement", function()
local regular = movable(3, 4)
local stickyBranch = movable(3, 3, true)
local blockingWall = wall(4, 3)
local possible, group = plan({regular, stickyBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertNotIn(group, stickyBranch)
end)
test("a skewer carries 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})
assertTrue(possible)
assertIn(group, regular)
assertIn(group, skewerBranch)
end)
test("a blocked skewer branch stops its rigid movement group", function()
local regular = movable(3, 4)
local skewerBranch = movable(3, 3, false, nil, true)
local blockingWall = wall(4, 3)
local possible = plan({regular, skewerBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0})
assertFalse(possible)
end)
test("ordinary pushing still propagates through regular blocks", function()
local first = movable(3, 3)
local second = movable(4, 3)
local possible, group = plan({first, second}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, first)
assertIn(group, second)
end)
test("diagonal blocks do not stick", function()
local regular = movable(3, 3)
local diagonalSticky = movable(2, 2, true)
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)
local possible, group = plan({tallRegular, sticky}, {x = 3, y = 3}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, tallRegular)
assertIn(group, sticky)
end)
test("production sticky and skewer blocks advertise distinct behavior", 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"
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")
assertTrue(regular:canMove())
assertFalse(regular:isSticky())
assertTrue(sticky:canMove())
assertTrue(sticky:isSticky())
assertFalse(sticky:isSkewer())
assertTrue(skewer:canMove())
assertFalse(skewer:isSticky())
assertTrue(skewer:isSkewer())
end)
local function loadRoomClass()
love = {
audio = {
newSource = function()
return {stop = function() end, play = function() end}
end,
},
}
require "room"
end
local function roomWith(player, entities, width, height)
gridWidth, gridHeight = width or 8, height or 8
local matrix = {}
for y = 1, gridHeight do
matrix[y] = {}
for x = 1, gridWidth do matrix[y][x] = false end
end
for _, entity in ipairs(entities) do
for _, cell in ipairs(entity:getOccupiedCells()) do
matrix[cell.y][cell.x] = entity
end
end
local room = setmetatable({
activeColors = {red = true},
colorsPlayerHas = {"red"},
player = {red = player},
collidableMatrices = {red = matrix},
}, {__index = Room})
room.playerJoinCheck = function() end
room.tick = function() end
return room
end
test("Room applies the sticky-regular-player pull", function()
loadRoomClass()
local sticky = movable(2, 3, true)
local regular = movable(3, 3)
local player = movable(4, 3)
local room = roomWith(player, {sticky, regular})
room:movePlayer("right")
assertTrue(sticky.x == 3, "sticky block should move one cell right")
assertTrue(regular.x == 4, "regular block should move one cell right")
assertTrue(player.x == 5, "player should move one cell right")
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 rear = movable(3, 4)
local component = {front, bridgeRight, bridgeMiddle, bridgeLeft, rear}
local room = roomWith(player, component)
room:movePlayer("right")
for _, entity in ipairs(component) do
assertTrue(entity.x <= 6, "an entity in the shared component moved more than once")
end
assertTrue(front.x == 6)
assertTrue(bridgeRight.x == 6)
assertTrue(bridgeMiddle.x == 5)
assertTrue(bridgeLeft.x == 4)
assertTrue(rear.x == 4)
assertTrue(player.x == 5)
end)
test("every moveable base box gets a synthetic sticky variant", 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 assets = {
box = {class = "moveable", sprites = boxSprites, cells = boxCells},
["box_2x1_11"] = {class = "moveable", sprites = shapeSprites, cells = shapeCells},
box_statue = {class = "immoveable", sprites = {}},
beer_mug = {class = "moveable", sprites = {}},
}
BoxVariants.addTo(assets)
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.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)
assertFalse(assets.sticky_box_statue)
assertFalse(assets.sticky_beer_mug)
assertFalse(assets.skewer_box_statue)
assertFalse(assets.skewer_beer_mug)
end)
print("movement/block-variant tests passed: " .. passed)

View file

@ -171,7 +171,14 @@ function World:drawRoomPreview(name, x, y, size)
local path = props.sprites[color]
if path then
love.graphics.setColor(gColor[color]:set())
love.graphics.draw(loadSprite(path), x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
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)
else
love.graphics.draw(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
end
end
end
end