Compare commits

..

No commits in common. "0618cfc51db30120e1c2dfa7a36d1c1901ba0651" and "e51dc22ca1bfeda1f1082d76588dbfca376ef514" have entirely different histories.

38 changed files with 1046 additions and 11485 deletions

View file

@ -4,13 +4,12 @@ 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 or 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, no test suite, and no 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.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

View file

@ -14,9 +14,6 @@ function Entity:initialize(t)
self.name = t.name
self.color = Color:new(t.color)
self.collision = t.collision --"none", "moveable", "unmoveable"
self.sticky = t.sticky or false
self.skewer = t.skewer or false
self.spiky = t.spiky or false
self.sprite = repSprite
self.shaAmount = t.sha or 1
-- onBump is a method (see Entity:onBump) so mixins like Dialogable can
@ -137,18 +134,6 @@ function Entity:canMove()
return false
end
function Entity:isSticky()
return self.sticky
end
function Entity:isSkewer()
return self.skewer
end
function Entity:isSpiky()
return self.spiky
end
function Entity:canPassOver()
if self:getCollision() == "none" then
return true

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ require "level_editor/gui"
require "level_editor/layout"
editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "white"
selectedColor = "red"
local bacSprite = love.graphics.newImage("art/_ui/bac.png")
local folderSprite = love.graphics.newImage("art/_ui/folder.png")
@ -34,6 +34,8 @@ local roomNameField = nil
local editorStatus = nil
local clearSaveConfirmationExpires = 0
local clearSaveConfirmationScope = nil
local worldSeedConfirmationExpires = 0
local worldMode = "place"
local worldRoomButtons = {}
local worldControls = {}
local worldPage = 1
@ -42,61 +44,6 @@ local assetSearchText = ""
local assetSearchButtons = {}
local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 }
local cloneSourceRoom = nil
local roomBrush = nil
local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = {}
-- Convert a pointer position into a valid room cell. Keeping brush state in
-- cell coordinates means a click that jitters within a tile cannot place or
-- erase that tile more than once.
local function roomCellAt(x, y)
if x < 0 or x >= width or y < 0 or y >= height then return nil end
local cell = currentRoom:getCellFromMousePos(x, y)
cell.x = constrain(cell.x, 1, gridWidth)
cell.y = constrain(cell.y, 1, gridHeight)
return cell
end
local function paintRoomCell(cell, button)
-- Use the centre of the cell so Room's existing pointer-based edit helpers
-- perform the same operation as a normal click.
local x = (cell.x - .5) * width / gridWidth
local y = (cell.y - .5) * height / gridHeight
if button == 1 and selectedAsset then
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor)
elseif button == 2 then
currentRoom:attemptDelete(x, y, selectedColor)
end
end
local function paintRoomLine(from, to, button)
-- Bresenham keeps a fast mouse movement from leaving holes between the
-- cells that happened to receive mouse-move events.
local x, y = from.x, from.y
local dx, dy = math.abs(to.x - x), math.abs(to.y - y)
local stepX = x < to.x and 1 or -1
local stepY = y < to.y and 1 or -1
local err = dx - dy
while true do
if x ~= from.x or y ~= from.y then paintRoomCell({ x = x, y = y }, button) end
if x == to.x and y == to.y then break end
local twiceErr = err * 2
if twiceErr > -dy then err = err - dy; x = x + stepX end
if twiceErr < dx then err = err + dx; y = y + stepY end
end
end
local function beginRoomBrush(x, y, button)
if button ~= 1 and button ~= 2 then return end
if button == 1 and not selectedAsset then return end
local cell = roomCellAt(x, y)
if not cell then return end
paintRoomCell(cell, button)
roomBrush = { button = button, lastCell = cell }
end
-- ── Room-view sidebar layout ────────────────────────────────────────────────
-- Declared up here (not next to editorDraw) so editorMouseHandler, defined
@ -109,120 +56,6 @@ local function currentRoomList()
else return assetListButtons end
end
local function rebuildBoxPaletteSections()
boxPaletteSections = {}
for _, prefix in ipairs({ "box", "spiky", "sticky", "skewer" }) do
local names = {}
local pattern = prefix == "box" and "^box" or "^" .. prefix .. "_box"
for name in pairs(gameAssets) do
if name:match(pattern) then table.insert(names, name) end
end
table.sort(names)
table.insert(boxPaletteSections, { prefix = prefix, names = names })
end
end
local function buildBoxPicker(rect)
local gap, cellSize, headerH = 4, 51, 18
local function contentMetrics(size)
local columns = math.max(1, math.floor((rect.w + gap) / (size + gap)))
local height = 0
for _, section in ipairs(boxPaletteSections) do
height = height + headerH + math.ceil(#section.names / columns) * (size + gap) + gap
end
return columns, height
end
-- Keep the picker to one screen when the sidebar is tall enough, while
-- preserving a usable minimum tile size for unusually short sidebars.
local columns, contentHeight = contentMetrics(cellSize)
while cellSize > 20 and contentHeight > rect.h do
cellSize = cellSize - 1
columns, contentHeight = contentMetrics(cellSize)
end
local contentY, sections, items = 0, {}, {}
for _, section in ipairs(boxPaletteSections) do
table.insert(sections, { prefix = section.prefix, y = contentY })
contentY = contentY + headerH
for index, name in ipairs(section.names) do
local column = (index - 1) % columns
local row = math.floor((index - 1) / columns)
table.insert(items, {
name = name,
x = rect.x + column * (cellSize + gap),
y = contentY + row * (cellSize + gap),
w = cellSize,
h = cellSize,
})
end
contentY = contentY + math.ceil(#section.names / columns) * (cellSize + gap) + gap
end
local maxScroll = math.max(0, contentY - rect.h)
boxPaletteScroll.scroll = math.max(0, math.min(boxPaletteScroll.scroll or 0, maxScroll))
local picker = { rect = rect, sections = sections, items = items, maxScroll = maxScroll, scroll = boxPaletteScroll.scroll }
function picker.at(x, y)
if not Layout.hit(rect, x, y) then return nil end
local contentYAtMouse = y - rect.y + picker.scroll
for _, item in ipairs(items) do
if x >= item.x and x < item.x + item.w and contentYAtMouse >= item.y and contentYAtMouse < item.y + item.h then
return item.name
end
end
end
return picker
end
local function drawPaletteAsset(name, x, y, size)
local props = gameAssets[name]
if not props or not props.sprites then return end
local maxW, maxH = 1, 1
for _, path in pairs(props.sprites) do
local sprite = loadSprite(path)
maxW, maxH = math.max(maxW, sprite:getWidth()), math.max(maxH, sprite:getHeight())
end
local scale = math.min((size - 6) / maxW, (size - 6) / maxH)
local drawX = x + (size - maxW * scale) / 2
local drawY = y + (size - maxH * scale) / 2
for color, path in pairs(props.sprites) do
local sprite = loadSprite(path)
love.graphics.setColor(gColor[color]:set())
if props.goopy and drawGoopySprite then
drawGoopySprite(sprite, drawX, drawY, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], drawX, drawY, scale)
elseif props.spiky and drawSpikySprite then
drawSpikySprite(sprite, props.cells and props.cells[color], drawX, drawY, scale)
else
love.graphics.draw(sprite, drawX, drawY, 0, scale, scale)
end
end
end
local function drawBoxPicker(picker)
Layout.clip(picker.rect)
for _, section in ipairs(picker.sections) do
love.graphics.setColor(gColor.white:set())
love.graphics.print(section.prefix, picker.rect.x, picker.rect.y + section.y - picker.scroll)
end
for _, item in ipairs(picker.items) do
local y = picker.rect.y + item.y - picker.scroll
if y + item.h > picker.rect.y and y < picker.rect.y + picker.rect.h then
love.graphics.setColor(1, 1, 1, 0.45)
love.graphics.rectangle("line", item.x, y, item.w, item.h)
drawPaletteAsset(item.name, item.x, y, item.w)
if item.name == selectedAsset then
love.graphics.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(2)
love.graphics.rectangle("line", item.x + 1, y + 1, item.w - 2, item.h - 2)
end
end
end
Layout.unclip()
Layout.scrollbar(picker)
end
-- Carve the sidebar into fixed, non-overlapping "divs": a search header pinned
-- to the top, a control cluster pinned to the bottom, a status strip above it,
-- and a flexible list filling whatever is left. Recomputed every frame so it
@ -230,7 +63,7 @@ end
local function roomSections()
local pad = 10
local w = math.max(0, sb.w - pad * 2)
local controlsH = 130
local controlsH = 104
local statusH = 44
local search = { x = pad, y = pad, w = w, h = 34 }
local controls = { x = pad, y = sb.h - pad - controlsH, w = w, h = controlsH }
@ -259,17 +92,10 @@ local function layoutRoomControls(c, s)
-- lives here instead of being scattered across the creation calls.
local function sizeBtn(name, target)
local b = uiButtons[name]
if b and b.sprite then
b.scale = target / b.sprite:getHeight()
b.w = b.sprite:getWidth() * b.scale
b.h = b.sprite:getHeight() * b.scale
elseif b and b.sprites then
local spriteW, spriteH = 1, 1
for _, sprite in pairs(b.sprites) do
spriteW, spriteH = math.max(spriteW, sprite:getWidth()), math.max(spriteH, sprite:getHeight())
end
b.scale = math.min(target / spriteW, target / spriteH)
b.w, b.h = spriteW * b.scale, spriteH * b.scale
if b and b.sprite then
b.scale = target / b.sprite:getHeight()
b.w = b.sprite:getWidth() * b.scale
b.h = b.sprite:getHeight() * b.scale
end
return b
end
@ -293,7 +119,6 @@ local function layoutRoomControls(c, s)
end
placeRow({ "white", "red", "green", "blue" }, 22)
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world" }, 28)
end
@ -302,7 +127,7 @@ end
local function buildRoomLayout()
local s = roomSections()
local showBack = assetSearchText ~= "" or selectedFolder or boxPaletteOpen
local showBack = assetSearchText ~= "" or selectedFolder
local sx, sw = s.search.x, s.search.w
if showBack then
s.back = { x = s.search.x, y = s.search.y, w = s.search.h, h = s.search.h }
@ -311,12 +136,8 @@ local function buildRoomLayout()
end
s.searchBox = { x = sx, y = s.search.y, w = sw, h = s.search.h }
if boxPaletteOpen then
s.boxPicker = buildBoxPicker(s.listRect)
else
s.items = currentRoomList()
s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2)
end
s.items = currentRoomList()
s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2)
layoutRoomControls(s.controls, s)
return s
@ -324,8 +145,7 @@ end
-- Mouse wheel scrolls the room list; clamped by Layout.list next frame.
function editorScrollRoomList(dy)
local scroll = boxPaletteOpen and boxPaletteScroll or listScroll
scroll.scroll = math.max(0, (scroll.scroll or 0) - dy * 40)
listScroll.scroll = math.max(0, (listScroll.scroll or 0) - dy * 40)
end
-- ────────────────────────────────────────────────────────────────────────────
@ -362,10 +182,6 @@ local function rebuildAssetSearchButtons()
for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end
table.insert(assetSearchButtons, createUIElement{
sprites = sprites,
goopy = gameAssets[asset].goopy,
skewer = gameAssets[asset].skewer,
spiky = gameAssets[asset].spiky,
cells = gameAssets[asset].cells,
name = "asset",
text = asset,
x = 30,
@ -394,6 +210,21 @@ local function rebuildWorldRoomButtons()
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 })
end
worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sb.h - 194, w = sb.w - 20, h = 34,
onClic = function() worldMode = worldMode == "place" and "jump" or "place"; rebuildWorldRoomButtons() end },
{ text = "RESEED OLD EXITS", x = 10, y = sb.h - 152, w = sb.w - 20, h = 34,
onClic = function()
local now = love.timer.getTime()
if now > worldSeedConfirmationExpires then
worldSeedConfirmationExpires = now + 3
editorStatus = "Click RESEED OLD EXITS again within 3 seconds to replace the current map."
return
end
worldSeedConfirmationExpires = 0
local result = seedWorldFromLegacyExits(gameWorld)
editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)."
rebuildWorldRoomButtons()
end },
{ text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sb.h - 110, w = sb.w - 20, h = 34,
onClic = function(buttonX)
if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end
@ -404,20 +235,10 @@ end
local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set())
love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sb.w - 16, "center")
worldSearchBox.w = sb.w - 20
if activeField == "cloneName" then
love.graphics.printf("CLONE '" .. cloneSourceRoom .. "' AS:", 8, 6, sb.w - 16, "center")
love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h)
love.graphics.printf(inputText, worldSearchBox.x + 6, worldSearchBox.y + 7, sb.w - 32, "left")
love.graphics.printf("Press Enter to create and select the clone. Esc cancels.", 8, worldSearchBox.y + 34, sb.w - 16, "center")
else
local instructions = selectedRoom
and "placing '" .. selectedRoom .. "' — click a map cell"
or "click a map cell to open or create; select a room below to place it"
love.graphics.printf("ROOMS — " .. instructions, 8, 6, sb.w - 16, "center")
love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h)
love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left")
end
love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h)
love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left")
for _, button in ipairs(worldRoomButtons) do
love.graphics.setColor(button.name == selectedRoom and 1 or 100 / 255, button.name == selectedRoom and 1 or 100 / 255, button.name == selectedRoom and 1 or 100 / 255, 1)
love.graphics.rectangle("line", button.x, button.y, button.w, button.h)
@ -469,8 +290,8 @@ local function saveRoomFromEditor()
return
end
local savePlayerPositions = not love.keyboard.isDown("lshift", "rshift")
if not currentRoom:saveToSource(name, savePlayerPositions) then
local savePlayerPositions = love.keyboard.isDown("lshift", "rshift")
if not currentRoom:saveToSource(name, savePlayerPositions, oldName) then
editorStatus = "Could not save '" .. name .. "'."
return
end
@ -481,18 +302,13 @@ local function saveRoomFromEditor()
removeFromSource("rooms/" .. oldName .. ".sav")
gameWorld:renameRoom(oldName, name)
end
-- The project source is now authoritative. Drop any runtime copy so it
-- cannot shadow this editor save the next time the game starts.
clearRuntimeSaveData(name)
if oldName and oldName ~= name then clearRuntimeSaveData(oldName) end
suppressRuntimeAutosave = true
gameWorld:invalidatePreview(name)
if editorView == "world" then rebuildWorldRoomButtons() end
currentRoom.name = name
editorRoomName = name
roomNameField:editText(name)
activeField = nil
editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or " without player positions.")
editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or ".")
end
local function clearRuntimeSaves()
@ -512,7 +328,6 @@ local function clearRuntimeSaves()
end
local removed = clearRuntimeSaveData(clearAll and nil or roomName)
suppressRuntimeAutosave = true
clearSaveConfirmationExpires = 0
clearSaveConfirmationScope = nil
if roomName then
@ -527,7 +342,6 @@ end
function _initLevelEditor()
gameAssets = getAllAssets()
rebuildBoxPaletteSections()
editorRoomName = ""
--create folder buttons
@ -626,25 +440,6 @@ function _initLevelEditor()
}, color)
end
for _, asset in ipairs({ "player", "static_wall", "switch", "door", "box" }) do
local properties = gameAssets[asset]
if properties then
local sprites = {}
for color, path in pairs(properties.sprites) do sprites[color] = love.graphics.newImage(path) end
_addUIButton(createUIElement{
name = "quick_" .. asset,
sprites = sprites,
goopy = properties.goopy,
skewer = properties.skewer,
spiky = properties.spiky,
cells = properties.cells,
onClic = { left = function() selectQuickAsset(asset) end },
x = 0,
y = 0,
}, "quick_" .. asset)
end
end
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
@ -669,7 +464,6 @@ end
function selectFolder(folder)
selectedFolder = folder
boxPaletteOpen = false
listScroll.scroll = 0
assetListButtons = {}
@ -684,19 +478,13 @@ function selectFolder(folder)
-- each frame from the list section (see buildRoomLayout).
table.insert(assetListButtons, createUIElement{
sprites = sprites,
goopy = gameAssets[asset].goopy,
skewer = gameAssets[asset].skewer,
spiky = gameAssets[asset].spiky,
cells = gameAssets[asset].cells,
name = "asset",
text = asset,
x = 30,
y = 0,
onClic = {
left = function() selectAsset(asset) end,
right = function()
if not gameAssets[asset].synthetic then editAsset(asset) end
end,
right = function() editAsset(asset) end,
}
})
end
@ -708,12 +496,6 @@ function selectAsset(asset)
print(selectedAsset)
end
function selectQuickAsset(asset)
selectAsset(asset)
boxPaletteOpen = asset == "box"
boxPaletteScroll.scroll = 0
end
function editAsset(asset)
inputText = ""
editorView = "asset"
@ -759,10 +541,6 @@ function editAsset(asset)
local assetButton = createUIElement{
name = asset,
sprites = assetSprites,
goopy = assetProperties.goopy,
skewer = assetProperties.skewer,
spiky = assetProperties.spiky,
cells = assetProperties.cells,
x = sb.w / 2 - 18,
y = 80,
}
@ -841,8 +619,7 @@ function worldView()
editorView = "world"
activeField = nil
selectedFolder = nil
selectedRoom = nil
cloneSourceRoom = nil
worldMode = "place"
worldPage = 1
rebuildWorldRoomButtons()
end
@ -857,36 +634,6 @@ local function newWorldRoomName(cellX, cellY)
return name
end
local function cloneWorldRoom(sourceName, cloneName)
local source = readFromSource("rooms/" .. sourceName .. ".sav")
if not source then
editorStatus = "Could not find source level '" .. sourceName .. "'."
return false
end
if readFromSource("rooms/" .. cloneName .. ".sav") then
editorStatus = "A level named '" .. cloneName .. "' already exists."
return false
end
if not writeToSource("rooms/" .. cloneName .. ".sav", source) then
editorStatus = "Could not create clone '" .. cloneName .. "'."
return false
end
gameWorld:invalidatePreview(cloneName)
selectedRoom = cloneName
rebuildWorldRoomButtons()
editorStatus = "Cloned '" .. sourceName .. "' as '" .. cloneName .. "'. Click a map cell to place it."
return true
end
local function beginCloneDialog(sourceName)
cloneSourceRoom = sourceName
activeField = "cloneName"
inputText = ""
response = ""
editorStatus = "Enter a name for the clone."
end
local function openWorldRoom(cellX, cellY)
local name = gameWorld:roomAt(cellX, cellY)
if not name then
@ -926,36 +673,24 @@ function editorMouseHandler(x, y, button)
rebuildAssetSearchButtons()
elseif selectedFolder then
folderView()
elseif boxPaletteOpen then
boxPaletteOpen = false
boxPaletteScroll.scroll = 0
end
return
end
if Layout.hit(s.searchBox, x, y) then
boxPaletteOpen = false
beginSearch("assetSearch")
return
end
if s.boxPicker then
local asset = s.boxPicker.at(x, y)
if asset then
if button == 1 then selectAsset(asset) end
return
end
else
-- scrolling list item
local i = s.list.at(x, y)
if i and s.items[i] then
s.items[i]:onClic(button)
return
end
-- scrolling list item
local i = s.list.at(x, y)
if i and s.items[i] then
s.items[i]:onClic(button)
return
end
-- control cluster (only the buttons that live there)
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do
local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then
b:onClic(button)
@ -969,20 +704,18 @@ function editorMouseHandler(x, y, button)
end
end
elseif editorView == "world" then
if activeField == "cloneName" then return end
if buttonContains(worldSearchBox, x, y) then
beginSearch("worldSearch")
return
end
for _, roomButton in ipairs(worldRoomButtons) do
if buttonContains(roomButton, x, y) then
if button ~= 1 then return end
if selectedRoom == roomButton.name then
selectedRoom = nil
editorStatus = "Placement cancelled. Click the map to open or create a level."
editorStatus = "Room brush cleared. Click the map to open or create a level."
else
selectedRoom = roomButton.name
editorStatus = "Selected '" .. selectedRoom .. "'. Click a map cell to place it."
editorStatus = "Selected '" .. selectedRoom .. "' for placement. Click it again to clear."
end
return
end
@ -997,53 +730,24 @@ function editorMouseHandler(x, y, button)
end
else
-- click landed on the grid (game area): place the selected asset
if editorView == "room" then
beginRoomBrush(x, y, button)
if editorView == "room" and button == 1 and selectedAsset then
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor)
elseif editorView == "world" then
local cellX, cellY = gameWorld:getCellFromMousePos(x, y)
if not gameWorld:isInBounds(cellX, cellY) then return end
if button == 2 then
local name = gameWorld:roomAt(cellX, cellY)
if name then beginCloneDialog(name) end
elseif selectedRoom then
local placedRoom = selectedRoom
if gameWorld:placeRoom(placedRoom, cellX, cellY) then
selectedRoom = nil
editorStatus = "Placed '" .. placedRoom .. "' at " .. cellX .. ", " .. cellY .. "."
else
editorStatus = "Could not place '" .. placedRoom .. "'."
end
else
local removed, name = gameWorld:removeRoom(cellX, cellY)
if removed then editorStatus = "Removed '" .. name .. "' from the world." end
elseif worldMode == "jump" or not selectedRoom then
openWorldRoom(cellX, cellY)
elseif selectedRoom then
gameWorld:placeRoom(selectedRoom, cellX, cellY)
editorStatus = "Placed '" .. selectedRoom .. "' at " .. cellX .. ", " .. cellY .. "."
end
end
end
end
function editorMouseMoved(x, y)
if not roomBrush then return end
if editorView ~= "room" then
roomBrush = nil
return
end
local cell = roomCellAt(x, y)
if not cell then
-- Leaving the grid ends this stroke; re-entering should not draw a line
-- across the sidebar or outside of the level.
roomBrush = nil
return
end
if cell.x == roomBrush.lastCell.x and cell.y == roomBrush.lastCell.y then return end
paintRoomLine(roomBrush.lastCell, cell, roomBrush.button)
roomBrush.lastCell = cell
end
function editorMouseReleased(_, _, button)
if roomBrush and roomBrush.button == button then roomBrush = nil end
end
function editorTextHandler(text)
if editorView == "asset" and activeField then
inputText = text
@ -1060,8 +764,6 @@ function editorTextHandler(text)
worldSearchText = text
worldPage = 1
rebuildWorldRoomButtons()
elseif editorView == "world" and activeField == "cloneName" then
inputText = text
end
end
@ -1074,32 +776,10 @@ function editorCompleteResponse()
assetSearchText = inputText
elseif editorView == "world" and activeField == "worldSearch" then
worldSearchText = inputText
elseif editorView == "world" and activeField == "cloneName" then
local cloneName, err = validRoomName(inputText)
if not cloneName then
editorStatus = err
return
end
local sourceName = cloneSourceRoom
if cloneWorldRoom(sourceName, cloneName) then
cloneSourceRoom = nil
activeField = nil
end
return
end
activeField = nil
end
function editorCancelResponse()
if editorView ~= "world" or activeField ~= "cloneName" then return false end
activeField = nil
cloneSourceRoom = nil
inputText = ""
response = ""
editorStatus = "Clone cancelled."
return true
end
function sortAllAssets()
setAll()
end
@ -1116,51 +796,29 @@ function editorDraw()
-- header: optional back button + search box
if s.back then
love.graphics.rectangle("line", s.back.x, s.back.y, s.back.w, s.back.h)
if boxPaletteOpen then
love.graphics.printf("X", s.back.x, s.back.y + 9, s.back.w, "center")
else
local sc = (s.back.h - 8) / bacSprite:getHeight()
love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc)
end
local sc = (s.back.h - 8) / bacSprite:getHeight()
love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc)
end
love.graphics.rectangle("line", s.searchBox.x, s.searchBox.y, s.searchBox.w, s.searchBox.h)
love.graphics.printf(boxPaletteOpen and "BOX VARIANTS" or "SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left")
love.graphics.printf("SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left")
if s.boxPicker then
drawBoxPicker(s.boxPicker)
else
-- Scrolling list, clipped to its section so nothing bleeds into others.
Layout.clip(s.listRect)
for i, item in ipairs(s.items) do
if s.list.visible(i) then
item.x, item.y = s.list.pos(i)
item.w = s.listRect.w
item:draw()
if item.name == "asset" and item.text == selectedAsset then
love.graphics.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(3)
love.graphics.rectangle("line", item.x + 1, item.y + 1, item.w - 2, item.h - 2)
end
end
-- scrolling list, clipped to its section so nothing bleeds into others
Layout.clip(s.listRect)
for i, item in ipairs(s.items) do
if s.list.visible(i) then
item.x, item.y = s.list.pos(i)
item.w = s.listRect.w
item:draw()
end
Layout.unclip()
Layout.scrollbar(s.list)
end
Layout.unclip()
Layout.scrollbar(s.list)
-- control cluster
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center")
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
local button = uiButtons[name]
if button then
button:draw()
local asset = name:match("^quick_(.+)$")
if asset and selectedAsset == asset then
love.graphics.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(2)
love.graphics.rectangle("line", button.x - 1, button.y - 1, button.w + 2, button.h + 2)
end
end
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do
if uiButtons[name] then uiButtons[name]:draw() end
end
-- status strip between the list and the controls

View file

@ -9,10 +9,6 @@ function GUI:initialize(t)
self.offset = {x = 0, y = 0}
self.name = t.name
self.text = t.text
self.goopy = t.goopy or false
self.skewer = t.skewer or false
self.spiky = t.spiky or false
self.cells = t.cells
if not t.onClic then
t.onClic = {}
@ -103,15 +99,7 @@ function GUI:draw()
if self.sprites then
for color, sprite in pairs(self.sprites) do
love.graphics.setColor(gColor[color]:set())
if self.goopy and drawGoopySprite then
drawGoopySprite(sprite, box.x, box.y, self.scale)
elseif self.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, self.cells and self.cells[color], box.x, box.y, self.scale)
elseif self.spiky and drawSpikySprite then
drawSpikySprite(sprite, self.cells and self.cells[color], box.x, box.y, self.scale)
else
love.graphics.draw(sprite, box.x, box.y, 0, self.scale, self.scale)
end
love.graphics.draw(sprite, box.x, box.y, 0, self.scale, self.scale)
end
end

View file

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

View file

@ -23,7 +23,6 @@ require "level_editor/editor"
love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. ";libs/narrator/?.lua")
narrator = require "narrator.narrator"
startSignBook = require "stories.start_sign"
jorgeBook = require "stories.jorge"
function love.load()
globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets
@ -32,7 +31,6 @@ function love.load()
_initGridVars()
_initSounds()
_initGlobalColors()
_initAccessibility()
gameWorld = World:new()
_initLevelEditor()
@ -56,9 +54,7 @@ end
-- Autosave on close so a position reached by moving within a room (short of a
-- room-to-room transition, which already autosaves in nextRoom) survives.
function love.quit()
-- An explicit editor save/clear removes the runtime copy intentionally;
-- don't immediately recreate it while shutting down.
if currentRoom and not suppressRuntimeAutosave then
if currentRoom then
currentRoom:save()
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end
end
@ -120,42 +116,16 @@ function love.draw()
love.graphics.pop()
love.graphics.setCanvas()
-- Collect every visible layer before the accessibility pass. Applying the
-- shader here also covers the editor sidebar, FPS display, and dialogue,
-- rather than only recolouring the room sprites.
love.graphics.setCanvas(presentationCanvas)
love.graphics.clear()
love.graphics.setBlendMode("alpha")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(finalCanvas, 0, 0)
love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y)
love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10)
Dialogue.draw()
love.graphics.setCanvas()
love.graphics.setColor(1, 1, 1, 1)
local filter = accessibilityModes[accessibilityModeIndex]
if filter.shader then
filter.shader:send("accessibilityTime", love.timer.getTime())
love.graphics.setShader(filter.shader)
end
love.graphics.draw(presentationCanvas, 0, 0)
love.graphics.setShader()
if accessibilityMessage and love.timer.getTime() < accessibilityMessageExpires then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.printf(accessibilityMessage, 0, 30, love.graphics.getWidth(), "center")
end
end
function love.keypressed(key)
-- Available even while a dialogue is open, so the player never has to
-- dismiss something important just to change the colour treatment.
if key == "f4" then
cycleAccessibilityMode()
return
end
-- Talking to a sign captures input: navigate/choose, don't move the player.
if Dialogue.isOpen() then
Dialogue.keypressed(key)
@ -180,8 +150,6 @@ function love.keypressed(key)
if key == "return" then
response = ""
editorCompleteResponse()
elseif key == "escape" and editorCancelResponse() then
return
elseif key == "backspace" then
local byteoffset = utf8.offset(response, -1)
@ -195,17 +163,12 @@ function love.keypressed(key)
end
function love.mousepressed(x, y, button)
if editorView == "room" and button == 2 then
currentRoom:attemptDelete(x, y, selectedColor)
end
editorMouseHandler(x, y, button)
end
function love.mousemoved(x, y)
editorMouseMoved(x, y)
end
function love.mousereleased(x, y, button)
editorMouseReleased(x, y, button)
end
function love.textinput(t)
response = response .. t
editorTextHandler(response)
@ -274,7 +237,6 @@ end
function _initCanvases()
gameCanvas = love.graphics.newCanvas()
finalCanvas = love.graphics.newCanvas()
presentationCanvas = love.graphics.newCanvas()
sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h))
sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled
end
@ -291,37 +253,6 @@ function _initGlobalColors()
gColor.red, gColor.green, gColor.blue, gColor.white = Color:new("red"), Color:new("green"), Color:new("blue"), Color:new("white")
end
function _initAccessibility()
accessibilityModes = {
{ id = "normal", label = "Normal", shader = nil },
{ id = "deuteranopia", label = "Deuteranopia", shader = accessibilityColorShader },
}
accessibilityModeIndex = 1
local savedMode = love.filesystem.read("accessibility_mode.txt")
if savedMode then
savedMode = savedMode:match("[%w_]+")
if savedMode == "deuteranopia_high_contrast" then savedMode = "deuteranopia" end
for index, mode in ipairs(accessibilityModes) do
if mode.id == savedMode then
accessibilityModeIndex = index
break
end
end
end
local mode = accessibilityModes[accessibilityModeIndex]
accessibilityMessage = "Color filter: " .. mode.label .. " (F4 to change)"
accessibilityMessageExpires = love.timer.getTime() + 4
end
function cycleAccessibilityMode()
accessibilityModeIndex = accessibilityModeIndex % #accessibilityModes + 1
local mode = accessibilityModes[accessibilityModeIndex]
love.filesystem.write("accessibility_mode.txt", mode.id)
accessibilityMessage = "Color filter: " .. mode.label .. " (F4 to change)"
accessibilityMessageExpires = love.timer.getTime() + 2.5
end
function _initSounds()
staticWave = sfxr.newSound()
staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr

View file

@ -1,143 +0,0 @@
-- Finds the complete set of blocks that must move together for one grid step.
-- A block joins the set when it is in another block's destination (ordinary
-- pushing), is softly grabbed by sticky/spiky, or touches a rigid skewer.
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
local function isSpiky(entity)
return entity.isSpiky and entity:isSpiky()
end
-- Build one rigid component: ordinary pushes are required, and skewers join
-- along every edge. `alreadyMoving` contains cells that will be vacated during
-- this same step, so a later pulled component may move into them.
local function findRigidGroup(startCells, direction, entityAt, isInBounds, alreadyMoving)
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] and
not (alreadyMoving and alreadyMoving[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
-- Skewers make every edge contact part of this rigid component.
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 attached = adjacentEntity and (
isSkewer(entity) or isSkewer(adjacentEntity)
)
if attached and adjacentEntity ~= entity and adjacentEntity:canMove() and
not (alreadyMoving and alreadyMoving[adjacentEntity]) then
table.insert(pendingCells, adjacentCell)
end
end
end
end
end
end
return true, entities
end
function Movement.findMovableGroup(startCells, direction, entityAt, isInBounds)
local possible, entities = findRigidGroup(
startCells, direction, entityAt, isInBounds
)
if not possible then return false, entities end
local movingQueue = {}
for entity in pairs(entities) do table.insert(movingQueue, entity) end
local attemptedPulls = {}
local nextMover = 1
while nextMover <= #movingQueue do
local entity = movingQueue[nextMover]
nextMover = nextMover + 1
-- Sticky contact is soft only on the trailing face. Spiky contact is soft
-- on every face. Either kind simply releases a blocked rigid component,
-- without cancelling this entity or another independently pulled branch.
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 softContact = adjacentEntity and (
isSpiky(entity) or isSpiky(adjacentEntity) or
(trailing and (isSticky(entity) or isSticky(adjacentEntity)))
)
if softContact and adjacentEntity ~= entity and
not entities[adjacentEntity] and not attemptedPulls[adjacentEntity] and
adjacentEntity:canMove() then
attemptedPulls[adjacentEntity] = true
local canPull, pulled = findRigidGroup(
{adjacentCell}, direction, entityAt, isInBounds, entities
)
if canPull then
for pulledEntity in pairs(pulled) do
if not entities[pulledEntity] then
entities[pulledEntity] = pulledEntity
table.insert(movingQueue, pulledEntity)
end
end
end
end
end
end
end
end
return true, entities
end
return Movement

18
npc.lua
View file

@ -20,21 +20,3 @@ function Sign:initialize(x, y)
},
})
end
Jorge = class("Jorge", Entity)
Jorge:include(Dialogable)
function Jorge:initialize(x, y)
self.book = jorgeBook
self.saveClass = "jorge"
Entity.initialize(self, {
x = x, y = y,
color = "red",
collision = "unmoveable",
sprites = {
red = loadSprite("art/npc/jorge_r.png"),
green = loadSprite("art/npc/jorge_g.png"),
blue = loadSprite("art/npc/jorge_b.png"),
},
})
end

194
room.lua
View file

@ -10,13 +10,8 @@ require "switch"
require "door"
require "dialogue_manager"
require "generic_moveable"
require "generic_sticky"
require "generic_skewer"
require "generic_spiky"
require "generic_unmoveable"
local Movement = require "movement"
function Room:initialize(t)
self.player = {}
self.switches = { red = {}, green = {}, blue = {} }
@ -207,7 +202,7 @@ function Room:movePlayer(key)
end
local move = true
-- PUSH ---------------------------------------------------------------
-- PUSHHHHHHHHHHHHHHHHHHHHHH---------------------
local initialTargetCell = {
x = pos.x + dir.x, y = pos.y + dir.y
@ -224,24 +219,104 @@ 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
move, entitiesToPush = Movement.findMovableGroup(
{initialTargetCell}, dir,
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
function(cell) return self:isInBounds(cell) end
)
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
end
-- PULL ---------------------------------------------------------------
---PULL CHEC
local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y}
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
)
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
end
if move then
@ -250,11 +325,7 @@ 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
entity:move(axis, dir[axis])
end
end
self.player[color]:move(axis, dir[axis])
@ -270,21 +341,21 @@ function Room:movePlayer(key)
end
function Room:switchPlayerColor(color)
-- Number keys can request a colour the player has not collected yet. Do
-- nothing in that case: there is no corresponding player object to switch
-- to (or use as the sound source).
local targetPlayer = self.player[color]
if not targetPlayer then return false end
local canSwitch
local canSwitch = not self:onlyColorIsActive(color)
if canSwitch then
for _, c in ipairs(self:getPossibleColors()) do
self:setActiveColor(c, c == color)
if self:onlyColorIsActive(color) then
canSwitch = false
else
for _, c in pairs(self:getPossibleColors()) do
if c == color then
self:setActiveColor(c, true)
else
self:setActiveColor(c, false)
end
end
end
targetPlayer:playSwitchSound(canSwitch)
return canSwitch
self.player[color]:playSwitchSound(canSwitch)
end
function Room:setActiveColor(color, bool)
@ -466,7 +537,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"/"sticky"/"skewer"/"spiky"); anything without a .meta defaults to immoveable. npc is the one
-- "moveable"); 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
@ -474,11 +545,6 @@ function Room:createEntity(class, color, x, y)
self:registerNpc(entity)
return entity
end
if class == "jorge" then
local entity = Jorge:new(x, y)
self:registerNpc(entity)
return entity
end
local props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable"
@ -511,12 +577,6 @@ 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)
elseif behavior == "spiky" then
entity = GenericSpiky:new(x, y, color, sprite, class, cellShape)
else
entity = GenericUnmoveable:new(x, y, color, sprite, cellShape)
end
@ -604,25 +664,12 @@ function Room:attemptDelete(mouseX, mouseY, color)
for index = #self.doors, 1, -1 do
if removedEntities[self.doors[index]] then table.remove(self.doors, index) end
end
-- NPCs are stamped into every collision layer, but are kept in their own
-- registry rather than in self.collideables. Remove them directly so a
-- right-click works no matter which editor colour is currently selected.
for index = #self.npcs, 1, -1 do
if self.npcs[index]:occupiesCell(cell) then table.remove(self.npcs, index) end
end
self:tick()
end
function Room:attemptPlace(name, props, mouseX, mouseY, color)
if not props or not props.sprites then return end
local cell = self:getCellFromMousePos(mouseX, mouseY)
-- Jorge is one dialogable NPC with three display channels, not three
-- independently placeable color-layer entities.
if name == "jorge" then
self:createEntity(name, nil, cell.x, cell.y)
self:tick()
return
end
-- "white" (or nil) means every channel; otherwise just the picked channel
local colors
@ -680,9 +727,32 @@ end
-- This is intentionally separate from Room:save. It is the editor's explicit
-- "write this level into the project" action; gameplay autosaves must never
-- call it.
function Room:saveToSource(name, savePlayerPositions)
function Room:saveToSource(name, savePlayerPositions, playerSourceName)
name = name or self.name or "test"
local saveTable = self:serialize(savePlayerPositions ~= false)
if savePlayerPositions == false then
-- A normal editor save keeps the authored spawn positions from the
-- existing source level. During a rename that source is still oldName.
local sourceName = playerSourceName or name
local existing = readFromSource("rooms/" .. sourceName .. ".sav")
local keptPlayer = false
if existing then
local previous = TSerial.unpack(existing)
for _, object in ipairs(previous.objects or {}) do
if object.class == "player" then
table.insert(saveTable.objects, object)
keptPlayer = true
end
end
end
-- A newly named blank level has no prior source spawn to retain.
if not keptPlayer then
for color, player in pairs(self.player) do
local pos = player:getGridPos()
table.insert(saveTable.objects, { class = "player", color = color, x = pos.x, y = pos.y })
end
end
end
local room = TSerial.pack(saveTable, nil, true)
return writeToSource("rooms/" .. name .. ".sav", room)
end
@ -721,7 +791,7 @@ function Room:serialize(includePlayers)
end
for _, npc in ipairs(self.npcs) do
table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y })
table.insert(objects, { class = "npc", x = npc.x, y = npc.y })
end
return { objects = objects }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,760 +0,0 @@
{
objects={
{
x=11,
y=11,
class="static_wall",
color="green"
},
{
x=11,
y=10,
class="static_wall",
color="green"
},
{
x=11,
y=9,
class="static_wall",
color="green"
},
{
x=11,
y=8,
class="static_wall",
color="green"
},
{
x=11,
y=7,
class="static_wall",
color="green"
},
{
x=11,
y=6,
class="static_wall",
color="green"
},
{
x=11,
y=4,
class="static_wall",
color="green"
},
{
x=11,
y=3,
class="static_wall",
color="green"
},
{
x=11,
y=2,
class="static_wall",
color="green"
},
{
x=11,
y=1,
class="static_wall",
color="green"
},
{
x=10,
y=1,
class="static_wall",
color="green"
},
{
x=9,
y=1,
class="static_wall",
color="green"
},
{
x=8,
y=1,
class="static_wall",
color="green"
},
{
x=7,
y=1,
class="static_wall",
color="green"
},
{
x=6,
y=1,
class="static_wall",
color="green"
},
{
x=5,
y=1,
class="static_wall",
color="green"
},
{
x=4,
y=1,
class="static_wall",
color="green"
},
{
x=3,
y=1,
class="static_wall",
color="green"
},
{
x=2,
y=1,
class="static_wall",
color="green"
},
{
x=1,
y=1,
class="static_wall",
color="green"
},
{
x=1,
y=2,
class="static_wall",
color="green"
},
{
x=1,
y=3,
class="static_wall",
color="green"
},
{
x=1,
y=4,
class="static_wall",
color="green"
},
{
x=1,
y=5,
class="static_wall",
color="green"
},
{
x=1,
y=6,
class="static_wall",
color="green"
},
{
x=1,
y=7,
class="static_wall",
color="green"
},
{
x=1,
y=8,
class="static_wall",
color="green"
},
{
x=1,
y=9,
class="static_wall",
color="green"
},
{
x=1,
y=10,
class="static_wall",
color="green"
},
{
x=1,
y=11,
class="static_wall",
color="green"
},
{
x=2,
y=11,
class="static_wall",
color="green"
},
{
x=3,
y=11,
class="static_wall",
color="green"
},
{
x=4,
y=11,
class="static_wall",
color="green"
},
{
x=5,
y=11,
class="static_wall",
color="green"
},
{
x=6,
y=11,
class="static_wall",
color="green"
},
{
x=7,
y=11,
class="static_wall",
color="green"
},
{
x=8,
y=11,
class="static_wall",
color="green"
},
{
x=9,
y=11,
class="static_wall",
color="green"
},
{
x=10,
y=11,
class="static_wall",
color="green"
},
{
x=11,
y=5,
class="static_wall",
color="green"
},
{
x=5,
y=7,
class="box",
color="green"
},
{
x=11,
y=11,
class="static_wall",
color="blue"
},
{
x=11,
y=10,
class="static_wall",
color="blue"
},
{
x=11,
y=9,
class="static_wall",
color="blue"
},
{
x=11,
y=8,
class="static_wall",
color="blue"
},
{
x=11,
y=7,
class="static_wall",
color="blue"
},
{
x=11,
y=6,
class="static_wall",
color="blue"
},
{
x=11,
y=4,
class="static_wall",
color="blue"
},
{
x=11,
y=3,
class="static_wall",
color="blue"
},
{
x=11,
y=2,
class="static_wall",
color="blue"
},
{
x=11,
y=1,
class="static_wall",
color="blue"
},
{
x=10,
y=1,
class="static_wall",
color="blue"
},
{
x=9,
y=1,
class="static_wall",
color="blue"
},
{
x=8,
y=1,
class="static_wall",
color="blue"
},
{
x=7,
y=1,
class="static_wall",
color="blue"
},
{
x=6,
y=1,
class="static_wall",
color="blue"
},
{
x=5,
y=1,
class="static_wall",
color="blue"
},
{
x=4,
y=1,
class="static_wall",
color="blue"
},
{
x=3,
y=1,
class="static_wall",
color="blue"
},
{
x=2,
y=1,
class="static_wall",
color="blue"
},
{
x=1,
y=1,
class="static_wall",
color="blue"
},
{
x=1,
y=2,
class="static_wall",
color="blue"
},
{
x=1,
y=3,
class="static_wall",
color="blue"
},
{
x=1,
y=4,
class="static_wall",
color="blue"
},
{
x=1,
y=5,
class="static_wall",
color="blue"
},
{
x=1,
y=6,
class="static_wall",
color="blue"
},
{
x=1,
y=7,
class="static_wall",
color="blue"
},
{
x=1,
y=8,
class="static_wall",
color="blue"
},
{
x=1,
y=9,
class="static_wall",
color="blue"
},
{
x=1,
y=10,
class="static_wall",
color="blue"
},
{
x=1,
y=11,
class="static_wall",
color="blue"
},
{
x=2,
y=11,
class="static_wall",
color="blue"
},
{
x=3,
y=11,
class="static_wall",
color="blue"
},
{
x=4,
y=11,
class="static_wall",
color="blue"
},
{
x=5,
y=11,
class="static_wall",
color="blue"
},
{
x=6,
y=11,
class="static_wall",
color="blue"
},
{
x=7,
y=11,
class="static_wall",
color="blue"
},
{
x=8,
y=11,
class="static_wall",
color="blue"
},
{
x=9,
y=11,
class="static_wall",
color="blue"
},
{
x=10,
y=11,
class="static_wall",
color="blue"
},
{
x=11,
y=5,
class="static_wall",
color="blue"
},
{
x=6,
y=6,
class="box",
color="blue"
},
{
x=11,
y=11,
class="static_wall",
color="red"
},
{
x=11,
y=10,
class="static_wall",
color="red"
},
{
x=11,
y=9,
class="static_wall",
color="red"
},
{
x=11,
y=8,
class="static_wall",
color="red"
},
{
x=11,
y=7,
class="static_wall",
color="red"
},
{
x=11,
y=6,
class="static_wall",
color="red"
},
{
x=11,
y=4,
class="static_wall",
color="red"
},
{
x=11,
y=3,
class="static_wall",
color="red"
},
{
x=11,
y=2,
class="static_wall",
color="red"
},
{
x=11,
y=1,
class="static_wall",
color="red"
},
{
x=10,
y=1,
class="static_wall",
color="red"
},
{
x=9,
y=1,
class="static_wall",
color="red"
},
{
x=8,
y=1,
class="static_wall",
color="red"
},
{
x=7,
y=1,
class="static_wall",
color="red"
},
{
x=6,
y=1,
class="static_wall",
color="red"
},
{
x=5,
y=1,
class="static_wall",
color="red"
},
{
x=4,
y=1,
class="static_wall",
color="red"
},
{
x=3,
y=1,
class="static_wall",
color="red"
},
{
x=2,
y=1,
class="static_wall",
color="red"
},
{
x=1,
y=1,
class="static_wall",
color="red"
},
{
x=1,
y=2,
class="static_wall",
color="red"
},
{
x=1,
y=3,
class="static_wall",
color="red"
},
{
x=1,
y=4,
class="static_wall",
color="red"
},
{
x=1,
y=5,
class="static_wall",
color="red"
},
{
x=1,
y=6,
class="static_wall",
color="red"
},
{
x=1,
y=7,
class="static_wall",
color="red"
},
{
x=1,
y=8,
class="static_wall",
color="red"
},
{
x=1,
y=9,
class="static_wall",
color="red"
},
{
x=1,
y=10,
class="static_wall",
color="red"
},
{
x=1,
y=11,
class="static_wall",
color="red"
},
{
x=2,
y=11,
class="static_wall",
color="red"
},
{
x=3,
y=11,
class="static_wall",
color="red"
},
{
x=4,
y=11,
class="static_wall",
color="red"
},
{
x=5,
y=11,
class="static_wall",
color="red"
},
{
x=6,
y=11,
class="static_wall",
color="red"
},
{
x=7,
y=11,
class="static_wall",
color="red"
},
{
x=8,
y=11,
class="static_wall",
color="red"
},
{
x=9,
y=11,
class="static_wall",
color="red"
},
{
x=10,
y=11,
class="static_wall",
color="red"
},
{
x=11,
y=5,
class="static_wall",
color="red"
},
{
x=5,
y=6,
class="box",
color="red"
},
{
x=6,
y=8,
class="player",
color="green"
},
{
x=6,
y=8,
class="player",
color="blue"
},
{
x=6,
y=8,
class="player",
color="red"
}
}
}

View file

@ -1,4 +0,0 @@
{
objects={
}
}

View file

@ -1,4 +0,0 @@
{
objects={
}
}

View file

@ -1,4 +0,0 @@
{
objects={
}
}

View file

@ -1,4 +0,0 @@
{
objects={
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,994 +0,0 @@
{
objects={
{
y=10,
x=11,
class="static_wall",
color="red"
},
{
y=9,
x=11,
class="static_wall",
color="red"
},
{
y=8,
x=11,
class="static_wall",
color="red"
},
{
y=7,
x=11,
class="static_wall",
color="red"
},
{
y=5,
x=11,
class="static_wall",
color="red"
},
{
y=4,
x=11,
class="static_wall",
color="red"
},
{
y=3,
x=11,
class="static_wall",
color="red"
},
{
y=2,
x=11,
class="static_wall",
color="red"
},
{
y=1,
x=11,
class="static_wall",
color="red"
},
{
y=1,
x=10,
class="static_wall",
color="red"
},
{
y=1,
x=9,
class="static_wall",
color="red"
},
{
y=1,
x=8,
class="static_wall",
color="red"
},
{
y=1,
x=7,
class="static_wall",
color="red"
},
{
y=1,
x=6,
class="static_wall",
color="red"
},
{
y=1,
x=5,
class="static_wall",
color="red"
},
{
y=1,
x=4,
class="static_wall",
color="red"
},
{
y=1,
x=3,
class="static_wall",
color="red"
},
{
y=1,
x=2,
class="static_wall",
color="red"
},
{
y=1,
x=1,
class="static_wall",
color="red"
},
{
y=2,
x=1,
class="static_wall",
color="red"
},
{
y=3,
x=1,
class="static_wall",
color="red"
},
{
y=4,
x=1,
class="static_wall",
color="red"
},
{
y=5,
x=1,
class="static_wall",
color="red"
},
{
y=6,
x=1,
class="static_wall",
color="red"
},
{
y=8,
x=1,
class="static_wall",
color="red"
},
{
y=9,
x=1,
class="static_wall",
color="red"
},
{
y=10,
x=1,
class="static_wall",
color="red"
},
{
y=11,
x=1,
class="static_wall",
color="red"
},
{
y=11,
x=2,
class="static_wall",
color="red"
},
{
y=11,
x=3,
class="static_wall",
color="red"
},
{
y=11,
x=4,
class="static_wall",
color="red"
},
{
y=11,
x=5,
class="static_wall",
color="red"
},
{
y=11,
x=6,
class="static_wall",
color="red"
},
{
y=11,
x=7,
class="static_wall",
color="red"
},
{
y=11,
x=8,
class="static_wall",
color="red"
},
{
y=11,
x=9,
class="static_wall",
color="red"
},
{
y=11,
x=10,
class="static_wall",
color="red"
},
{
y=11,
x=11,
class="static_wall",
color="red"
},
{
y=2,
x=8,
class="static_wall",
color="red"
},
{
y=3,
x=9,
class="static_wall",
color="red"
},
{
y=4,
x=2,
class="static_wall",
color="red"
},
{
y=2,
x=2,
class="static_wall",
color="red"
},
{
y=2,
x=3,
class="static_wall",
color="red"
},
{
y=5,
x=5,
class="static_wall",
color="red"
},
{
y=2,
x=4,
class="static_wall",
color="red"
},
{
y=2,
x=5,
class="static_wall",
color="red"
},
{
y=2,
x=6,
class="static_wall",
color="red"
},
{
y=2,
x=7,
class="static_wall",
color="red"
},
{
y=3,
x=5,
class="box_3x2_111-010",
color="red"
},
{
y=3,
x=8,
class="static_wall",
color="red"
},
{
y=5,
x=4,
class="static_wall",
color="red"
},
{
y=5,
x=6,
class="static_wall",
color="red"
},
{
y=6,
x=11,
class="door",
color="red"
},
{
y=10,
x=11,
class="static_wall",
color="green"
},
{
y=9,
x=11,
class="static_wall",
color="green"
},
{
y=8,
x=11,
class="static_wall",
color="green"
},
{
y=7,
x=11,
class="static_wall",
color="green"
},
{
y=5,
x=11,
class="static_wall",
color="green"
},
{
y=4,
x=11,
class="static_wall",
color="green"
},
{
y=3,
x=11,
class="static_wall",
color="green"
},
{
y=2,
x=11,
class="static_wall",
color="green"
},
{
y=1,
x=11,
class="static_wall",
color="green"
},
{
y=1,
x=10,
class="static_wall",
color="green"
},
{
y=1,
x=9,
class="static_wall",
color="green"
},
{
y=1,
x=8,
class="static_wall",
color="green"
},
{
y=1,
x=7,
class="static_wall",
color="green"
},
{
y=1,
x=6,
class="static_wall",
color="green"
},
{
y=1,
x=5,
class="static_wall",
color="green"
},
{
y=1,
x=4,
class="static_wall",
color="green"
},
{
y=1,
x=3,
class="static_wall",
color="green"
},
{
y=1,
x=2,
class="static_wall",
color="green"
},
{
y=1,
x=1,
class="static_wall",
color="green"
},
{
y=2,
x=1,
class="static_wall",
color="green"
},
{
y=3,
x=1,
class="static_wall",
color="green"
},
{
y=4,
x=1,
class="static_wall",
color="green"
},
{
y=5,
x=1,
class="static_wall",
color="green"
},
{
y=6,
x=1,
class="static_wall",
color="green"
},
{
y=8,
x=1,
class="static_wall",
color="green"
},
{
y=9,
x=1,
class="static_wall",
color="green"
},
{
y=10,
x=1,
class="static_wall",
color="green"
},
{
y=11,
x=1,
class="static_wall",
color="green"
},
{
y=11,
x=2,
class="static_wall",
color="green"
},
{
y=11,
x=3,
class="static_wall",
color="green"
},
{
y=11,
x=4,
class="static_wall",
color="green"
},
{
y=11,
x=5,
class="static_wall",
color="green"
},
{
y=11,
x=6,
class="static_wall",
color="green"
},
{
y=11,
x=7,
class="static_wall",
color="green"
},
{
y=11,
x=8,
class="static_wall",
color="green"
},
{
y=11,
x=9,
class="static_wall",
color="green"
},
{
y=11,
x=10,
class="static_wall",
color="green"
},
{
y=11,
x=11,
class="static_wall",
color="green"
},
{
y=2,
x=8,
class="static_wall",
color="green"
},
{
y=3,
x=9,
class="static_wall",
color="green"
},
{
y=4,
x=2,
class="static_wall",
color="green"
},
{
y=2,
x=2,
class="static_wall",
color="green"
},
{
y=2,
x=3,
class="static_wall",
color="green"
},
{
y=5,
x=5,
class="static_wall",
color="green"
},
{
y=2,
x=4,
class="static_wall",
color="green"
},
{
y=2,
x=5,
class="static_wall",
color="green"
},
{
y=2,
x=6,
class="static_wall",
color="green"
},
{
y=2,
x=7,
class="static_wall",
color="green"
},
{
y=3,
x=5,
class="box_3x2_111-010",
color="green"
},
{
y=3,
x=8,
class="static_wall",
color="green"
},
{
y=5,
x=4,
class="static_wall",
color="green"
},
{
y=5,
x=6,
class="static_wall",
color="green"
},
{
y=6,
x=11,
class="door",
color="green"
},
{
y=10,
x=11,
class="static_wall",
color="blue"
},
{
y=9,
x=11,
class="static_wall",
color="blue"
},
{
y=8,
x=11,
class="static_wall",
color="blue"
},
{
y=7,
x=11,
class="static_wall",
color="blue"
},
{
y=5,
x=11,
class="static_wall",
color="blue"
},
{
y=4,
x=11,
class="static_wall",
color="blue"
},
{
y=3,
x=11,
class="static_wall",
color="blue"
},
{
y=2,
x=11,
class="static_wall",
color="blue"
},
{
y=1,
x=11,
class="static_wall",
color="blue"
},
{
y=1,
x=10,
class="static_wall",
color="blue"
},
{
y=1,
x=9,
class="static_wall",
color="blue"
},
{
y=1,
x=8,
class="static_wall",
color="blue"
},
{
y=1,
x=7,
class="static_wall",
color="blue"
},
{
y=1,
x=6,
class="static_wall",
color="blue"
},
{
y=1,
x=5,
class="static_wall",
color="blue"
},
{
y=1,
x=4,
class="static_wall",
color="blue"
},
{
y=1,
x=3,
class="static_wall",
color="blue"
},
{
y=1,
x=2,
class="static_wall",
color="blue"
},
{
y=1,
x=1,
class="static_wall",
color="blue"
},
{
y=2,
x=1,
class="static_wall",
color="blue"
},
{
y=3,
x=1,
class="static_wall",
color="blue"
},
{
y=4,
x=1,
class="static_wall",
color="blue"
},
{
y=5,
x=1,
class="static_wall",
color="blue"
},
{
y=6,
x=1,
class="static_wall",
color="blue"
},
{
y=8,
x=1,
class="static_wall",
color="blue"
},
{
y=9,
x=1,
class="static_wall",
color="blue"
},
{
y=10,
x=1,
class="static_wall",
color="blue"
},
{
y=11,
x=1,
class="static_wall",
color="blue"
},
{
y=11,
x=2,
class="static_wall",
color="blue"
},
{
y=11,
x=3,
class="static_wall",
color="blue"
},
{
y=11,
x=4,
class="static_wall",
color="blue"
},
{
y=11,
x=5,
class="static_wall",
color="blue"
},
{
y=11,
x=6,
class="static_wall",
color="blue"
},
{
y=11,
x=7,
class="static_wall",
color="blue"
},
{
y=11,
x=8,
class="static_wall",
color="blue"
},
{
y=11,
x=9,
class="static_wall",
color="blue"
},
{
y=11,
x=10,
class="static_wall",
color="blue"
},
{
y=11,
x=11,
class="static_wall",
color="blue"
},
{
y=2,
x=8,
class="static_wall",
color="blue"
},
{
y=3,
x=9,
class="static_wall",
color="blue"
},
{
y=4,
x=2,
class="static_wall",
color="blue"
},
{
y=2,
x=2,
class="static_wall",
color="blue"
},
{
y=2,
x=3,
class="static_wall",
color="blue"
},
{
y=5,
x=5,
class="static_wall",
color="blue"
},
{
y=2,
x=4,
class="static_wall",
color="blue"
},
{
y=2,
x=5,
class="static_wall",
color="blue"
},
{
y=2,
x=6,
class="static_wall",
color="blue"
},
{
y=2,
x=7,
class="static_wall",
color="blue"
},
{
y=3,
x=5,
class="box_3x2_111-010",
color="blue"
},
{
y=3,
x=8,
class="static_wall",
color="blue"
},
{
y=5,
x=4,
class="static_wall",
color="blue"
},
{
y=5,
x=6,
class="static_wall",
color="blue"
},
{
y=6,
x=11,
class="door",
color="blue"
},
{
y=3,
x=2,
class="switch",
color="red"
},
{
y=3,
x=2,
class="switch",
color="green"
},
{
y=3,
x=2,
class="switch",
color="blue"
},
{
y=4,
x=4,
class="player",
color="red"
},
{
y=4,
x=4,
class="player",
color="green"
},
{
y=4,
x=4,
class="player",
color="blue"
}
}
}

View file

@ -1,795 +0,0 @@
{
objects={
{
y=1,
x=6,
class="door",
color="red"
},
{
y=9,
x=1,
class="static_wall",
color="red"
},
{
y=11,
x=2,
class="static_wall",
color="red"
},
{
y=6,
x=5,
class="static_wall",
color="red"
},
{
y=1,
x=1,
class="static_wall",
color="red"
},
{
y=3,
x=1,
class="static_wall",
color="red"
},
{
y=1,
x=3,
class="static_wall",
color="red"
},
{
y=10,
x=1,
class="static_wall",
color="red"
},
{
y=1,
x=2,
class="static_wall",
color="red"
},
{
y=1,
x=4,
class="static_wall",
color="red"
},
{
y=3,
x=11,
class="static_wall",
color="red"
},
{
y=1,
x=5,
class="static_wall",
color="red"
},
{
y=11,
x=3,
class="static_wall",
color="red"
},
{
y=11,
x=5,
class="static_wall",
color="red"
},
{
y=4,
x=11,
class="static_wall",
color="red"
},
{
y=1,
x=7,
class="static_wall",
color="red"
},
{
y=10,
x=11,
class="static_wall",
color="red"
},
{
y=8,
x=1,
class="static_wall",
color="red"
},
{
y=1,
x=9,
class="static_wall",
color="red"
},
{
y=11,
x=11,
class="static_wall",
color="red"
},
{
y=5,
x=11,
class="static_wall",
color="red"
},
{
y=11,
x=9,
class="static_wall",
color="red"
},
{
y=6,
x=8,
class="static_wall",
color="red"
},
{
y=11,
x=6,
class="static_wall",
color="red"
},
{
y=6,
x=7,
class="static_wall",
color="red"
},
{
y=1,
x=8,
class="static_wall",
color="red"
},
{
y=11,
x=1,
class="static_wall",
color="red"
},
{
y=6,
x=11,
class="static_wall",
color="red"
},
{
y=6,
x=2,
class="static_wall",
color="red"
},
{
y=11,
x=10,
class="static_wall",
color="red"
},
{
y=6,
x=1,
class="static_wall",
color="red"
},
{
y=6,
x=10,
class="static_wall",
color="red"
},
{
y=7,
x=1,
class="static_wall",
color="red"
},
{
y=6,
x=9,
class="static_wall",
color="red"
},
{
y=6,
x=3,
class="static_wall",
color="red"
},
{
y=11,
x=8,
class="static_wall",
color="red"
},
{
y=7,
x=11,
class="static_wall",
color="red"
},
{
y=4,
x=1,
class="static_wall",
color="red"
},
{
y=5,
x=1,
class="static_wall",
color="red"
},
{
y=2,
x=1,
class="static_wall",
color="red"
},
{
y=6,
x=4,
class="static_wall",
color="red"
},
{
y=2,
x=11,
class="static_wall",
color="red"
},
{
y=11,
x=7,
class="static_wall",
color="red"
},
{
y=1,
x=11,
class="static_wall",
color="red"
},
{
y=1,
x=10,
class="static_wall",
color="red"
},
{
y=9,
x=11,
class="static_wall",
color="red"
},
{
y=8,
x=11,
class="static_wall",
color="red"
},
{
y=11,
x=4,
class="static_wall",
color="red"
},
{
y=6,
x=6,
class="box",
color="red"
},
{
y=1,
x=6,
class="door",
color="green"
},
{
y=9,
x=1,
class="static_wall",
color="green"
},
{
y=11,
x=2,
class="static_wall",
color="green"
},
{
y=1,
x=1,
class="static_wall",
color="green"
},
{
y=2,
x=11,
class="static_wall",
color="green"
},
{
y=10,
x=1,
class="static_wall",
color="green"
},
{
y=1,
x=2,
class="static_wall",
color="green"
},
{
y=1,
x=4,
class="static_wall",
color="green"
},
{
y=3,
x=11,
class="static_wall",
color="green"
},
{
y=1,
x=5,
class="static_wall",
color="green"
},
{
y=1,
x=10,
class="static_wall",
color="green"
},
{
y=11,
x=5,
class="static_wall",
color="green"
},
{
y=4,
x=11,
class="static_wall",
color="green"
},
{
y=1,
x=7,
class="static_wall",
color="green"
},
{
y=10,
x=11,
class="static_wall",
color="green"
},
{
y=8,
x=1,
class="static_wall",
color="green"
},
{
y=11,
x=11,
class="static_wall",
color="green"
},
{
y=5,
x=11,
class="static_wall",
color="green"
},
{
y=11,
x=9,
class="static_wall",
color="green"
},
{
y=11,
x=6,
class="static_wall",
color="green"
},
{
y=1,
x=9,
class="static_wall",
color="green"
},
{
y=3,
x=1,
class="static_wall",
color="green"
},
{
y=11,
x=1,
class="static_wall",
color="green"
},
{
y=6,
x=11,
class="static_wall",
color="green"
},
{
y=6,
x=1,
class="static_wall",
color="green"
},
{
y=11,
x=10,
class="static_wall",
color="green"
},
{
y=1,
x=11,
class="static_wall",
color="green"
},
{
y=7,
x=1,
class="static_wall",
color="green"
},
{
y=9,
x=11,
class="static_wall",
color="green"
},
{
y=7,
x=11,
class="static_wall",
color="green"
},
{
y=4,
x=1,
class="static_wall",
color="green"
},
{
y=5,
x=1,
class="static_wall",
color="green"
},
{
y=2,
x=1,
class="static_wall",
color="green"
},
{
y=1,
x=3,
class="static_wall",
color="green"
},
{
y=11,
x=7,
class="static_wall",
color="green"
},
{
y=1,
x=8,
class="static_wall",
color="green"
},
{
y=11,
x=8,
class="static_wall",
color="green"
},
{
y=11,
x=3,
class="static_wall",
color="green"
},
{
y=8,
x=11,
class="static_wall",
color="green"
},
{
y=11,
x=4,
class="static_wall",
color="green"
},
{
y=1,
x=6,
class="door",
color="blue"
},
{
y=9,
x=1,
class="static_wall",
color="blue"
},
{
y=11,
x=2,
class="static_wall",
color="blue"
},
{
y=1,
x=1,
class="static_wall",
color="blue"
},
{
y=2,
x=11,
class="static_wall",
color="blue"
},
{
y=10,
x=1,
class="static_wall",
color="blue"
},
{
y=1,
x=2,
class="static_wall",
color="blue"
},
{
y=1,
x=4,
class="static_wall",
color="blue"
},
{
y=3,
x=11,
class="static_wall",
color="blue"
},
{
y=1,
x=5,
class="static_wall",
color="blue"
},
{
y=1,
x=10,
class="static_wall",
color="blue"
},
{
y=11,
x=5,
class="static_wall",
color="blue"
},
{
y=4,
x=11,
class="static_wall",
color="blue"
},
{
y=1,
x=7,
class="static_wall",
color="blue"
},
{
y=10,
x=11,
class="static_wall",
color="blue"
},
{
y=8,
x=1,
class="static_wall",
color="blue"
},
{
y=11,
x=11,
class="static_wall",
color="blue"
},
{
y=5,
x=11,
class="static_wall",
color="blue"
},
{
y=11,
x=9,
class="static_wall",
color="blue"
},
{
y=11,
x=6,
class="static_wall",
color="blue"
},
{
y=1,
x=9,
class="static_wall",
color="blue"
},
{
y=3,
x=1,
class="static_wall",
color="blue"
},
{
y=11,
x=1,
class="static_wall",
color="blue"
},
{
y=6,
x=11,
class="static_wall",
color="blue"
},
{
y=6,
x=1,
class="static_wall",
color="blue"
},
{
y=11,
x=10,
class="static_wall",
color="blue"
},
{
y=1,
x=11,
class="static_wall",
color="blue"
},
{
y=7,
x=1,
class="static_wall",
color="blue"
},
{
y=9,
x=11,
class="static_wall",
color="blue"
},
{
y=7,
x=11,
class="static_wall",
color="blue"
},
{
y=4,
x=1,
class="static_wall",
color="blue"
},
{
y=5,
x=1,
class="static_wall",
color="blue"
},
{
y=2,
x=1,
class="static_wall",
color="blue"
},
{
y=1,
x=3,
class="static_wall",
color="blue"
},
{
y=11,
x=7,
class="static_wall",
color="blue"
},
{
y=1,
x=8,
class="static_wall",
color="blue"
},
{
y=11,
x=8,
class="static_wall",
color="blue"
},
{
y=11,
x=3,
class="static_wall",
color="blue"
},
{
y=8,
x=11,
class="static_wall",
color="blue"
},
{
y=11,
x=4,
class="static_wall",
color="blue"
},
{
y=4,
x=6,
class="switch",
color="red"
},
{
x=4,
class="npc",
y=8
},
{
x=6,
color="red",
class="player",
y=9
}
}
}

View file

@ -1,207 +1,157 @@
{
width=18,
height=18,
rooms={
{
x=10,
name="start",
y=11,
x=9
},
{
name="3",
y=10
y=10,
x=10
},
{
x=12,
name="5",
y=10
y=10,
x=12
},
{
x=12,
name="village_1",
y=9
y=9,
x=12
},
{
name="village_2",
y=8,
x=12
},
{
x=13,
name="village_3",
y=8
y=8,
x=13
},
{
x=11,
name="village_4",
y=8
y=8,
x=11
},
{
x=11,
name="yellow_1",
y=7
y=7,
x=11
},
{
x=11,
name="yellow_3",
y=6
y=6,
x=11
},
{
x=12,
name="yellow_2",
y=7
y=7,
x=12
},
{
x=11,
name="yellow_4",
y=5
y=5,
x=11
},
{
x=11,
name="yellow_5",
y=4
y=4,
x=11
},
{
x=10,
name="yellow_6",
y=5
y=5,
x=10
},
{
x=1,
name="default",
y=1
y=1,
x=1
},
{
x=3,
name="old_yellow_3",
y=1
y=1,
x=3
},
{
x=5,
name="test_patt",
y=1
y=1,
x=5
},
{
x=7,
name="test_patt_update",
y=1
y=1,
x=7
},
{
x=9,
name="white_asymmetrical",
y=1
y=1,
x=9
},
{
x=11,
name="white_ok",
y=1
y=1,
x=11
},
{
x=13,
name="white_primary",
y=1
y=1,
x=13
},
{
x=15,
name="yellow_arches",
y=1
y=1,
x=15
},
{
x=17,
name="yellow_asymmetrical",
y=1
y=1,
x=17
},
{
x=1,
name="yellow_cc_moustache",
y=3
y=3,
x=1
},
{
x=3,
name="yellow_color_changing_levvel_1",
y=3
y=3,
x=3
},
{
x=7,
name="yellow_smile",
y=3
y=3,
x=7
},
{
x=9,
name="2",
y=10
y=10,
x=9
},
{
x=11,
name="room_11_09",
y=9
y=9,
x=11
},
{
x=9,
name="yellow_skull",
y=3
y=3,
x=9
},
{
x=5,
name="jorge_room",
y=5
y=5,
x=5
},
{
x=11,
name="4",
y=10
},
{
x=1,
name="room_01_05",
y=5
},
{
x=2,
name="ok",
y=8
},
{
x=5,
name="room_05_18",
y=18
},
{
x=9,
name="village_start",
y=11
},
{
x=4,
name="spiky_test",
y=8
},
{
x=5,
name="corner_trimino_test",
y=7
},
{
x=6,
name="tetromino_loop_t",
y=7
},
{
x=5,
name="corner_trimino_test_copy",
y=6
},
{
x=7,
name="room_07_07",
y=7
},
{
x=5,
name="spiky_test_2",
y=8
},
{
x=5,
name="spiky_test_3",
y=9
},
{
x=3,
name="jorge2",
y=14
y=10,
x=11
}
},
lastRoom="jorge2"
lastRoom="yellow_4",
width=18,
height=18
}

View file

@ -58,121 +58,3 @@ glow = love.graphics.newShader[[
return pixel_me;
}
]]
-- 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
-- a palette whose red, green, and yellow accents remain distinct for people
-- with deuteranopia.
accessibilityColorShader = love.graphics.newShader[[
extern number accessibilityTime;
vec3 rgbToHsv(vec3 c) {
float maximum = max(c.r, max(c.g, c.b));
float minimum = min(c.r, min(c.g, c.b));
float delta = maximum - minimum;
float hue = 0.0;
if (delta > 0.0001) {
if (maximum == c.r) {
hue = mod((c.g - c.b) / delta, 6.0);
} else if (maximum == c.g) {
hue = ((c.b - c.r) / delta) + 2.0;
} else {
hue = ((c.r - c.g) / delta) + 4.0;
}
hue = hue / 6.0;
if (hue < 0.0) hue += 1.0;
}
float saturation = maximum <= 0.0 ? 0.0 : delta / maximum;
return vec3(hue, saturation, maximum);
}
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
vec4 pixel = Texel(texture, texture_coords) * color;
vec3 hsv = rgbToHsv(pixel.rgb);
// Preserve greys, white (including additive RGB overlap), and near-black
// background detail. Only colourful puzzle elements are recoloured.
if (hsv.y < 0.22 || hsv.z < 0.06) return pixel;
// Keep every additive pair in its own bucket. In particular, magenta
// (red + blue) must not become the same colour as red, and cyan
// (green + blue) must not become the same colour as green.
vec3 replacement;
float pattern = 0.0;
// Advance in whole pixels so the texture stays crisp instead of swimming.
float ditherStep = floor(accessibilityTime * 1.5);
if (hsv.x < 0.055 || hsv.x >= 0.94) {
replacement = vec3(0.93, 0.18, 0.50); // red: pink
} else if (hsv.x < 0.19) {
replacement = vec3(1.00, 0.78, 0.08); // red + green: gold
vec2 cell = mod(floor(screen_coords + vec2(ditherStep, 0.0)), vec2(9.0));
pattern = length(cell - vec2(4.0)) < 1.6 ? 1.0 : 0.0; // dithered dots
} else if (hsv.x < 0.43) {
replacement = vec3(0.00, 0.72, 0.62); // green: turquoise
} else if (hsv.x < 0.58) {
replacement = vec3(0.12, 0.55, 0.96); // green + blue: sky blue
pattern = mod(floor(screen_coords.x + screen_coords.y + ditherStep), 10.0) < 2.0 ? 1.0 : 0.0; // dithered slashes
} else if (hsv.x < 0.77) {
replacement = vec3(0.38, 0.32, 0.96); // blue: indigo
} else {
replacement = vec3(0.72, 0.25, 0.92); // red + blue: purple
pattern = mod(floor((screen_coords.x + ditherStep) / 4.0) + floor(screen_coords.y / 4.0), 2.0); // dithered checker
}
// Secondary colours get a gently animated dither cue, so they are
// identifiable even when their hue is hard to perceive. Lighting and
// fades remain.
replacement = mix(replacement, vec3(1.0), pattern * 0.55);
return vec4(replacement * hsv.z, pixel.a);
}
]]

View file

@ -1,7 +0,0 @@
Jorge: I'm Jorge. I'm LGBTQ+, and I spent too long hiding that.
* [Be kind] I'm glad you told me.
Jorge smiles. "Thank you. Being met with kindness makes this place feel safer."
* [Mistreat Jorge] That's not something I respect.
Jorge's smile fades. "You can choose cruelty, but you don't get to define me."
- -> END

View file

@ -1 +0,0 @@
return {["constants"]={},["tree"]={["_"]={["_"]={"Jorge: I'm Jorge. I'm LGBTQ+, and I spent too long hiding that.",{["node"]={"Jorge smiles. \"Thank you. Being met with kindness makes this place feel safer.\""},["choice"]="Be kind",["text"]="I'm glad you told me."},{["node"]={"Jorge's smile fades. \"You can choose cruelty, but you don't get to define me.\""},["choice"]="Mistreat Jorge",["text"]="That's not something I respect."},{["divert"]={["path"]="END",["tunnel"]=false}}}}},["inclusions"]={},["params"]={},["version"]={["tree"]=1,["engine"]=2},["variables"]={},["lists"]={}}

View file

@ -1,548 +0,0 @@
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, spiky)
local entity = {
x = x, y = y,
sticky = sticky or false,
skewer = skewer or false,
spiky = spiky 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:isSpiky() return self.spiky 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 moving sticky block pulls a regular block behind it", function()
-- regular, sticky, player; moving right
local regular = movable(2, 3)
local sticky = movable(3, 3, true)
local 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("a spiky block pulls both orthogonal side blocks", function()
local spiky = movable(4, 4, false, nil, false, true)
local upper = movable(4, 3)
local lower = movable(4, 5)
local possible, group = plan(
{spiky, upper, lower}, {x = 4, y = 4}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spiky)
assertIn(group, upper)
assertIn(group, lower)
end)
test("a spiky block releases one blocked side while pulling the other", function()
local spiky = movable(4, 4, false, nil, false, true)
local blockedUpper = movable(4, 3)
local freeLower = movable(4, 5)
local blockingWall = wall(5, 3)
local possible, group = plan(
{spiky, blockedUpper, freeLower, blockingWall},
{x = 4, y = 4}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spiky)
assertNotIn(group, blockedUpper)
assertIn(group, freeLower)
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("an L-shaped sticky pulls from its trailing face in every direction", function()
local lShape = {{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1}}
local cases = {
{direction = {x = 1, y = 0}, trailing = {x = 2, y = 3}},
{direction = {x = -1, y = 0}, trailing = {x = 5, y = 3}},
{direction = {x = 0, y = 1}, trailing = {x = 3, y = 2}},
{direction = {x = 0, y = -1}, trailing = {x = 3, y = 5}},
}
for _, case in ipairs(cases) do
local stickyL = movable(3, 3, true, lShape)
local trailing = movable(case.trailing.x, case.trailing.y)
local possible, group = plan(
{stickyL, trailing}, {x = 3, y = 3}, case.direction
)
assertTrue(possible)
assertIn(group, stickyL)
assertIn(group, trailing)
end
end)
test("an L-shaped sticky can release one blocked pull while completing another", function()
-- Moving right, the two exposed trailing cells touch separate objects. The
-- upper object is pinned against a wall; the lower one is free to follow.
local stickyL = movable(3, 3, true, {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
})
local pinned = movable(2, 2, false, {{x = 0, y = 0}, {x = 0, y = 1}})
local free = movable(2, 4)
local blockingWall = wall(3, 2)
local possible, group = plan(
{stickyL, pinned, free, blockingWall},
{x = 3, y = 3}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, stickyL)
assertIn(group, free)
assertNotIn(group, pinned)
end)
test("a T-shaped sticky pulls independently across a notched trailing face", function()
-- Moving up, three exposed lower faces pull into cells vacated by the T.
local stickyT = movable(3, 3, true, {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 2, y = 0},
{x = 1, y = 1},
})
local trailingLeft = movable(3, 4)
local trailingRight = movable(5, 4)
local trailingStem = movable(4, 5)
local possible, group = plan(
{stickyT, trailingLeft, trailingRight, trailingStem},
{x = 3, y = 3}, {x = 0, y = -1}
)
assertTrue(possible)
assertIn(group, stickyT)
assertIn(group, trailingLeft)
assertIn(group, trailingRight)
assertIn(group, trailingStem)
end)
test("an L-shaped spiky block grabs moveables across several exposed faces", function()
local spikyL = movable(3, 3, false, {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
}, false, true)
local above = movable(3, 2)
local below = movable(3, 5)
local behind = movable(2, 3)
local possible, group = plan(
{spikyL, above, below, behind},
{x = 3, y = 3}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spikyL)
assertIn(group, above)
assertIn(group, below)
assertIn(group, behind)
end)
test("production sticky, skewer, and spiky 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"
require "generic_spiky"
local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end}
local regular = GenericMoveable:new(2, 2, "red", sprite, "box")
local sticky = GenericSticky:new(3, 2, "red", sprite, "sticky_box")
local skewer = GenericSkewer:new(4, 2, "red", sprite, "skewer_box")
local spiky = GenericSpiky:new(5, 2, "red", sprite, "spiky_box")
assertTrue(regular:canMove())
assertFalse(regular:isSticky())
assertTrue(sticky:canMove())
assertTrue(sticky:isSticky())
assertFalse(sticky:isSkewer())
assertTrue(skewer:canMove())
assertFalse(skewer:isSticky())
assertTrue(skewer:isSkewer())
assertFalse(skewer:isSpiky())
assertTrue(spiky:canMove())
assertFalse(spiky:isSticky())
assertFalse(spiky:isSkewer())
assertTrue(spiky:isSpiky())
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 pulls sticky out of a pinned concave moveable", function()
loadRoomClass()
-- Before: ___ After: p__
-- px_ sx_
-- sm_ _m_
-- mm_ mm_
local player = movable(1, 2)
local wall = wall(2, 2)
local sticky = movable(1, 3, true)
local concave = movable(1, 3, false, {
{x = 1, y = 0},
{x = 0, y = 1}, {x = 1, y = 1},
})
local room = roomWith(player, {wall, sticky, concave})
room:movePlayer("up")
assertTrue(player.x == 1 and player.y == 1)
assertTrue(sticky.x == 1 and sticky.y == 2, "sticky should peel away from the concave block")
assertTrue(concave.x == 1 and concave.y == 3, "pinned concave block should stay put")
assertTrue(wall.x == 2 and wall.y == 2)
end)
test("Room moves an L-shaped sticky and both available trailing pulls", function()
loadRoomClass()
local player = movable(3, 2)
local stickyL = movable(3, 3, true, {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
})
local trailingLeft = movable(3, 5)
local trailingRight = movable(4, 4)
local room = roomWith(player, {stickyL, trailingLeft, trailingRight})
room:movePlayer("up")
assertTrue(player.x == 3 and player.y == 1)
assertTrue(stickyL.x == 3 and stickyL.y == 2)
assertTrue(trailingLeft.x == 3 and trailingLeft.y == 4)
assertTrue(trailingRight.x == 4 and trailingRight.y == 3)
end)
test("Room shears a lateral sticky contact", function()
loadRoomClass()
local sticky = movable(3, 2, true)
local regular = movable(3, 3)
local player = movable(4, 3)
local room = roomWith(player, {sticky, regular})
room:movePlayer("right")
assertTrue(sticky.x == 3, "lateral sticky block should shear and stay behind")
assertTrue(regular.x == 4)
assertTrue(player.x == 5)
end)
test("Room never moves a component found by push and pull twice", function()
loadRoomClass()
local player = movable(4, 4)
local front = movable(5, 4)
local bridgeRight = movable(5, 3, false, nil, true)
local bridgeMiddle = movable(4, 3, false, nil, true)
local bridgeLeft = movable(3, 3, false, nil, true)
local 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("Room pushes spiky onward while a blocked side shears off", function()
loadRoomClass()
local player = movable(3, 4)
local spiky = movable(4, 4, false, nil, false, true)
local blockedUpper = movable(4, 3)
local freeLower = movable(4, 5)
local blockingWall = wall(5, 3)
local room = roomWith(player, {spiky, blockedUpper, freeLower, blockingWall})
room:movePlayer("right")
assertTrue(player.x == 4 and player.y == 4)
assertTrue(spiky.x == 5 and spiky.y == 4)
assertTrue(blockedUpper.x == 4 and blockedUpper.y == 3)
assertTrue(freeLower.x == 5 and freeLower.y == 5)
assertTrue(blockingWall.x == 5 and blockingWall.y == 3)
end)
test("every moveable base box gets all three synthetic behavior variants", function()
local BoxVariants = require "box_variants"
local boxSprites = {red = "box_r.png"}
local boxCells = {red = {{x = 1, y = 1}}}
local shapeSprites = {blue = "box_shape_b.png"}
local shapeCells = {blue = {{x = 1, y = 1}, {x = 2, y = 1}}}
local lSprites = {green = "box_l_g.png"}
local lCells = {green = {{x = 1, y = 1}, {x = 1, y = 2}, {x = 2, y = 2}}}
local assets = {
box = {class = "moveable", sprites = boxSprites, cells = boxCells},
["box_2x1_11"] = {class = "moveable", sprites = shapeSprites, cells = shapeCells},
["box_2x2_10-11"] = {class = "moveable", sprites = lSprites, cells = lCells},
box_statue = {class = "immoveable", sprites = {}},
beer_mug = {class = "moveable", sprites = {}},
}
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["sticky_box_2x2_10-11"].sprites == lSprites)
assertTrue(assets["sticky_box_2x2_10-11"].cells == lCells)
assertTrue(assets.skewer_box.class == "skewer")
assertTrue(assets.skewer_box.directory == "skewer")
assertTrue(assets.skewer_box.synthetic)
assertTrue(assets.skewer_box.skewer)
assertTrue(assets.skewer_box.sprites == boxSprites)
assertTrue(assets.skewer_box.cells == boxCells)
assertTrue(assets["skewer_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["skewer_box_2x2_10-11"].cells == lCells)
assertTrue(assets.spiky_box.class == "spiky")
assertTrue(assets.spiky_box.directory == "spiky")
assertTrue(assets.spiky_box.synthetic)
assertTrue(assets.spiky_box.spiky)
assertTrue(assets.spiky_box.sprites == boxSprites)
assertTrue(assets.spiky_box.cells == boxCells)
assertTrue(assets["spiky_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["spiky_box_2x2_10-11"].cells == lCells)
assertFalse(assets.sticky_box_statue)
assertFalse(assets.sticky_beer_mug)
assertFalse(assets.skewer_box_statue)
assertFalse(assets.skewer_beer_mug)
assertFalse(assets.spiky_box_statue)
assertFalse(assets.spiky_beer_mug)
end)
print("movement/block-variant tests passed: " .. passed)

View file

@ -171,16 +171,7 @@ function World:drawRoomPreview(name, x, y, size)
local path = props.sprites[color]
if path then
love.graphics.setColor(gColor[color]:set())
local sprite = loadSprite(path)
if props.goopy and drawGoopySprite then
drawGoopySprite(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
elseif props.spiky and drawSpikySprite then
drawSpikySprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
else
love.graphics.draw(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
end
love.graphics.draw(loadSprite(path), x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
end
end
end
@ -189,30 +180,6 @@ function World:drawRoomPreview(name, x, y, size)
love.graphics.setScissor()
end
function World:drawHoveredRoomName()
local mouseX, mouseY = love.mouse.getPosition()
local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)
local name = self:isInBounds(cellX, cellY) and self:roomAt(cellX, cellY)
if not name then return end
local font = love.graphics.getFont()
local padding = 6
local tooltipW = math.min(font:getWidth(name) + padding * 2, width - 8)
local tooltipH = font:getHeight() + padding * 2
local tooltipX = math.min(mouseX + 14, width - tooltipW - 4)
local tooltipY = math.min(mouseY + 14, height - tooltipH - 4)
tooltipX = math.max(4, tooltipX)
tooltipY = math.max(4, tooltipY)
-- Keep the label anchored beside the pointer, while keeping it entirely in
-- the playable map rather than allowing it to spill into the editor panel.
love.graphics.setColor(0, 0, 0, 0.9)
love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.printf(name, tooltipX + padding, tooltipY + padding, tooltipW - padding * 2, "left")
end
function World:draw()
local cell = width / self.width
love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
@ -226,5 +193,4 @@ function World:draw()
love.graphics.rectangle("line", screenX, screenY, cell, cell)
end
end
self:drawHoveredRoomName()
end