Add directional sticky and skewer box variants
This commit is contained in:
parent
c3f5aafd77
commit
f549eaf523
13 changed files with 723 additions and 101 deletions
|
|
@ -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
36
box_variants.lua
Normal 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
|
||||
10
entity.lua
10
entity.lua
|
|
@ -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
89
generic_skewer.lua
Normal 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
42
generic_sticky.lua
Normal 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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +102,15 @@ 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)
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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
83
movement.lua
Normal 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
|
||||
122
room.lua
122
room.lua
|
|
@ -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
|
||||
move, entitiesToPush = Movement.findMovableGroup(
|
||||
{initialTargetCell}, dir,
|
||||
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
|
||||
function(cell) return self:isInBounds(cell) 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
|
||||
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,9 +249,13 @@ function Room:movePlayer(key)
|
|||
end
|
||||
if pull == true then
|
||||
for _, entity in pairs(entitiesToPull) do
|
||||
-- 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])
|
||||
moved = true
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
47
shaders.lua
47
shaders.lua
|
|
@ -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
353
tests/test_movement.lua
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue