From f549eaf5235fb06009121153d8e1597d2af0876b Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 11 Aug 2026 15:07:11 -0400 Subject: [PATCH] Add directional sticky and skewer box variants --- CLAUDE.md | 3 +- box_variants.lua | 36 +++ entity.lua | 10 + generic_skewer.lua | 89 ++++++++ generic_sticky.lua | 42 ++++ level_editor/editor.lua | 13 +- level_editor/gui.lua | 11 +- level_editor/object_attributes.lua | 4 +- movement.lua | 83 +++++++ room.lua | 124 +++------- shaders.lua | 47 ++++ tests/test_movement.lua | 353 +++++++++++++++++++++++++++++ world.lua | 9 +- 13 files changed, 723 insertions(+), 101 deletions(-) create mode 100644 box_variants.lua create mode 100644 generic_skewer.lua create mode 100644 generic_sticky.lua create mode 100644 movement.lua create mode 100644 tests/test_movement.lua diff --git a/CLAUDE.md b/CLAUDE.md index 62b720b..440711e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/box_variants.lua b/box_variants.lua new file mode 100644 index 0000000..ff9fcef --- /dev/null +++ b/box_variants.lua @@ -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 diff --git a/entity.lua b/entity.lua index fc2adb2..0766501 100644 --- a/entity.lua +++ b/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 diff --git a/generic_skewer.lua b/generic_skewer.lua new file mode 100644 index 0000000..5cdfc77 --- /dev/null +++ b/generic_skewer.lua @@ -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 diff --git a/generic_sticky.lua b/generic_sticky.lua new file mode 100644 index 0000000..98b8717 --- /dev/null +++ b/generic_sticky.lua @@ -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 diff --git a/level_editor/editor.lua b/level_editor/editor.lua index a11fe29..fb7ed4e 100644 --- a/level_editor/editor.lua +++ b/level_editor/editor.lua @@ -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, } diff --git a/level_editor/gui.lua b/level_editor/gui.lua index 55db806..d60fb80 100644 --- a/level_editor/gui.lua +++ b/level_editor/gui.lua @@ -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 diff --git a/level_editor/object_attributes.lua b/level_editor/object_attributes.lua index 71f0a64..9cab6c9 100644 --- a/level_editor/object_attributes.lua +++ b/level_editor/object_attributes.lua @@ -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 diff --git a/movement.lua b/movement.lua new file mode 100644 index 0000000..2fc30dc --- /dev/null +++ b/movement.lua @@ -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 diff --git a/room.lua b/room.lua index a37bba5..76d301a 100644 --- a/room.lua +++ b/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 - 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 diff --git a/shaders.lua b/shaders.lua index a046df1..491bac7 100644 --- a/shaders.lua +++ b/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 diff --git a/tests/test_movement.lua b/tests/test_movement.lua new file mode 100644 index 0000000..5745883 --- /dev/null +++ b/tests/test_movement.lua @@ -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) diff --git a/world.lua b/world.lua index 7204cb1..72cea55 100644 --- a/world.lua +++ b/world.lua @@ -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