Compare commits

...

12 commits

Author SHA1 Message Date
Your Name
0618cfc51d jorge level update 2026-08-11 20:10:46 -04:00
Your Name
0d05e83c18 Add Jorge dialogable NPC 2026-08-11 20:10:03 -04:00
Your Name
e5e80c2754 Save editor player positions by default 2026-08-11 20:09:07 -04:00
Your Name
c3a2692351 Add spiky box variants 2026-08-11 20:08:58 -04:00
Your Name
761214fe3e new level experiments 2026-08-11 19:16:22 -04:00
Your Name
d426306c94 Clear runtime saves after editor save 2026-08-11 16:37:11 -04:00
Your Name
858775fdc8 Improve world editor palette workflow 2026-08-11 16:24:51 -04:00
Your Name
4cb536d6c8 feat: add tetromino loop level 2026-08-11 15:07:46 -04:00
Your Name
f549eaf523 Add directional sticky and skewer box variants 2026-08-11 15:07:11 -04:00
Your Name
c3f5aafd77 Add drag painting to level editor 2026-08-11 14:11:57 -04:00
Your Name
fa0bada313 Add color blind accessibility filter 2026-08-11 14:08:36 -04:00
Your Name
b42569eb9c Prevent unavailable color switching crash 2026-08-11 13:43:23 -04:00
38 changed files with 11425 additions and 986 deletions

View file

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

Binary file not shown.

BIN
art/npc/jorge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
art/npc/jorge_b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
art/npc/jorge_g.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
art/npc/jorge_r.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
art/npc/jorge_reduced.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

37
box_variants.lua Normal file
View file

@ -0,0 +1,37 @@
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,6 +14,9 @@ function Entity:initialize(t)
self.name = t.name self.name = t.name
self.color = Color:new(t.color) self.color = Color:new(t.color)
self.collision = t.collision --"none", "moveable", "unmoveable" 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.sprite = repSprite
self.shaAmount = t.sha or 1 self.shaAmount = t.sha or 1
-- onBump is a method (see Entity:onBump) so mixins like Dialogable can -- onBump is a method (see Entity:onBump) so mixins like Dialogable can
@ -134,6 +137,18 @@ function Entity:canMove()
return false return false
end 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() function Entity:canPassOver()
if self:getCollision() == "none" then if self:getCollision() == "none" then
return true return true

92
generic_skewer.lua Normal file
View file

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

46
generic_spiky.lua Normal file
View file

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

42
generic_sticky.lua Normal file
View file

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

View file

@ -3,7 +3,7 @@ require "level_editor/gui"
require "level_editor/layout" require "level_editor/layout"
editorView = "room" --can be "off", "room", "world", "asset" editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "red" selectedColor = "white"
local bacSprite = love.graphics.newImage("art/_ui/bac.png") local bacSprite = love.graphics.newImage("art/_ui/bac.png")
local folderSprite = love.graphics.newImage("art/_ui/folder.png") local folderSprite = love.graphics.newImage("art/_ui/folder.png")
@ -34,8 +34,6 @@ local roomNameField = nil
local editorStatus = nil local editorStatus = nil
local clearSaveConfirmationExpires = 0 local clearSaveConfirmationExpires = 0
local clearSaveConfirmationScope = nil local clearSaveConfirmationScope = nil
local worldSeedConfirmationExpires = 0
local worldMode = "place"
local worldRoomButtons = {} local worldRoomButtons = {}
local worldControls = {} local worldControls = {}
local worldPage = 1 local worldPage = 1
@ -44,6 +42,61 @@ local assetSearchText = ""
local assetSearchButtons = {} local assetSearchButtons = {}
local worldSearchText = "" local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 } 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 ──────────────────────────────────────────────── -- ── Room-view sidebar layout ────────────────────────────────────────────────
-- Declared up here (not next to editorDraw) so editorMouseHandler, defined -- Declared up here (not next to editorDraw) so editorMouseHandler, defined
@ -56,6 +109,120 @@ local function currentRoomList()
else return assetListButtons end else return assetListButtons end
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 -- 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, -- 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 -- and a flexible list filling whatever is left. Recomputed every frame so it
@ -63,7 +230,7 @@ end
local function roomSections() local function roomSections()
local pad = 10 local pad = 10
local w = math.max(0, sb.w - pad * 2) local w = math.max(0, sb.w - pad * 2)
local controlsH = 104 local controlsH = 130
local statusH = 44 local statusH = 44
local search = { x = pad, y = pad, w = w, h = 34 } local search = { x = pad, y = pad, w = w, h = 34 }
local controls = { x = pad, y = sb.h - pad - controlsH, w = w, h = controlsH } local controls = { x = pad, y = sb.h - pad - controlsH, w = w, h = controlsH }
@ -96,6 +263,13 @@ local function layoutRoomControls(c, s)
b.scale = target / b.sprite:getHeight() b.scale = target / b.sprite:getHeight()
b.w = b.sprite:getWidth() * b.scale b.w = b.sprite:getWidth() * b.scale
b.h = b.sprite:getHeight() * 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
end end
return b return b
end end
@ -119,6 +293,7 @@ local function layoutRoomControls(c, s)
end end
placeRow({ "white", "red", "green", "blue" }, 22) placeRow({ "white", "red", "green", "blue" }, 22)
placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world" }, 28) placeRow({ "save", "clearSaves", "world" }, 28)
end end
@ -127,7 +302,7 @@ end
local function buildRoomLayout() local function buildRoomLayout()
local s = roomSections() local s = roomSections()
local showBack = assetSearchText ~= "" or selectedFolder local showBack = assetSearchText ~= "" or selectedFolder or boxPaletteOpen
local sx, sw = s.search.x, s.search.w local sx, sw = s.search.x, s.search.w
if showBack then if showBack then
s.back = { x = s.search.x, y = s.search.y, w = s.search.h, h = s.search.h } s.back = { x = s.search.x, y = s.search.y, w = s.search.h, h = s.search.h }
@ -136,8 +311,12 @@ local function buildRoomLayout()
end end
s.searchBox = { x = sx, y = s.search.y, w = sw, h = s.search.h } 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.items = currentRoomList()
s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2) s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2)
end
layoutRoomControls(s.controls, s) layoutRoomControls(s.controls, s)
return s return s
@ -145,7 +324,8 @@ end
-- Mouse wheel scrolls the room list; clamped by Layout.list next frame. -- Mouse wheel scrolls the room list; clamped by Layout.list next frame.
function editorScrollRoomList(dy) function editorScrollRoomList(dy)
listScroll.scroll = math.max(0, (listScroll.scroll or 0) - dy * 40) local scroll = boxPaletteOpen and boxPaletteScroll or listScroll
scroll.scroll = math.max(0, (scroll.scroll or 0) - dy * 40)
end end
-- ──────────────────────────────────────────────────────────────────────────── -- ────────────────────────────────────────────────────────────────────────────
@ -182,6 +362,10 @@ local function rebuildAssetSearchButtons()
for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end
table.insert(assetSearchButtons, createUIElement{ table.insert(assetSearchButtons, createUIElement{
sprites = sprites, sprites = sprites,
goopy = gameAssets[asset].goopy,
skewer = gameAssets[asset].skewer,
spiky = gameAssets[asset].spiky,
cells = gameAssets[asset].cells,
name = "asset", name = "asset",
text = asset, text = asset,
x = 30, x = 30,
@ -210,21 +394,6 @@ local function rebuildWorldRoomButtons()
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 }) table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 })
end end
worldControls = { 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, { text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sb.h - 110, w = sb.w - 20, h = 34,
onClic = function(buttonX) onClic = function(buttonX)
if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end
@ -235,10 +404,20 @@ end
local function drawWorldSidebar() local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set()) 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 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.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") love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left")
end
for _, button in ipairs(worldRoomButtons) do 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.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) love.graphics.rectangle("line", button.x, button.y, button.w, button.h)
@ -290,8 +469,8 @@ local function saveRoomFromEditor()
return return
end end
local savePlayerPositions = love.keyboard.isDown("lshift", "rshift") local savePlayerPositions = not love.keyboard.isDown("lshift", "rshift")
if not currentRoom:saveToSource(name, savePlayerPositions, oldName) then if not currentRoom:saveToSource(name, savePlayerPositions) then
editorStatus = "Could not save '" .. name .. "'." editorStatus = "Could not save '" .. name .. "'."
return return
end end
@ -302,13 +481,18 @@ local function saveRoomFromEditor()
removeFromSource("rooms/" .. oldName .. ".sav") removeFromSource("rooms/" .. oldName .. ".sav")
gameWorld:renameRoom(oldName, name) gameWorld:renameRoom(oldName, name)
end 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) gameWorld:invalidatePreview(name)
if editorView == "world" then rebuildWorldRoomButtons() end if editorView == "world" then rebuildWorldRoomButtons() end
currentRoom.name = name currentRoom.name = name
editorRoomName = name editorRoomName = name
roomNameField:editText(name) roomNameField:editText(name)
activeField = nil activeField = nil
editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or ".") editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or " without player positions.")
end end
local function clearRuntimeSaves() local function clearRuntimeSaves()
@ -328,6 +512,7 @@ local function clearRuntimeSaves()
end end
local removed = clearRuntimeSaveData(clearAll and nil or roomName) local removed = clearRuntimeSaveData(clearAll and nil or roomName)
suppressRuntimeAutosave = true
clearSaveConfirmationExpires = 0 clearSaveConfirmationExpires = 0
clearSaveConfirmationScope = nil clearSaveConfirmationScope = nil
if roomName then if roomName then
@ -342,6 +527,7 @@ end
function _initLevelEditor() function _initLevelEditor()
gameAssets = getAllAssets() gameAssets = getAllAssets()
rebuildBoxPaletteSections()
editorRoomName = "" editorRoomName = ""
--create folder buttons --create folder buttons
@ -440,6 +626,25 @@ function _initLevelEditor()
}, color) }, color)
end 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{ roomNameField = createUIElement{
name = "roomName", name = "roomName",
text = editorRoomName, text = editorRoomName,
@ -464,6 +669,7 @@ end
function selectFolder(folder) function selectFolder(folder)
selectedFolder = folder selectedFolder = folder
boxPaletteOpen = false
listScroll.scroll = 0 listScroll.scroll = 0
assetListButtons = {} assetListButtons = {}
@ -478,13 +684,19 @@ function selectFolder(folder)
-- each frame from the list section (see buildRoomLayout). -- each frame from the list section (see buildRoomLayout).
table.insert(assetListButtons, createUIElement{ table.insert(assetListButtons, createUIElement{
sprites = sprites, sprites = sprites,
goopy = gameAssets[asset].goopy,
skewer = gameAssets[asset].skewer,
spiky = gameAssets[asset].spiky,
cells = gameAssets[asset].cells,
name = "asset", name = "asset",
text = asset, text = asset,
x = 30, x = 30,
y = 0, y = 0,
onClic = { onClic = {
left = function() selectAsset(asset) end, left = function() selectAsset(asset) end,
right = function() editAsset(asset) end, right = function()
if not gameAssets[asset].synthetic then editAsset(asset) end
end,
} }
}) })
end end
@ -496,6 +708,12 @@ function selectAsset(asset)
print(selectedAsset) print(selectedAsset)
end end
function selectQuickAsset(asset)
selectAsset(asset)
boxPaletteOpen = asset == "box"
boxPaletteScroll.scroll = 0
end
function editAsset(asset) function editAsset(asset)
inputText = "" inputText = ""
editorView = "asset" editorView = "asset"
@ -541,6 +759,10 @@ function editAsset(asset)
local assetButton = createUIElement{ local assetButton = createUIElement{
name = asset, name = asset,
sprites = assetSprites, sprites = assetSprites,
goopy = assetProperties.goopy,
skewer = assetProperties.skewer,
spiky = assetProperties.spiky,
cells = assetProperties.cells,
x = sb.w / 2 - 18, x = sb.w / 2 - 18,
y = 80, y = 80,
} }
@ -619,7 +841,8 @@ function worldView()
editorView = "world" editorView = "world"
activeField = nil activeField = nil
selectedFolder = nil selectedFolder = nil
worldMode = "place" selectedRoom = nil
cloneSourceRoom = nil
worldPage = 1 worldPage = 1
rebuildWorldRoomButtons() rebuildWorldRoomButtons()
end end
@ -634,6 +857,36 @@ local function newWorldRoomName(cellX, cellY)
return name return name
end 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 function openWorldRoom(cellX, cellY)
local name = gameWorld:roomAt(cellX, cellY) local name = gameWorld:roomAt(cellX, cellY)
if not name then if not name then
@ -673,24 +926,36 @@ function editorMouseHandler(x, y, button)
rebuildAssetSearchButtons() rebuildAssetSearchButtons()
elseif selectedFolder then elseif selectedFolder then
folderView() folderView()
elseif boxPaletteOpen then
boxPaletteOpen = false
boxPaletteScroll.scroll = 0
end end
return return
end end
if Layout.hit(s.searchBox, x, y) then if Layout.hit(s.searchBox, x, y) then
boxPaletteOpen = false
beginSearch("assetSearch") beginSearch("assetSearch")
return return
end 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 -- scrolling list item
local i = s.list.at(x, y) local i = s.list.at(x, y)
if i and s.items[i] then if i and s.items[i] then
s.items[i]:onClic(button) s.items[i]:onClic(button)
return return
end end
end
-- control cluster (only the buttons that live there) -- control cluster (only the buttons that live there)
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do 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 b = uiButtons[name] local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then if b and b:clicIsInBox(x, y) then
b:onClic(button) b:onClic(button)
@ -704,18 +969,20 @@ function editorMouseHandler(x, y, button)
end end
end end
elseif editorView == "world" then elseif editorView == "world" then
if activeField == "cloneName" then return end
if buttonContains(worldSearchBox, x, y) then if buttonContains(worldSearchBox, x, y) then
beginSearch("worldSearch") beginSearch("worldSearch")
return return
end end
for _, roomButton in ipairs(worldRoomButtons) do for _, roomButton in ipairs(worldRoomButtons) do
if buttonContains(roomButton, x, y) then if buttonContains(roomButton, x, y) then
if button ~= 1 then return end
if selectedRoom == roomButton.name then if selectedRoom == roomButton.name then
selectedRoom = nil selectedRoom = nil
editorStatus = "Room brush cleared. Click the map to open or create a level." editorStatus = "Placement cancelled. Click the map to open or create a level."
else else
selectedRoom = roomButton.name selectedRoom = roomButton.name
editorStatus = "Selected '" .. selectedRoom .. "' for placement. Click it again to clear." editorStatus = "Selected '" .. selectedRoom .. "'. Click a map cell to place it."
end end
return return
end end
@ -730,24 +997,53 @@ function editorMouseHandler(x, y, button)
end end
else else
-- click landed on the grid (game area): place the selected asset -- click landed on the grid (game area): place the selected asset
if editorView == "room" and button == 1 and selectedAsset then if editorView == "room" then
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor) beginRoomBrush(x, y, button)
elseif editorView == "world" then elseif editorView == "world" then
local cellX, cellY = gameWorld:getCellFromMousePos(x, y) local cellX, cellY = gameWorld:getCellFromMousePos(x, y)
if not gameWorld:isInBounds(cellX, cellY) then return end if not gameWorld:isInBounds(cellX, cellY) then return end
if button == 2 then if button == 2 then
local removed, name = gameWorld:removeRoom(cellX, cellY) local name = gameWorld:roomAt(cellX, cellY)
if removed then editorStatus = "Removed '" .. name .. "' from the world." end if name then beginCloneDialog(name) end
elseif worldMode == "jump" or not selectedRoom then
openWorldRoom(cellX, cellY)
elseif selectedRoom then elseif selectedRoom then
gameWorld:placeRoom(selectedRoom, cellX, cellY) local placedRoom = selectedRoom
editorStatus = "Placed '" .. selectedRoom .. "' at " .. cellX .. ", " .. cellY .. "." if gameWorld:placeRoom(placedRoom, cellX, cellY) then
selectedRoom = nil
editorStatus = "Placed '" .. placedRoom .. "' at " .. cellX .. ", " .. cellY .. "."
else
editorStatus = "Could not place '" .. placedRoom .. "'."
end
else
openWorldRoom(cellX, cellY)
end end
end end
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) function editorTextHandler(text)
if editorView == "asset" and activeField then if editorView == "asset" and activeField then
inputText = text inputText = text
@ -764,6 +1060,8 @@ function editorTextHandler(text)
worldSearchText = text worldSearchText = text
worldPage = 1 worldPage = 1
rebuildWorldRoomButtons() rebuildWorldRoomButtons()
elseif editorView == "world" and activeField == "cloneName" then
inputText = text
end end
end end
@ -776,10 +1074,32 @@ function editorCompleteResponse()
assetSearchText = inputText assetSearchText = inputText
elseif editorView == "world" and activeField == "worldSearch" then elseif editorView == "world" and activeField == "worldSearch" then
worldSearchText = inputText 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 end
activeField = nil activeField = nil
end 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() function sortAllAssets()
setAll() setAll()
end end
@ -796,29 +1116,51 @@ function editorDraw()
-- header: optional back button + search box -- header: optional back button + search box
if s.back then if s.back then
love.graphics.rectangle("line", s.back.x, s.back.y, s.back.w, s.back.h) 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() local sc = (s.back.h - 8) / bacSprite:getHeight()
love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc) love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc)
end end
end
love.graphics.rectangle("line", s.searchBox.x, s.searchBox.y, s.searchBox.w, s.searchBox.h) love.graphics.rectangle("line", s.searchBox.x, s.searchBox.y, s.searchBox.w, s.searchBox.h)
love.graphics.printf("SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left") love.graphics.printf(boxPaletteOpen and "BOX VARIANTS" or "SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left")
-- scrolling list, clipped to its section so nothing bleeds into others if s.boxPicker then
drawBoxPicker(s.boxPicker)
else
-- Scrolling list, clipped to its section so nothing bleeds into others.
Layout.clip(s.listRect) Layout.clip(s.listRect)
for i, item in ipairs(s.items) do for i, item in ipairs(s.items) do
if s.list.visible(i) then if s.list.visible(i) then
item.x, item.y = s.list.pos(i) item.x, item.y = s.list.pos(i)
item.w = s.listRect.w item.w = s.listRect.w
item:draw() 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 end
end end
Layout.unclip() Layout.unclip()
Layout.scrollbar(s.list) Layout.scrollbar(s.list)
end
-- control cluster -- control cluster
love.graphics.setColor(gColor.white:set()) love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center") love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center")
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do
if uiButtons[name] then uiButtons[name]:draw() end 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
end end
-- status strip between the list and the controls -- status strip between the list and the controls

View file

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

View file

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

View file

@ -23,6 +23,7 @@ require "level_editor/editor"
love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. ";libs/narrator/?.lua") love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. ";libs/narrator/?.lua")
narrator = require "narrator.narrator" narrator = require "narrator.narrator"
startSignBook = require "stories.start_sign" startSignBook = require "stories.start_sign"
jorgeBook = require "stories.jorge"
function love.load() function love.load()
globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets
@ -31,6 +32,7 @@ function love.load()
_initGridVars() _initGridVars()
_initSounds() _initSounds()
_initGlobalColors() _initGlobalColors()
_initAccessibility()
gameWorld = World:new() gameWorld = World:new()
_initLevelEditor() _initLevelEditor()
@ -54,7 +56,9 @@ end
-- Autosave on close so a position reached by moving within a room (short of a -- 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. -- room-to-room transition, which already autosaves in nextRoom) survives.
function love.quit() function love.quit()
if currentRoom then -- An explicit editor save/clear removes the runtime copy intentionally;
-- don't immediately recreate it while shutting down.
if currentRoom and not suppressRuntimeAutosave then
currentRoom:save() currentRoom:save()
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end if gameWorld then gameWorld:setLastRoom(currentRoom.name) end
end end
@ -116,16 +120,42 @@ function love.draw()
love.graphics.pop() love.graphics.pop()
love.graphics.setCanvas() 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.setBlendMode("alpha")
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(finalCanvas, 0, 0) love.graphics.draw(finalCanvas, 0, 0)
love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y)
love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10)
Dialogue.draw() 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 end
function love.keypressed(key) 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. -- Talking to a sign captures input: navigate/choose, don't move the player.
if Dialogue.isOpen() then if Dialogue.isOpen() then
Dialogue.keypressed(key) Dialogue.keypressed(key)
@ -150,6 +180,8 @@ function love.keypressed(key)
if key == "return" then if key == "return" then
response = "" response = ""
editorCompleteResponse() editorCompleteResponse()
elseif key == "escape" and editorCancelResponse() then
return
elseif key == "backspace" then elseif key == "backspace" then
local byteoffset = utf8.offset(response, -1) local byteoffset = utf8.offset(response, -1)
@ -163,12 +195,17 @@ function love.keypressed(key)
end end
function love.mousepressed(x, y, button) function love.mousepressed(x, y, button)
if editorView == "room" and button == 2 then
currentRoom:attemptDelete(x, y, selectedColor)
end
editorMouseHandler(x, y, button) editorMouseHandler(x, y, button)
end 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) function love.textinput(t)
response = response .. t response = response .. t
editorTextHandler(response) editorTextHandler(response)
@ -237,6 +274,7 @@ end
function _initCanvases() function _initCanvases()
gameCanvas = love.graphics.newCanvas() gameCanvas = love.graphics.newCanvas()
finalCanvas = 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 = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h))
sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled
end end
@ -253,6 +291,37 @@ function _initGlobalColors()
gColor.red, gColor.green, gColor.blue, gColor.white = Color:new("red"), Color:new("green"), Color:new("blue"), Color:new("white") gColor.red, gColor.green, gColor.blue, gColor.white = Color:new("red"), Color:new("green"), Color:new("blue"), Color:new("white")
end 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() function _initSounds()
staticWave = sfxr.newSound() staticWave = sfxr.newSound()
staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr

143
movement.lua Normal file
View file

@ -0,0 +1,143 @@
-- 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,3 +20,21 @@ function Sign:initialize(x, y)
}, },
}) })
end 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

192
room.lua
View file

@ -10,8 +10,13 @@ require "switch"
require "door" require "door"
require "dialogue_manager" require "dialogue_manager"
require "generic_moveable" require "generic_moveable"
require "generic_sticky"
require "generic_skewer"
require "generic_spiky"
require "generic_unmoveable" require "generic_unmoveable"
local Movement = require "movement"
function Room:initialize(t) function Room:initialize(t)
self.player = {} self.player = {}
self.switches = { red = {}, green = {}, blue = {} } self.switches = { red = {}, green = {}, blue = {} }
@ -202,7 +207,7 @@ function Room:movePlayer(key)
end end
local move = true local move = true
-- PUSHHHHHHHHHHHHHHHHHHHHHH--------------------- -- PUSH ---------------------------------------------------------------
local initialTargetCell = { local initialTargetCell = {
x = pos.x + dir.x, y = pos.y + dir.y x = pos.x + dir.x, y = pos.y + dir.y
@ -219,104 +224,24 @@ function Room:movePlayer(key)
if not self:isInBounds(initialTargetCell) then if not self:isInBounds(initialTargetCell) then
-- let's us move out of bounds to Change Rooms without pulling a bloc_ -- let's us move out of bounds to Change Rooms without pulling a bloc_
move = true move = true
elseif initialTargetEntity and initialTargetEntity:canPassOver() then
--we should put this in the else statement actually
move = true
else else
local targetCells move, entitiesToPush = Movement.findMovableGroup(
{initialTargetCell}, dir,
targetCells = {initialTargetCell} -- ...we start with our attempted movement... function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
local doneSearching function(cell) return self:isInBounds(cell) end
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 end
--if every projected cell is empty then we can do the move -- PULL ---------------------------------------------------------------
if #emptyCells == #targetCells then
move = true
doneSearching = true
end
targetCells = nextTargetCells
end
--let's get out of this loop b/c if we can't move one color we can't move at all
end
---PULL CHEC
local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y} local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y}
local pull = false
local targetCells if self:isInBounds(initialTargetCell) then
pull, entitiesToPull = Movement.findMovableGroup(
local pull {initialTargetCell}, dir,
--for each color... function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
targetCells = {initialTargetCell} -- ...we start with our attempted movement... function(cell) return self:isInBounds(cell) end
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 end
if move then if move then
@ -325,9 +250,13 @@ function Room:movePlayer(key)
end end
if pull == true then if pull == true then
for _, entity in pairs(entitiesToPull) do 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]) entity:move(axis, dir[axis])
end end
end end
end
self.player[color]:move(axis, dir[axis]) self.player[color]:move(axis, dir[axis])
moved = true moved = true
end end
@ -341,21 +270,21 @@ function Room:movePlayer(key)
end end
function Room:switchPlayerColor(color) function Room:switchPlayerColor(color)
local canSwitch -- 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
if self:onlyColorIsActive(color) then local canSwitch = not self:onlyColorIsActive(color)
canSwitch = false if canSwitch then
else for _, c in ipairs(self:getPossibleColors()) do
for _, c in pairs(self:getPossibleColors()) do self:setActiveColor(c, c == color)
if c == color then
self:setActiveColor(c, true)
else
self:setActiveColor(c, false)
end
end end
end end
self.player[color]:playSwitchSound(canSwitch) targetPlayer:playSwitchSound(canSwitch)
return canSwitch
end end
function Room:setActiveColor(color, bool) function Room:setActiveColor(color, bool)
@ -537,7 +466,7 @@ end
-- Instantiate one object from the flat {class,color,x,y} schema. `class` names an -- 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"/ -- art asset, and its .meta declares the behaviour ("door"/"switch"/"player"/
-- "moveable"); anything without a .meta defaults to immoveable. npc is the one -- "moveable"/"sticky"/"skewer"/"spiky"); anything without a .meta defaults to immoveable. npc is the one
-- keyword handled directly since it has no art. -- keyword handled directly since it has no art.
function Room:createEntity(class, color, x, y) function Room:createEntity(class, color, x, y)
if class == "npc" then if class == "npc" then
@ -545,6 +474,11 @@ function Room:createEntity(class, color, x, y)
self:registerNpc(entity) self:registerNpc(entity)
return entity return entity
end end
if class == "jorge" then
local entity = Jorge:new(x, y)
self:registerNpc(entity)
return entity
end
local props = globalAssetProperties and globalAssetProperties[class] local props = globalAssetProperties and globalAssetProperties[class]
local behavior = props and props.class or "immoveable" local behavior = props and props.class or "immoveable"
@ -577,6 +511,12 @@ function Room:createEntity(class, color, x, y)
if cellShape and #cellShape == 0 then cellShape = nil end if cellShape and #cellShape == 0 then cellShape = nil end
if behavior == "moveable" then if behavior == "moveable" then
entity = GenericMoveable:new(x, y, color, sprite, class, cellShape) 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 else
entity = GenericUnmoveable:new(x, y, color, sprite, cellShape) entity = GenericUnmoveable:new(x, y, color, sprite, cellShape)
end end
@ -664,12 +604,25 @@ function Room:attemptDelete(mouseX, mouseY, color)
for index = #self.doors, 1, -1 do for index = #self.doors, 1, -1 do
if removedEntities[self.doors[index]] then table.remove(self.doors, index) end if removedEntities[self.doors[index]] then table.remove(self.doors, index) end
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() self:tick()
end end
function Room:attemptPlace(name, props, mouseX, mouseY, color) function Room:attemptPlace(name, props, mouseX, mouseY, color)
if not props or not props.sprites then return end if not props or not props.sprites then return end
local cell = self:getCellFromMousePos(mouseX, mouseY) 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 -- "white" (or nil) means every channel; otherwise just the picked channel
local colors local colors
@ -727,32 +680,9 @@ end
-- This is intentionally separate from Room:save. It is the editor's explicit -- This is intentionally separate from Room:save. It is the editor's explicit
-- "write this level into the project" action; gameplay autosaves must never -- "write this level into the project" action; gameplay autosaves must never
-- call it. -- call it.
function Room:saveToSource(name, savePlayerPositions, playerSourceName) function Room:saveToSource(name, savePlayerPositions)
name = name or self.name or "test" name = name or self.name or "test"
local saveTable = self:serialize(savePlayerPositions ~= false) 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) local room = TSerial.pack(saveTable, nil, true)
return writeToSource("rooms/" .. name .. ".sav", room) return writeToSource("rooms/" .. name .. ".sav", room)
end end
@ -791,7 +721,7 @@ function Room:serialize(includePlayers)
end end
for _, npc in ipairs(self.npcs) do for _, npc in ipairs(self.npcs) do
table.insert(objects, { class = "npc", x = npc.x, y = npc.y }) table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y })
end end
return { objects = objects } return { objects = objects }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1408
rooms/jorge2.sav Normal file

File diff suppressed because it is too large Load diff

760
rooms/ok.sav Normal file
View file

@ -0,0 +1,760 @@
{
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"
}
}
}

4
rooms/room_01_05.sav Normal file
View file

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

4
rooms/room_05_09.sav Normal file
View file

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

4
rooms/room_05_18.sav Normal file
View file

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

4
rooms/room_07_07.sav Normal file
View file

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

File diff suppressed because it is too large Load diff

1159
rooms/spiky_test_2.sav Normal file

File diff suppressed because it is too large Load diff

1249
rooms/spiky_test_3.sav Normal file

File diff suppressed because it is too large Load diff

994
rooms/tetromino_loop_t.sav Normal file
View file

@ -0,0 +1,994 @@
{
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"
}
}
}

795
rooms/village_start.sav Normal file
View file

@ -0,0 +1,795 @@
{
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,157 +1,207 @@
{ {
width=18,
height=18,
rooms={ rooms={
{ {
name="start", x=10,
y=11,
x=9
},
{
name="3", name="3",
y=10, y=10
x=10
}, },
{ {
x=12,
name="5", name="5",
y=10, y=10
x=12
}, },
{ {
x=12,
name="village_1", name="village_1",
y=9, y=9
x=12
},
{
name="village_2",
y=8,
x=12
}, },
{ {
x=13,
name="village_3", name="village_3",
y=8, y=8
x=13
}, },
{ {
x=11,
name="village_4", name="village_4",
y=8, y=8
x=11
}, },
{ {
x=11,
name="yellow_1", name="yellow_1",
y=7, y=7
x=11
}, },
{ {
x=11,
name="yellow_3", name="yellow_3",
y=6, y=6
x=11
}, },
{ {
x=12,
name="yellow_2", name="yellow_2",
y=7, y=7
x=12
}, },
{ {
x=11,
name="yellow_4", name="yellow_4",
y=5, y=5
x=11
}, },
{ {
x=11,
name="yellow_5", name="yellow_5",
y=4, y=4
x=11
}, },
{ {
x=10,
name="yellow_6", name="yellow_6",
y=5, y=5
x=10
}, },
{ {
x=1,
name="default", name="default",
y=1, y=1
x=1
}, },
{ {
x=3,
name="old_yellow_3", name="old_yellow_3",
y=1, y=1
x=3
}, },
{ {
x=5,
name="test_patt", name="test_patt",
y=1, y=1
x=5
}, },
{ {
x=7,
name="test_patt_update", name="test_patt_update",
y=1, y=1
x=7
}, },
{ {
x=9,
name="white_asymmetrical", name="white_asymmetrical",
y=1, y=1
x=9
}, },
{ {
x=11,
name="white_ok", name="white_ok",
y=1, y=1
x=11
}, },
{ {
x=13,
name="white_primary", name="white_primary",
y=1, y=1
x=13
}, },
{ {
x=15,
name="yellow_arches", name="yellow_arches",
y=1, y=1
x=15
}, },
{ {
x=17,
name="yellow_asymmetrical", name="yellow_asymmetrical",
y=1, y=1
x=17
}, },
{ {
x=1,
name="yellow_cc_moustache", name="yellow_cc_moustache",
y=3, y=3
x=1
}, },
{ {
x=3,
name="yellow_color_changing_levvel_1", name="yellow_color_changing_levvel_1",
y=3, y=3
x=3
}, },
{ {
x=7,
name="yellow_smile", name="yellow_smile",
y=3, y=3
x=7
}, },
{ {
x=9,
name="2", name="2",
y=10, y=10
x=9
}, },
{ {
x=11,
name="room_11_09", name="room_11_09",
y=9, y=9
x=11
}, },
{ {
x=9,
name="yellow_skull", name="yellow_skull",
y=3, y=3
x=9
}, },
{ {
x=5,
name="jorge_room", name="jorge_room",
y=5, y=5
x=5
}, },
{ {
x=11,
name="4", name="4",
y=10, y=10
x=11 },
{
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
} }
}, },
lastRoom="yellow_4", lastRoom="jorge2"
width=18,
height=18
} }

View file

@ -58,3 +58,121 @@ glow = love.graphics.newShader[[
return pixel_me; 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);
}
]]

7
stories/jorge.ink Normal file
View file

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

1
stories/jorge.lua Normal file
View file

@ -0,0 +1 @@
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"]={}}

548
tests/test_movement.lua Normal file
View file

@ -0,0 +1,548 @@
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,7 +171,16 @@ function World:drawRoomPreview(name, x, y, size)
local path = props.sprites[color] local path = props.sprites[color]
if path then if path then
love.graphics.setColor(gColor[color]:set()) love.graphics.setColor(gColor[color]:set())
love.graphics.draw(loadSprite(path), x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale) local sprite = loadSprite(path)
if props.goopy and drawGoopySprite then
drawGoopySprite(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
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
end end
end end
end end
@ -180,6 +189,30 @@ function World:drawRoomPreview(name, x, y, size)
love.graphics.setScissor() love.graphics.setScissor()
end 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() function World:draw()
local cell = width / self.width local cell = width / self.width
love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1) love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
@ -193,4 +226,5 @@ function World:draw()
love.graphics.rectangle("line", screenX, screenY, cell, cell) love.graphics.rectangle("line", screenX, screenY, cell, cell)
end end
end end
self:drawHoveredRoomName()
end end