Compare commits

...

3 commits

Author SHA1 Message Date
Your Name
bc4976b324 wired switches and doors 2026-08-11 22:55:58 -04:00
Your Name
ee7d36e72b Synchronize world state across editor and room saves 2026-08-11 21:49:32 -04:00
Your Name
1965e548b4 Add world-level movement and seam rendering 2026-08-11 21:26:19 -04:00
16 changed files with 3026 additions and 865 deletions

View file

@ -44,6 +44,11 @@ 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 cloneSourceRoom = nil
local roomBrush = nil local roomBrush = nil
local wireBrush = nil
local selectedCircuitId = nil
local wireCameraX, wireCameraY = nil, nil
local wireDirtyRooms = {}
local wireOriginalWires = nil
local boxPaletteOpen = false local boxPaletteOpen = false
local boxPaletteScroll = { scroll = 0 } local boxPaletteScroll = { scroll = 0 }
local boxPaletteSections = {} local boxPaletteSections = {}
@ -273,6 +278,8 @@ local function layoutRoomControls(c, s)
end end
b.scale = math.min(target / spriteW, target / spriteH) b.scale = math.min(target / spriteW, target / spriteH)
b.w, b.h = spriteW * b.scale, spriteH * b.scale b.w, b.h = spriteW * b.scale, spriteH * b.scale
else
b.w, b.h = 72, target
end end
return b return b
end end
@ -296,8 +303,8 @@ 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({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box" }, 22)
placeRow({ "save", "clearSaves", "world" }, 28) placeRow({ "save", "clearSaves", "world", "wire" }, 28)
end end
-- Build the full room-view layout (sections + a scroll list + a back/search -- Build the full room-view layout (sections + a scroll list + a back/search
@ -492,10 +499,74 @@ local function saveRoomFromEditor()
gameWorld:invalidatePreview(name) gameWorld:invalidatePreview(name)
if editorView == "world" then rebuildWorldRoomButtons() end if editorView == "world" then rebuildWorldRoomButtons() end
currentRoom.name = name currentRoom.name = name
gameWorld:setLevel(currentRoom)
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 " without player positions.") editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or " without player positions.")
-- A circuit stroke can touch neighbouring rooms in one gesture. Save those
-- authored fragments together so a cross-room route cannot be half-written.
for room in pairs(wireDirtyRooms) do
if room ~= currentRoom and room.name then
room:saveWiresToSource()
gameWorld:invalidatePreview(room.name)
clearRuntimeSaveData(room.name)
end
end
wireDirtyRooms = {}
end
local function copyWires(wires)
local copy = {}
for y, row in pairs(wires or {}) do
copy[y] = {}
for x, tracks in pairs(row) do
copy[y][x] = {}
for circuitId, mask in pairs(tracks) do copy[y][x][circuitId] = mask end
end
end
return copy
end
local function resumeWireRoom(status)
local name = wireCameraX and wireCameraY and gameWorld:roomAt(wireCameraX, wireCameraY)
local room = name and gameWorld.levels[name]
if not room then
room = currentRoom
status = status .. " The centered world cell is empty, so the previous room remains open."
end
currentRoom = room
gameWorld:setLevel(currentRoom)
gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name)
wireOriginalWires = nil
wireDirtyRooms = {}
assetsView()
editorStatus = status
end
local function saveWireRooms()
local saved = 0
for room in pairs(wireDirtyRooms) do
local success = room == currentRoom and room:saveToSource(room.name) or room:saveWiresToSource()
if room.name and success then
saved = saved + 1
gameWorld:invalidatePreview(room.name)
clearRuntimeSaveData(room.name)
end
end
suppressRuntimeAutosave = true
local status = saved > 0 and ("Saved wire fragments in " .. saved .. " room(s).") or "No wire changes to save."
resumeWireRoom(status)
end
local function discardWireRooms()
for room, wires in pairs(wireOriginalWires or {}) do room.wires = copyWires(wires) end
gameWorld:resolveCircuits()
gameWorld:rebuildCollision()
resumeWireRoom("Discarded wire changes.")
end end
local function clearRuntimeSaves() local function clearRuntimeSaves()
@ -520,6 +591,7 @@ local function clearRuntimeSaves()
clearSaveConfirmationScope = nil clearSaveConfirmationScope = nil
if roomName then if roomName then
currentRoom = Room:new{ name = roomName } currentRoom = Room:new{ name = roomName }
gameWorld:setLevel(currentRoom)
setEditorRoomName(currentRoom.name) setEditorRoomName(currentRoom.name)
editorStatus = "Cleared " .. removed .. " runtime save item(s) and reloaded '" .. roomName .. ".'" editorStatus = "Cleared " .. removed .. " runtime save item(s) and reloaded '" .. roomName .. ".'"
else else
@ -629,7 +701,7 @@ function _initLevelEditor()
}, color) }, color)
end end
for _, asset in ipairs({ "player", "static_wall", "switch", "door", "box" }) do for _, asset in ipairs({ "player", "static_wall", "switch", "door", "wired_switch", "wired_door", "box" }) do
local properties = gameAssets[asset] local properties = gameAssets[asset]
if properties then if properties then
local sprites = {} local sprites = {}
@ -648,6 +720,16 @@ function _initLevelEditor()
end end
end end
_addUIButton(createUIElement{
name = "wire",
text = "WIRES",
onClic = { left = function()
if editorView == "wire" then assetsView() else wireView() end
end },
x = 0,
y = 0,
}, "wire")
roomNameField = createUIElement{ roomNameField = createUIElement{
name = "roomName", name = "roomName",
text = editorRoomName, text = editorRoomName,
@ -837,11 +919,31 @@ end
function assetsView() function assetsView()
editingAssetButtons = {} editingAssetButtons = {}
editorView = "room" editorView = "room"
wireBrush = nil
labels = {} labels = {}
end end
function wireView()
local location = currentRoom and gameWorld:roomLocation(currentRoom.name)
if not location then
editorStatus = "Place this room in the world before drawing cross-room wires."
return
end
editorView = "wire"
activeField = nil
roomBrush = nil
wireCameraX, wireCameraY = location.x, location.y
wireOriginalWires = {}
for _, room in pairs(gameWorld.levels or {}) do wireOriginalWires[room] = copyWires(room.wires) end
wireDirtyRooms = {}
editorStatus = selectedCircuitId
and "Circuit selected. Drag to draw; right-drag erases; arrow keys pan."
or "Click a wired door to select its circuit, then drag to its switches."
end
function worldView() function worldView()
editorView = "world" editorView = "world"
wireBrush = nil
activeField = nil activeField = nil
selectedFolder = nil selectedFolder = nil
selectedRoom = nil selectedRoom = nil
@ -850,6 +952,118 @@ function worldView()
rebuildWorldRoomButtons() rebuildWorldRoomButtons()
end end
local function wireControls()
local pad, gap, h = 12, 8, 40
local w = (sb.w - pad * 2 - gap) / 2
return {
save = {x = pad, y = sb.h - pad - h, w = w, h = h, text = "SAVE"},
discard = {x = pad + w + gap, y = sb.h - pad - h, w = w, h = h, text = "DISCARD"},
}
end
local function rememberWireRooms(rooms)
for _, room in ipairs(rooms or {}) do wireDirtyRooms[room] = true end
end
local function wireCellAt(x, y)
if x < 0 or x >= width or y < 0 or y >= height then return nil end
if not wireCameraX or not wireCameraY then return nil end
local cell = gameWorld:wireEditorCellAt(x, y, wireCameraX, wireCameraY)
local room = gameWorld:roomCell(cell)
return room and cell or nil
end
local function paintWireStep(from, to, button)
if button == 1 then
local rooms, err = gameWorld:addWireConnection(from, to, selectedCircuitId)
rememberWireRooms(rooms)
if err then editorStatus = err end
else
rememberWireRooms(gameWorld:removeWireAt(to, selectedCircuitId))
end
end
local function paintWireLine(from, to, button)
local current = {x = from.x, y = from.y}
local function walk(axis, target)
local step = current[axis] < target and 1 or -1
while current[axis] ~= target do
local nextCell = {x = current.x, y = current.y}
nextCell[axis] = nextCell[axis] + step
local room = gameWorld:roomCell(nextCell)
if not room then return false end
paintWireStep(current, nextCell, button)
current = nextCell
end
return true
end
-- Manhattan interpolation keeps every authored segment orthogonal even if
-- the mouse skips diagonally across several cells between move events.
if not walk("x", to.x) then return current end
walk("y", to.y)
return current
end
local function beginWireBrush(x, y, button)
if button ~= 1 and button ~= 2 then return end
local cell = wireCellAt(x, y)
if not cell then return end
if button == 1 then
local door = gameWorld:wiredDoorAt(cell)
if door then
-- Each stroke leaving a door is its own run and may terminate at one
-- logical switch. Multiple runs can meet at the door, whose state is the
-- AND of those independently coloured switch terminals.
selectedCircuitId = gameWorld:newCircuitId()
editorStatus = "Started " .. selectedCircuitId .. ". End this run on one switch."
else
-- A wire is a selectable terminal even after its door is deleted.
-- Clicking empty space keeps the current circuit so a new branch can
-- still begin away from the existing route.
local circuitAtCell = gameWorld:circuitAt(cell)
if circuitAtCell then
selectedCircuitId = circuitAtCell
editorStatus = "Selected orphaned circuit " .. selectedCircuitId .. "."
end
end
if not selectedCircuitId then
editorStatus = "Start by clicking a wired door."
return
end
rememberWireRooms(gameWorld:ensureWireAt(cell, selectedCircuitId))
else
-- Prefer the selected circuit when several routes overlap here; otherwise
-- adopt the top route. Repeated right-clicks can therefore remove every
-- orphan at an overlapping cell without needing a door.
local circuitAtCell = gameWorld:circuitAt(cell, selectedCircuitId)
if not circuitAtCell then
editorStatus = "There is no wire at this cell to erase."
return
end
selectedCircuitId = circuitAtCell
rememberWireRooms(gameWorld:removeWireAt(cell, selectedCircuitId))
editorStatus = "Erasing " .. selectedCircuitId .. "."
end
wireBrush = {button = button, lastCell = cell}
end
function editorWireKeypressed(key)
if editorView ~= "wire" then return false end
local directions = {
left = {-1, 0}, right = {1, 0}, up = {0, -1}, down = {0, 1},
}
local direction = directions[key]
if not direction then return false end
wireCameraX = constrain(wireCameraX + direction[1], 1, gameWorld.width)
wireCameraY = constrain(wireCameraY + direction[2], 1, gameWorld.height)
wireBrush = nil
return true
end
function wireEditorCamera()
return wireCameraX, wireCameraY, selectedCircuitId
end
local function newWorldRoomName(cellX, cellY) local function newWorldRoomName(cellX, cellY)
local base = string.format("room_%02d_%02d", cellX, cellY) local base = string.format("room_%02d_%02d", cellX, cellY)
local name, suffix = base, 2 local name, suffix = base, 2
@ -870,6 +1084,23 @@ local function cloneWorldRoom(sourceName, cloneName)
editorStatus = "A level named '" .. cloneName .. "' already exists." editorStatus = "A level named '" .. cloneName .. "' already exists."
return false return false
end end
-- A clone is a new puzzle slice, so remap every circuit identity found in
-- it. Keeping the originals would let the clone silently join the source's
-- cross-room wires when both are placed in the same world.
local ok, saveTable = pcall(TSerial.unpack, source)
if ok and type(saveTable) == "table" then
local remapped = {}
local function remap(circuitId)
if not circuitId then return nil end
if not remapped[circuitId] then remapped[circuitId] = gameWorld:newCircuitId() end
return remapped[circuitId]
end
for _, object in ipairs(saveTable.objects or {}) do
if object.circuit then object.circuit = remap(object.circuit) end
end
for _, wire in ipairs(saveTable.wires or {}) do wire.circuit = remap(wire.circuit) end
source = TSerial.pack(saveTable, nil, true)
end
if not writeToSource("rooms/" .. cloneName .. ".sav", source) then if not writeToSource("rooms/" .. cloneName .. ".sav", source) then
editorStatus = "Could not create clone '" .. cloneName .. "'." editorStatus = "Could not create clone '" .. cloneName .. "'."
return false return false
@ -905,6 +1136,7 @@ local function openWorldRoom(cellX, cellY)
currentRoom = Room:new{ name = name } currentRoom = Room:new{ name = name }
editorStatus = "Editing '" .. name .. ".'" editorStatus = "Editing '" .. name .. ".'"
end end
gameWorld:setLevel(currentRoom)
gameWorld:setLastRoom(currentRoom.name) gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name) setEditorRoomName(currentRoom.name)
assetsView() assetsView()
@ -958,13 +1190,17 @@ function editorMouseHandler(x, y, button)
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", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire" }) 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)
return return
end end
end end
elseif editorView == "wire" then
local controls = wireControls()
if buttonContains(controls.save, x, y) then saveWireRooms(); return end
if buttonContains(controls.discard, x, y) then discardWireRooms(); return end
elseif editorView == "asset" then elseif editorView == "asset" then
for _, uiElement in pairs(editingAssetButtons) do for _, uiElement in pairs(editingAssetButtons) do
if uiElement:clicIsInBox(x, y) then if uiElement:clicIsInBox(x, y) then
@ -1002,6 +1238,8 @@ function editorMouseHandler(x, y, button)
-- 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" then if editorView == "room" then
beginRoomBrush(x, y, button) beginRoomBrush(x, y, button)
elseif editorView == "wire" then
beginWireBrush(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
@ -1024,6 +1262,15 @@ function editorMouseHandler(x, y, button)
end end
function editorMouseMoved(x, y) function editorMouseMoved(x, y)
if wireBrush then
if editorView ~= "wire" then wireBrush = nil; return end
local cell = wireCellAt(x, y)
if not cell then wireBrush = nil; return end
if cell.x ~= wireBrush.lastCell.x or cell.y ~= wireBrush.lastCell.y then
wireBrush.lastCell = paintWireLine(wireBrush.lastCell, cell, wireBrush.button)
end
return
end
if not roomBrush then return end if not roomBrush then return end
if editorView ~= "room" then if editorView ~= "room" then
roomBrush = nil roomBrush = nil
@ -1045,6 +1292,7 @@ end
function editorMouseReleased(_, _, button) function editorMouseReleased(_, _, button)
if roomBrush and roomBrush.button == button then roomBrush = nil end if roomBrush and roomBrush.button == button then roomBrush = nil end
if wireBrush and wireBrush.button == button then wireBrush = nil end
end end
function editorTextHandler(text) function editorTextHandler(text)
@ -1094,6 +1342,7 @@ function editorCompleteResponse()
end end
function editorCancelResponse() function editorCancelResponse()
if editorView == "wire" then discardWireRooms(); return true end
if editorView ~= "world" or activeField ~= "cloneName" then return false end if editorView ~= "world" or activeField ~= "cloneName" then return false end
activeField = nil activeField = nil
cloneSourceRoom = nil cloneSourceRoom = nil
@ -1153,11 +1402,15 @@ function editorDraw()
-- 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", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box", "save", "clearSaves", "world", "wire" }) do
local button = uiButtons[name] local button = uiButtons[name]
if button then if button then
button:draw() button:draw()
local asset = name:match("^quick_(.+)$") local asset = name:match("^quick_(.+)$")
if asset and asset:match("^wired_") then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("W", button.x + button.w - 7, button.y - 4)
end
if asset and selectedAsset == asset then if asset and selectedAsset == asset then
love.graphics.setColor(1, 0.85, 0.2, 1) love.graphics.setColor(1, 0.85, 0.2, 1)
love.graphics.setLineWidth(2) love.graphics.setLineWidth(2)
@ -1173,6 +1426,32 @@ function editorDraw()
love.graphics.printf(editorStatus, s.status.x, s.status.y + 4, s.status.w, "center") love.graphics.printf(editorStatus, s.status.x, s.status.y + 4, s.status.w, "center")
Layout.unclip() Layout.unclip()
end end
elseif editorView == "wire" then
local circuitColor = selectedCircuitId and require("wire").color(selectedCircuitId)
love.graphics.setColor(gColor.white:set())
love.graphics.printf("CIRCUIT WIRING", 10, 16, sb.w - 20, "center")
if selectedCircuitId then
love.graphics.setColor(circuitColor[1], circuitColor[2], circuitColor[3], 1)
love.graphics.circle("fill", 28, 65, 9)
love.graphics.setColor(gColor.white:set())
love.graphics.printf(selectedCircuitId, 46, 54, sb.w - 58, "left")
else
love.graphics.printf("No circuit selected", 10, 54, sb.w - 20, "center")
end
love.graphics.printf(
"Click a door to start a new run, or a wire to edit that run. Each run ends at one switch; start another run from the door for another switch.\n\nLeft-drag draws. Right-drag erases. Independent wires may share a cell without connecting.\n\nArrow keys pan by one room.",
24, 100, sb.w - 48, "left"
)
if editorStatus then
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 24, sb.h * 0.55, sb.w - 48, "center")
end
local controls = wireControls()
for _, control in pairs(controls) do
love.graphics.setColor(gColor.white:set())
love.graphics.rectangle("line", control.x, control.y, control.w, control.h)
love.graphics.printf(control.text, control.x, control.y + 11, control.w, "center")
end
elseif editorView == "asset" then elseif editorView == "asset" then
for _, button in pairs(editingAssetButtons) do for _, button in pairs(editingAssetButtons) do
button:draw() button:draw()
@ -1186,7 +1465,7 @@ function editorDraw()
end end
-- The room view draws its status inside its own strip (see the room branch); -- The room view draws its status inside its own strip (see the room branch);
-- the other views keep the simple centered line. -- the other views keep the simple centered line.
if editorStatus and editorView ~= "room" then if editorStatus and editorView ~= "room" and editorView ~= "wire" then
love.graphics.setColor(gColor.white:set()) love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center") love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center")
end end

View file

@ -59,5 +59,23 @@ function getAllAssets()
end end
end end
-- Wired devices intentionally reuse the familiar switch/door artwork. Their
-- white terminal marker is drawn by the entity class, while these synthetic
-- asset records make them available to the ordinary palette and brush.
local function synthetic(name, source, behavior)
local original = globalAssetProperties[source]
if not original then return end
globalAssetProperties[name] = {
directory = original.directory,
sprites = original.sprites,
cells = original.cells,
class = behavior,
preload = false,
synthetic = true,
}
end
synthetic("wired_switch", "switch", "wired_switch")
synthetic("wired_door", "door", "wired_door")
return BoxVariants.addTo(globalAssetProperties) return BoxVariants.addTo(globalAssetProperties)
end end

View file

@ -35,6 +35,9 @@ function love.load()
_initAccessibility() _initAccessibility()
gameWorld = World:new() gameWorld = World:new()
_initLevelEditor() _initLevelEditor()
-- getAllAssets runs in _initLevelEditor; deserialize the world only after
-- its asset metadata exists, otherwise every non-player object is skipped.
gameWorld:loadLevels()
_initCanvases() _initCanvases()
@ -43,10 +46,16 @@ function love.load()
-- Resume the game: prefer the runtime autosave so the last saved position is -- Resume the game: prefer the runtime autosave so the last saved position is
-- restored, falling back to the authored source level. Editor room-opens go -- restored, falling back to the authored source level. Editor room-opens go
-- through their own (source-only) load path, so this only affects resume. -- through their own (source-only) load path, so this only affects resume.
currentRoom = Room:new{ local initialRoom = gameWorld.lastRoom or "start"
name = gameWorld.lastRoom or "start", -- Preserve the old resume behavior for the active room while the rest of the
runtime = true -- world comes from the authored level files.
} currentRoom = Room:new{name = initialRoom, runtime = true}
if gameWorld.levels[initialRoom] then
gameWorld.levels[initialRoom] = currentRoom
gameWorld:rebuildCollision()
gameWorld:resolveCircuits()
gameWorld:rebuildCollision()
end
gameWorld:setLastRoom(currentRoom.name) gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name) setEditorRoomName(currentRoom.name)
@ -59,17 +68,21 @@ function love.quit()
-- An explicit editor save/clear removes the runtime copy intentionally; -- An explicit editor save/clear removes the runtime copy intentionally;
-- don't immediately recreate it while shutting down. -- don't immediately recreate it while shutting down.
if currentRoom and not suppressRuntimeAutosave then if currentRoom and not suppressRuntimeAutosave then
if gameWorld then
gameWorld:markDirty(currentRoom)
gameWorld:flushRuntime()
else
currentRoom:save() currentRoom:save()
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end end
end end
end end
function love.update(dt) function love.update(dt)
if editorView == "world" then if editorView == "world" or editorView == "wire" then
Input:stopMoveRepeat() Input:stopMoveRepeat()
else else
local repeatedMove = Input:handler(dt) local repeatedMove = Input:handler(dt)
if repeatedMove and not Dialogue.isOpen() then currentRoom:movePlayer(repeatedMove) end if repeatedMove and not Dialogue.isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end
end end
currentRoom:update(dt) currentRoom:update(dt)
@ -83,6 +96,8 @@ function love.draw()
love.graphics.setBlendMode("screen") love.graphics.setBlendMode("screen")
if editorView == "world" then if editorView == "world" then
gameWorld:draw() gameWorld:draw()
elseif editorView == "wire" then
gameWorld:drawWireEditor(wireEditorCamera())
else else
currentRoom:draw() currentRoom:draw()
end end
@ -162,12 +177,14 @@ function love.keypressed(key)
return return
end end
if editorView ~= "world" and Input.isPlayerMove(key) then if editorView == "wire" and editorWireKeypressed(key) then return end
if editorView ~= "world" and editorView ~= "wire" and Input.isPlayerMove(key) then
Input:beginMove(key) Input:beginMove(key)
currentRoom:movePlayer(key) gameWorld:movePlayer(currentRoom, key)
elseif editorView ~= "world" and Input.isPlayerSwitch(key) then elseif editorView ~= "world" and editorView ~= "wire" and Input.isPlayerSwitch(key) then
local c = Input.playerColor(key) local c = Input.playerColor(key)
currentRoom:switchPlayerColor(c) gameWorld:switchPlayerColor(currentRoom, c)
end end
-- if key == "w" then -- if key == "w" then

303
room.lua
View file

@ -8,6 +8,8 @@ require "art"
require "assorted" require "assorted"
require "switch" require "switch"
require "door" require "door"
require "wired_switch"
require "wired_door"
require "dialogue_manager" require "dialogue_manager"
require "generic_moveable" require "generic_moveable"
require "generic_sticky" require "generic_sticky"
@ -17,11 +19,15 @@ require "generic_unmoveable"
require "generic_player_blocker" require "generic_player_blocker"
local Movement = require "movement" local Movement = require "movement"
local Wire = require "wire"
function Room:initialize(t) function Room:initialize(t)
self.player = {} self.player = {}
self.switches = { red = {}, green = {}, blue = {} } self.switches = { red = {}, green = {}, blue = {} }
self.doors = {} self.doors = {}
self.wiredSwitches = {}
self.wiredDoors = {}
self.wires = {}
self.collideables = { red = {}, green = {}, blue = {} } self.collideables = { red = {}, green = {}, blue = {} }
self.playerBlockers = { red = {}, green = {}, blue = {} } self.playerBlockers = { red = {}, green = {}, blue = {} }
self.npcs = {} self.npcs = {}
@ -125,6 +131,7 @@ function Room:update(dt)
end end
function Room:draw() function Room:draw()
self:drawWiresAt(0, 0, width / gridWidth, nil, false)
-- draw only the channels the player has. Each object just exposes sprites per -- draw only the channels the player has. Each object just exposes sprites per
-- channel via Entity:draw(channel); a door registered on all three channels -- channel via Entity:draw(channel); a door registered on all three channels
@ -132,12 +139,120 @@ function Room:draw()
for _, channel in ipairs(self.colorsPlayerHas) do for _, channel in ipairs(self.colorsPlayerHas) do
if self.player[channel] then self.player[channel]:draw(channel) end if self.player[channel] then self.player[channel]:draw(channel) end
for _, entity in pairs(self.collideables[channel]) do entity:draw(channel) end for _, entity in pairs(self.collideables[channel]) do entity:draw(channel) end
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then gameWorld:drawOverlaps(self, channel) end
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end
for _, npc in ipairs(self.npcs) do npc:draw(channel) end for _, npc in ipairs(self.npcs) do npc:draw(channel) end
end end
end end
function Room:wireTracksAt(cell, create)
local row = self.wires[cell.y]
if not row and create then row = {}; self.wires[cell.y] = row end
local tracks = row and row[cell.x]
if not tracks and create then tracks = {}; row[cell.x] = tracks end
return tracks
end
function Room:setWireMask(cell, circuitId, mask)
if not self:isInBounds(cell) then return false end
local tracks = self:wireTracksAt(cell, true)
tracks[circuitId] = mask or 0
return true
end
function Room:wireCircuitAt(cell)
local tracks = self:wireTracksAt(cell)
local ids = {}
for circuitId in pairs(tracks or {}) do table.insert(ids, circuitId) end
table.sort(ids)
return ids[1]
end
function Room:removeWire(cell, circuitId)
local tracks = self:wireTracksAt(cell)
if not tracks or tracks[circuitId] == nil then return false end
tracks[circuitId] = nil
if not next(tracks) then self.wires[cell.y][cell.x] = nil end
if not next(self.wires[cell.y]) then self.wires[cell.y] = nil end
return true
end
function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorColors)
local ids = {}
for _, row in pairs(self.wires) do
for _, tracks in pairs(row) do
for circuitId in pairs(tracks) do ids[circuitId] = true end
end
end
local ordered = {}
for circuitId in pairs(ids) do table.insert(ordered, circuitId) end
table.sort(ordered)
love.graphics.setBlendMode("alpha")
local visibleColors = {red = false, green = false, blue = false}
for _, color in ipairs(self.colorsPlayerHas or {}) do visibleColors[color] = true end
local editorIsOn = editorView ~= nil and editorView ~= "off"
for _, circuitId in ipairs(ordered) do
local selected = not selectedCircuit or selectedCircuit == circuitId
for y, row in pairs(self.wires) do
for x, tracks in pairs(row) do
local mask = tracks[circuitId]
if mask ~= nil then
local localCell = {x = x, y = y}
local switchColors = gameWorld and gameWorld.wireSwitchColorsAt and
gameWorld:wireSwitchColorsAt(self, localCell, circuitId)
local drawColors = {}
if editorColors then
-- Connected circuits retain their ID colour in the wiring tool;
-- switchless fragments are neutral so orphans are obvious.
table.insert(drawColors, switchColors and Wire.color(circuitId) or {0.52, 0.52, 0.58})
elseif switchColors then
-- Draw literal RGB passes taken from the switch entities at this
-- run's sole terminal. The room's player channels decide which
-- passes exist, exactly as they do for every other entity.
for _, channel in ipairs({"red", "green", "blue"}) do
if switchColors[channel] and visibleColors[channel] then
local channelColor = gColor[channel]
table.insert(drawColors, {channelColor.r, channelColor.g, channelColor.b})
end
end
elseif editorIsOn then
table.insert(drawColors, {0.52, 0.52, 0.58})
end
if #drawColors > 0 then
local cx = originX + (x - 0.5) * cellSize
local cy = originY + (y - 0.5) * cellSize
local lines = {}
for _, direction in ipairs(Wire.directions) do
if Wire.has(mask, direction.bit) then
table.insert(lines, {cx, cy, cx + direction.x * cellSize / 2, cy + direction.y * cellSize / 2})
end
end
if #lines == 0 then table.insert(lines, {cx, cy, cx, cy}) end
-- The dark under-stroke makes later tracks cut a readable gap in
-- earlier tracks when two independent circuits cross.
love.graphics.setColor(0.03, 0.03, 0.05, selected and 1 or 0.7)
love.graphics.setLineWidth(math.max(1.5, cellSize * 0.10))
for _, line in ipairs(lines) do love.graphics.line(line) end
local wireBlend = (editorColors or not switchColors) and "alpha" or "screen"
love.graphics.setBlendMode(wireBlend)
love.graphics.setLineWidth(math.max(1, cellSize * 0.045))
for _, color in ipairs(drawColors) do
love.graphics.setColor(color[1], color[2], color[3], selected and 1 or 0.32)
for _, line in ipairs(lines) do love.graphics.line(line) end
love.graphics.circle("fill", cx, cy, math.max(0.75, cellSize * 0.0275))
end
end
end
end
end
end
love.graphics.setBlendMode("screen")
end
function Room:createCollideablesMatrix() function Room:createCollideablesMatrix()
for color, entities in pairs(self.collideables) do for color, entities in pairs(self.collideables) do
self.collidableMatrices[color] = self:createEntityMatrix(entities) self.collidableMatrices[color] = self:createEntityMatrix(entities)
@ -181,7 +296,7 @@ function Room:createEntityMatrix(entities)
for _, entity in pairs(entities) do for _, entity in pairs(entities) do
for _, occupiedCells in pairs(entity:getOccupiedCells()) do for _, occupiedCells in pairs(entity:getOccupiedCells()) do
--we store a table reference so we can call a method on it during a chec_ --we store a table reference so we can call a method on it during a chec_
matrix[occupiedCells.y][occupiedCells.x] = entity if self:isInBounds(occupiedCells) then matrix[occupiedCells.y][occupiedCells.x] = entity end
end end
end end
-- printMatrix(matrix) -- printMatrix(matrix)
@ -193,7 +308,9 @@ function Room:getCellInMatrix(matrix, cell)
return matrix[cell.y][cell.x] return matrix[cell.y][cell.x]
end end
function Room:movePlayer(key) -- Kept for isolated room tests/editor previews. The live game goes through
-- World:movePlayer so no gameplay decision is room-local.
function Room:movePlayerLocal(key)
local moved = false local moved = false
local previousPositions = {} --these are so we can perform the overlap chec_ local previousPositions = {} --these are so we can perform the overlap chec_
@ -224,23 +341,36 @@ function Room:movePlayer(key)
x = pos.x + dir.x, y = pos.y + dir.y x = pos.x + dir.x, y = pos.y + dir.y
} -- this is for reference for each color as we change the targetCells table } -- this is for reference for each color as we change the targetCells table
local useWorld = gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self
local context = useWorld and gameWorld:movementContext(self, color)
local worldTarget = useWorld and context:worldCell(initialTargetCell)
local initialTargetEntity local initialTargetEntity
if self:isInBounds(initialTargetCell) then if useWorld then
initialTargetEntity = context:entityAt(worldTarget)
elseif self:isInBounds(initialTargetCell) then
initialTargetEntity = self:getCellInMatrix(self.collidableMatrices[color], initialTargetCell) initialTargetEntity = self:getCellInMatrix(self.collidableMatrices[color], initialTargetCell)
end end
if initialTargetEntity then initialTargetEntity:bump() end if initialTargetEntity then initialTargetEntity:bump() end
local playerBlocker local playerBlocker
if self:isInBounds(initialTargetCell) and self.playerBlockerMatrices and self.playerBlockerMatrices[color] then if useWorld then
playerBlocker = context:playerBlockerAt(worldTarget)
elseif self:isInBounds(initialTargetCell) and self.playerBlockerMatrices and self.playerBlockerMatrices[color] then
playerBlocker = self:getCellInMatrix(self.playerBlockerMatrices[color], initialTargetCell) playerBlocker = self:getCellInMatrix(self.playerBlockerMatrices[color], initialTargetCell)
end end
if not self:isInBounds(initialTargetCell) then if useWorld and not context:isInBounds(worldTarget) then
move = true
elseif not useWorld and 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 playerBlocker and playerBlocker:blocksPlayer() then elseif playerBlocker and playerBlocker:blocksPlayer() then
move = false move = false
elseif useWorld then
move, entitiesToPush = Movement.findMovableGroup({worldTarget}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
else else
move, entitiesToPush = Movement.findMovableGroup( move, entitiesToPush = Movement.findMovableGroup(
{initialTargetCell}, dir, {initialTargetCell}, dir,
@ -253,7 +383,11 @@ function Room:movePlayer(key)
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 pull = false
if self:isInBounds(initialTargetCell) then if useWorld and context:isInBounds(context:worldCell(initialTargetCell)) then
pull, entitiesToPull = Movement.findMovableGroup({context:worldCell(initialTargetCell)}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
elseif not useWorld and self:isInBounds(initialTargetCell) then
pull, entitiesToPull = Movement.findMovableGroup( pull, entitiesToPull = Movement.findMovableGroup(
{initialTargetCell}, dir, {initialTargetCell}, dir,
function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end, function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end,
@ -262,10 +396,15 @@ function Room:movePlayer(key)
end end
if move then if move then
for _, entity in pairs(entitiesToPush) do if useWorld then
entity:move(axis, dir[axis]) local group = {}
for entity in pairs(entitiesToPush) do group[entity] = entity end
if pull then for entity in pairs(entitiesToPull) do group[entity] = entity end end
gameWorld:moveGroup(group, dir)
else
for _, entity in pairs(entitiesToPush) do entity:move(axis, dir[axis]) end
end end
if pull == true then if pull == true and not useWorld 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 -- A connected component can wrap around the player and be found by
-- both searches. Every entity still moves exactly one grid cell. -- both searches. Every entity still moves exactly one grid cell.
@ -281,12 +420,20 @@ function Room:movePlayer(key)
end end
if moved then if moved then
self:playerJoinCheck(previousPositions, newPositions) if useWorld then gameWorld:playerJoinCheck(self, previousPositions, newPositions)
else self:playerJoinCheck(previousPositions, newPositions) end
self:tick() self:tick()
end end
end end
function Room:switchPlayerColor(color) function Room:movePlayer(key)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then
return gameWorld:movePlayer(self, key)
end
return self:movePlayerLocal(key)
end
function Room:switchPlayerColorLocal(color)
-- Number keys can request a colour the player has not collected yet. Do -- 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 -- nothing in that case: there is no corresponding player object to switch
-- to (or use as the sound source). -- to (or use as the sound source).
@ -304,6 +451,13 @@ function Room:switchPlayerColor(color)
return canSwitch return canSwitch
end end
function Room:switchPlayerColor(color)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then
return gameWorld:switchPlayerColor(self, color)
end
return self:switchPlayerColorLocal(color)
end
function Room:setActiveColor(color, bool) function Room:setActiveColor(color, bool)
self.activeColors[color] = bool self.activeColors[color] = bool
end end
@ -361,7 +515,7 @@ function Room:onlyColorIsActive(color)
end end
end end
function Room:tick() function Room:tickLocal()
for color, entityArray in pairs(self.collideables) do for color, entityArray in pairs(self.collideables) do
for _, entity in pairs(entityArray) do for _, entity in pairs(entityArray) do
entity:tick() entity:tick()
@ -371,6 +525,11 @@ function Room:tick()
self:createPlayerBlockerMatrix() self:createPlayerBlockerMatrix()
self:createSwitchMatrix() self:createSwitchMatrix()
self:resolveSwitchesAndDoors() self:resolveSwitchesAndDoors()
end
function Room:tick()
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then return gameWorld:tick(self) end
self:tickLocal()
self:nextRoomCheck() self:nextRoomCheck()
end end
@ -445,7 +604,14 @@ function Room:switchCheck()
for color, switchArray in pairs(self.switches) do for color, switchArray in pairs(self.switches) do
local onSwitches = {} local onSwitches = {}
for _, switch in pairs(switchArray) do for _, switch in pairs(switchArray) do
local targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos()) local targetCell
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then
local cell = gameWorld:worldCell(self, switch:getGridPos())
local row = cell and gameWorld.collidableMatrices[color][cell.y]
targetCell = row and row[cell.x]
else
targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos())
end
local player = self.player[color] local player = self.player[color]
-- Players are not stored in collidableMatrices, but a colour copy -- Players are not stored in collidableMatrices, but a colour copy
-- standing on its matching switch presses it just like a block does. -- standing on its matching switch presses it just like a block does.
@ -486,7 +652,8 @@ end
-- art asset, and its .meta declares the behaviour ("door"/"switch"/"player"/ -- art asset, and its .meta declares the behaviour ("door"/"switch"/"player"/
-- "moveable"/"sticky"/"skewer"/"spiky"/"player_blocker"); anything without a .meta defaults to immoveable. npc is the one -- "moveable"/"sticky"/"skewer"/"spiky"/"player_blocker"); 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, data)
data = data or {}
if class == "npc" then if class == "npc" then
local entity = Sign:new(x, y) local entity = Sign:new(x, y)
self:registerNpc(entity) self:registerNpc(entity)
@ -502,7 +669,31 @@ function Room:createEntity(class, color, x, y)
local behavior = props and props.class or "immoveable" local behavior = props and props.class or "immoveable"
local entity local entity
if behavior == "door" then if behavior == "wired_door" then
if not color then return end
local circuitId = data.circuit
-- The wire terminal owns identity. A replacement door adopts the route
-- already present at its cell; additional RGB layers of a white door adopt
-- the first door layer's circuit as well.
if not circuitId then
circuitId = self:wireCircuitAt({x = x, y = y})
end
if not circuitId then
for _, existingDoor in ipairs(self.wiredDoors) do
if existingDoor:occupiesCell({x = x, y = y}) then
circuitId = existingDoor.circuitId
break
end
end
end
if not circuitId and gameWorld then circuitId = gameWorld:newCircuitId() end
circuitId = circuitId or ("wire_" .. tostring(os.time()) .. "_" .. tostring(math.random(1000000)))
entity = WiredDoor:new(x, y, color, circuitId)
self:registerWiredDoor(entity, color)
elseif behavior == "wired_switch" then
entity = WiredSwitch:new(x, y, color)
self:registerWiredSwitch(entity)
elseif behavior == "door" then
-- Old migrated rooms stored a colourless shared door. Expand that legacy -- Old migrated rooms stored a colourless shared door. Expand that legacy
-- record into ordinary colour-layer door objects on load. -- record into ordinary colour-layer door objects on load.
if not color then if not color then
@ -570,6 +761,15 @@ function Room:registerSwitch(entityPointer, color)
table.insert(self.switches[color], entityPointer) table.insert(self.switches[color], entityPointer)
end end
function Room:registerWiredDoor(entityPointer, color)
table.insert(self.wiredDoors, entityPointer)
self:registerCollidable(entityPointer, color)
end
function Room:registerWiredSwitch(entityPointer)
table.insert(self.wiredSwitches, entityPointer)
end
function Room:registerPlayer(entityPointer, color) function Room:registerPlayer(entityPointer, color)
table.insert(self.colorsPlayerHas, color) table.insert(self.colorsPlayerHas, color)
self.player[color] = entityPointer self.player[color] = entityPointer
@ -639,6 +839,15 @@ 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
for index = #self.wiredDoors, 1, -1 do
if removedEntities[self.wiredDoors[index]] then table.remove(self.wiredDoors, index) end
end
for index = #self.wiredSwitches, 1, -1 do
if self.wiredSwitches[index]:occupiesCell(cell) and
(not color or color == "white" or self.wiredSwitches[index].color:getname() == color) then
table.remove(self.wiredSwitches, index)
end
end
-- NPCs are stamped into every collision layer, but are kept in their own -- NPCs are stamped into every collision layer, but are kept in their own
-- registry rather than in self.collideables. Remove them directly so a -- registry rather than in self.collideables. Remove them directly so a
-- right-click works no matter which editor colour is currently selected. -- right-click works no matter which editor colour is currently selected.
@ -694,7 +903,7 @@ end
function Room:deserialize(saveTable) function Room:deserialize(saveTable)
for _, obj in ipairs(saveTable.objects or {}) do for _, obj in ipairs(saveTable.objects or {}) do
self:createEntity(obj.class, obj.color, obj.x, obj.y) self:createEntity(obj.class, obj.color, obj.x, obj.y, obj)
-- createEntity turns every loaded player on; honour a saved active flag so -- createEntity turns every loaded player on; honour a saved active flag so
-- copies that were off at save time stay off (older savs lack the field and -- copies that were off at save time stay off (older savs lack the field and
-- keep the old all-on behaviour). -- keep the old all-on behaviour).
@ -702,6 +911,11 @@ function Room:deserialize(saveTable)
self:setActiveColor(obj.color, obj.active) self:setActiveColor(obj.color, obj.active)
end end
end end
for _, wire in ipairs(saveTable.wires or {}) do
if wire.x and wire.y and wire.circuit then
self:setWireMask({x = wire.x, y = wire.y}, wire.circuit, wire.edges or 0)
end
end
end end
function Room:save() function Room:save()
@ -722,12 +936,43 @@ function Room:saveToSource(name, savePlayerPositions)
return writeToSource("rooms/" .. name .. ".sav", room) return writeToSource("rooms/" .. name .. ".sav", room)
end end
function Room:serializeWires()
local wires = {}
for y, row in pairs(self.wires) do
for x, tracks in pairs(row) do
for circuitId, edges in pairs(tracks) do
table.insert(wires, {x = x, y = y, circuit = circuitId, edges = edges})
end
end
end
table.sort(wires, function(a, b)
if a.y ~= b.y then return a.y < b.y end
if a.x ~= b.x then return a.x < b.x end
return a.circuit < b.circuit
end)
return wires
end
-- Cross-room wire editing may touch a neighbour loaded from runtime state.
-- Merge only its authored wire slice so saving a route cannot accidentally
-- bake that neighbour's runtime block/player positions into the source level.
function Room:saveWiresToSource()
if not self.name then return false end
local raw = readFromSource("rooms/" .. self.name .. ".sav")
local ok, saveTable = raw and pcall(TSerial.unpack, raw)
if not ok or type(saveTable) ~= "table" then saveTable = self:serialize(false) end
saveTable.wires = self:serializeWires()
return writeToSource("rooms/" .. self.name .. ".sav", TSerial.pack(saveTable, nil, true))
end
function Room:serialize(includePlayers) function Room:serialize(includePlayers)
local objects = {} local objects = {}
local function emit(class, color, entity) local function emit(class, color, entity)
local pos = entity:getGridPos() local pos = entity:getGridPos()
table.insert(objects, { class = class, color = color, x = pos.x, y = pos.y }) local object = { class = class, color = color, x = pos.x, y = pos.y }
if entity.circuitId then object.circuit = entity.circuitId end
table.insert(objects, object)
end end
if includePlayers ~= false then if includePlayers ~= false then
@ -760,15 +1005,19 @@ function Room:serialize(includePlayers)
emit("switch", color, switch) emit("switch", color, switch)
end end
end end
for _, switch in ipairs(self.wiredSwitches) do
emit("wired_switch", switch.color:getname(), switch)
end
for _, npc in ipairs(self.npcs) do for _, npc in ipairs(self.npcs) do
table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y }) table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y })
end end
return { objects = objects } return { objects = objects, wires = self:serializeWires() }
end end
function nextRoom(pos) function nextRoom(pos)
if gameWorld and currentRoom then return gameWorld:nextRoom(currentRoom, pos) end
local exit local exit
local newPos = {x = pos.x, y = pos.y} local newPos = {x = pos.x, y = pos.y}
if pos.x > gridWidth then if pos.x > gridWidth then
@ -791,13 +1040,17 @@ function nextRoom(pos)
-- Missing map neighbours intentionally wrap back into the room just exited. -- Missing map neighbours intentionally wrap back into the room just exited.
-- A placed neighbour gets the same carried player state at its opposite edge. -- A placed neighbour gets the same carried player state at its opposite edge.
currentRoom = Room:new{ local playerState = {x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors()}
name = nextName or currentName, currentRoom = gameWorld and gameWorld.levels[nextName or currentName] or Room:new{name = nextName or currentName, runtime = true}
runtime = true, if currentRoom then
player = { currentRoom.activeColors = {red = false, green = false, blue = false}
x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors() for _, color in ipairs(playerState.activeColors) do
} if currentRoom.player[color] then currentRoom.player[color].x, currentRoom.player[color].y = playerState.x, playerState.y
} else currentRoom:registerPlayer(Player:new(playerState.x, playerState.y, color), color) end
currentRoom:setActiveColor(color, true)
end
gameWorld:playerJoinCheck(currentRoom)
end
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end if gameWorld then gameWorld:setLastRoom(currentRoom.name) end
if currentRoom.name then setEditorRoomName(currentRoom.name) end if currentRoom.name then setEditorRoomName(currentRoom.name) end

207
rooms/room_08_13.sav Normal file
View file

@ -0,0 +1,207 @@
{
objects={
{
color="green",
x=3,
y=5,
active=true,
class="player"
},
{
color="blue",
x=3,
y=5,
active=true,
class="player"
},
{
color="red",
x=3,
y=5,
active=true,
class="player"
},
{
color="green",
x=2,
y=4,
class="box"
},
{
color="blue",
x=2,
y=9,
class="box"
},
{
color="blue",
x=3,
y=6,
class="box"
},
{
color="red",
x=2,
y=9,
class="box"
},
{
color="red",
x=3,
y=6,
class="box"
},
{
color="red",
x=3,
y=9,
class="wired_switch"
},
{
color="green",
x=3,
y=9,
class="wired_switch"
},
{
color="blue",
x=3,
y=9,
class="wired_switch"
},
{
color="green",
x=3,
y=4,
class="wired_switch"
}
},
wires={
{
edges=6,
x=4,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=5,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=6,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=12,
x=7,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=6,
x=10,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=11,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=2,
x=3,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=9,
x=4,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=3,
x=7,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=8,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=9,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=9,
x=10,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=2,
x=3,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=4,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=5,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=6,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=7,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=8,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=9,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=10,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=11,
y=9,
circuit="wire_6a7bdb2a_0001"
}
}
}

217
rooms/room_09_13.sav Normal file
View file

@ -0,0 +1,217 @@
{
objects={
{
color="green",
x=3,
y=7,
active=true,
class="player"
},
{
color="blue",
x=3,
y=7,
active=true,
class="player"
},
{
color="red",
x=3,
y=7,
active=true,
class="player"
},
{
color="green",
x=9,
y=8,
circuit="wire_6a7bdb2a_0001",
class="wired_door"
},
{
color="green",
x=5,
y=8,
class="box"
},
{
color="blue",
x=9,
y=8,
circuit="wire_6a7bdb2a_0001",
class="wired_door"
},
{
color="blue",
x=7,
y=4,
circuit="wire_6a7bd969_000a",
class="wired_door"
},
{
color="red",
x=9,
y=8,
circuit="wire_6a7bdb2a_0001",
class="wired_door"
},
{
color="green",
x=5,
y=7,
class="wired_switch"
}
},
wires={
{
edges=12,
x=1,
y=3,
circuit="wire_6a7bd969_000a"
},
{
edges=3,
x=1,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=2,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=3,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=4,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=5,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=10,
x=6,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=8,
x=7,
y=4,
circuit="wire_6a7bd969_000a"
},
{
edges=4,
x=7,
y=4,
circuit="wire_6a7bdf16_0001"
},
{
edges=5,
x=7,
y=5,
circuit="wire_6a7bdf16_0001"
},
{
edges=6,
x=5,
y=6,
circuit="wire_6a7bdf16_0001"
},
{
edges=10,
x=6,
y=6,
circuit="wire_6a7bdf16_0001"
},
{
edges=9,
x=7,
y=6,
circuit="wire_6a7bdf16_0001"
},
{
edges=0,
x=5,
y=7,
circuit="wire_6a7bd969_000a"
},
{
edges=1,
x=5,
y=7,
circuit="wire_6a7bdf16_0001"
},
{
edges=4,
x=9,
y=8,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=1,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=2,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=3,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=4,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=5,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=6,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=7,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=10,
x=8,
y=9,
circuit="wire_6a7bdb2a_0001"
},
{
edges=9,
x=9,
y=9,
circuit="wire_6a7bdb2a_0001"
}
}
}

4
rooms/room_13_08.sav Normal file
View file

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

4
rooms/room_14_07.sav Normal file
View file

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

View file

@ -1,255 +1,369 @@
{ {
objects={ objects={
{ {
color="blue",
class="door", class="door",
color="blue",
x=6, x=6,
y=1 y=1
}, },
{ {
color="red", class="static_wall",
color="blue",
x=1,
y=1
},
{
class="static_wall",
color="blue",
x=2,
y=1
},
{
class="static_wall",
color="blue",
x=3,
y=1
},
{
class="static_wall",
color="blue",
x=4,
y=1
},
{
class="static_wall",
color="blue",
x=5,
y=1
},
{
class="static_wall",
color="blue",
x=7,
y=1
},
{
class="static_wall",
color="blue",
x=8,
y=1
},
{
class="static_wall",
color="blue",
x=9,
y=1
},
{
class="static_wall",
color="blue",
x=10,
y=1
},
{
class="static_wall",
color="blue",
x=11,
y=1
},
{
class="static_wall",
color="blue",
x=1,
y=11
},
{
class="static_wall",
color="blue",
x=11,
y=11
},
{
class="spiky_box_2x1_11",
color="blue",
x=1,
y=6
},
{
class="box",
color="blue",
x=3,
y=6
},
{
class="box_1x2_1-1",
color="blue",
x=4,
y=6
},
{
class="spiky_box_1x2_1-1",
color="blue",
x=5,
y=6
},
{
class="box_2x2_11-10",
color="blue",
x=6,
y=5
},
{
class="spiky_box_2x1_11",
color="blue",
x=7,
y=6
},
{
class="spiky_box_2x1_11",
color="blue",
x=9,
y=6
},
{
class="door", class="door",
color="red",
x=6, x=6,
y=1 y=1
}, },
{ {
color="green", class="static_wall",
color="red",
x=1,
y=1
},
{
class="static_wall",
color="red",
x=2,
y=1
},
{
class="static_wall",
color="red",
x=3,
y=1
},
{
class="static_wall",
color="red",
x=4,
y=1
},
{
class="static_wall",
color="red",
x=5,
y=1
},
{
class="static_wall",
color="red",
x=7,
y=1
},
{
class="static_wall",
color="red",
x=8,
y=1
},
{
class="static_wall",
color="red",
x=9,
y=1
},
{
class="static_wall",
color="red",
x=10,
y=1
},
{
class="static_wall",
color="red",
x=11,
y=1
},
{
class="static_wall",
color="red",
x=1,
y=11
},
{
class="static_wall",
color="red",
x=11,
y=11
},
{
class="spiky_box_2x1_11",
color="red",
x=1,
y=6
},
{
class="box",
color="red",
x=3,
y=6
},
{
class="box_1x2_1-1",
color="red",
x=4,
y=6
},
{
class="spiky_box_1x2_1-1",
color="red",
x=5,
y=6
},
{
class="box_2x2_11-10",
color="red",
x=6,
y=5
},
{
class="spiky_box_2x1_11",
color="red",
x=7,
y=6
},
{
class="spiky_box_2x1_11",
color="red",
x=9,
y=6
},
{
class="door", class="door",
color="green",
x=6, x=6,
y=1 y=1
}, },
{ {
color="blue",
class="static_wall", class="static_wall",
x=1,
y=1
},
{
color="red",
class="static_wall",
x=1,
y=1
},
{
color="green", color="green",
class="static_wall",
x=1, x=1,
y=1 y=1
}, },
{ {
color="blue",
class="static_wall", class="static_wall",
color="green",
x=2, x=2,
y=1 y=1
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=2,
y=1
},
{
color="green", color="green",
class="static_wall",
x=2,
y=1
},
{
color="blue",
class="static_wall",
x=3, x=3,
y=1 y=1
}, },
{ {
color="green",
class="static_wall", class="static_wall",
color="green",
x=4,
y=1
},
{
class="static_wall",
color="green",
x=5,
y=1
},
{
class="static_wall",
color="green",
x=7,
y=1
},
{
class="static_wall",
color="green",
x=8,
y=1
},
{
class="static_wall",
color="green",
x=9,
y=1
},
{
class="static_wall",
color="green",
x=10,
y=1
},
{
class="static_wall",
color="green",
x=11,
y=1
},
{
class="static_wall",
color="green",
x=1,
y=11
},
{
class="static_wall",
color="green",
x=11,
y=11
},
{
class="spiky_box_2x1_11",
color="green",
x=3, x=3,
y=1 y=6
},
{
color="red",
class="static_wall",
x=3,
y=1
},
{
color="red",
class="static_wall",
x=4,
y=1
}, },
{ {
class="spiky_box_2x1_11",
color="green", color="green",
class="static_wall",
x=4,
y=1
},
{
color="blue",
class="static_wall",
x=4,
y=1
},
{
color="blue",
class="static_wall",
x=5,
y=1
},
{
color="red",
class="static_wall",
x=5,
y=1
},
{
color="green",
class="static_wall",
x=5,
y=1
},
{
color="green",
class="static_wall",
x=7,
y=1
},
{
color="blue",
class="static_wall",
x=7,
y=1
},
{
color="red",
class="static_wall",
x=7,
y=1
},
{
color="red",
class="static_wall",
x=8,
y=1
},
{
color="green",
class="static_wall",
x=8,
y=1
},
{
color="blue",
class="static_wall",
x=8,
y=1
},
{
color="red",
class="static_wall",
x=9,
y=1
},
{
color="green",
class="static_wall",
x=9,
y=1
},
{
color="blue",
class="static_wall",
x=9,
y=1
},
{
color="blue",
class="static_wall",
x=10,
y=1
},
{
color="red",
class="static_wall",
x=10,
y=1
},
{
color="green",
class="static_wall",
x=10,
y=1
},
{
color="red",
class="static_wall",
x=11,
y=1
},
{
color="green",
class="static_wall",
x=11,
y=1
},
{
color="blue",
class="static_wall",
x=11,
y=1
},
{
color="red",
class="static_wall",
x=1, x=1,
y=11 y=6
},
{
color="blue",
class="static_wall",
x=1,
y=11
}, },
{ {
class="box",
color="green", color="green",
class="static_wall", x=5,
x=1, y=6
y=11
},
{
color="red",
class="static_wall",
x=11,
y=11
},
{
color="blue",
class="static_wall",
x=11,
y=11
}, },
{ {
class="box_1x2_1-1",
color="green", color="green",
class="static_wall",
x=11,
y=11
},
{
color="red",
class="switch",
x=6, x=6,
y=6 y=6
}, },
{ {
color="blue", class="spiky_box_1x2_1-1",
class="switch", color="green",
x=6, x=7,
y=6 y=6
}, },
{ {
class="box_2x2_11-10",
color="green", color="green",
class="switch", x=8,
x=6, y=5
},
{
class="spiky_box_2x1_11",
color="green",
x=9,
y=6
},
{
class="spiky_box_2x1_11",
color="green",
x=11,
y=6 y=6
} }
} }

File diff suppressed because it is too large Load diff

View file

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

206
tests/test_wire.lua Normal file
View file

@ -0,0 +1,206 @@
package.path = "./?.lua;" .. package.path
class = require "libs/middleclass"
local Wire = require "wire"
require "world"
gridWidth, gridHeight = 2, 2
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 switch(color, x, y)
local entity = {x = x, y = y, activated = false}
entity.color = {getname = function() return color end}
function entity:getGridPos() return {x = self.x, y = self.y} end
function entity:activate() self.activated = true end
function entity:deactivate() self.activated = false end
return entity
end
local function door(circuitId, x, y)
local entity = {circuitId = circuitId, x = x, y = y, powered = false}
function entity:getGridPos() return {x = self.x, y = self.y} end
function entity:setPowered(powered) self.powered = powered end
return entity
end
local function makeWorld(secondSwitch)
local blueSwitch = switch("blue", 2, 1)
local wiredDoor = door("circuit_a", 1, 1)
local roomA = {
name = "a", player = {}, wiredSwitches = {}, wiredDoors = {wiredDoor},
wires = {[1] = {
[1] = {circuit_a = Wire.EAST},
[2] = {circuit_a = Wire.WEST + Wire.EAST},
}},
}
local roomB = {
name = "b", player = {}, wiredSwitches = {blueSwitch}, wiredDoors = {},
wires = {[1] = {
[1] = {circuit_a = Wire.WEST + Wire.EAST},
[2] = {circuit_a = Wire.WEST},
}},
}
local function wireCircuitAt(room, cell)
local tracks = room.wires[cell.y] and room.wires[cell.y][cell.x]
for circuitId in pairs(tracks or {}) do return circuitId end
end
roomA.wireCircuitAt = wireCircuitAt
roomB.wireCircuitAt = wireCircuitAt
if secondSwitch then table.insert(roomB.wiredSwitches, secondSwitch) end
local instance = setmetatable({
levels = {a = roomA, b = roomB},
roomByName = {a = {name = "a", x = 1, y = 1}, b = {name = "b", x = 2, y = 1}},
collidableMatrices = {red = {}, green = {}, blue = {}},
}, {__index = World})
function instance:roomCell(cell)
if cell.x <= gridWidth then return roomA, {x = cell.x, y = cell.y} end
return roomB, {x = cell.x - gridWidth, y = cell.y}
end
local function wireTracksAt(room, cell, create)
local row = room.wires[cell.y]
if not row and create then row = {}; room.wires[cell.y] = row end
local tracks = row and row[cell.x]
if not tracks and create then tracks = {}; row[cell.x] = tracks end
return tracks
end
roomA.wireTracksAt = wireTracksAt
roomB.wireTracksAt = wireTracksAt
return instance, roomA, roomB, wiredDoor, blueSwitch
end
test("direction masks add and remove without duplicating bits", function()
local mask = Wire.add(0, Wire.EAST)
mask = Wire.add(mask, Wire.EAST)
assertTrue(mask == Wire.EAST)
assertTrue(Wire.has(mask, Wire.EAST))
assertFalse(Wire.has(mask, Wire.WEST))
assertTrue(Wire.remove(mask, Wire.EAST) == 0)
end)
test("orphaned and overlapping wire cells remain selectable without a door", function()
local world, roomA = makeWorld()
roomA.wires[1][1].orphan_b = Wire.SOUTH
local top = world:circuitAt({x = 1, y = 1})
assertTrue(top == "orphan_b", "top orphaned circuit was not selectable")
local preferred = world:circuitAt({x = 1, y = 1}, "circuit_a")
assertTrue(preferred == "circuit_a", "selected overlapping circuit was not preferred")
end)
test("a white switch powers every layer of a white door", function()
local world, roomA, roomB = makeWorld()
roomA.wiredDoors = {
door("circuit_a", 1, 1),
door("old_green_id", 1, 1),
door("old_blue_id", 1, 1),
}
roomB.wiredSwitches = {
switch("red", 2, 1),
switch("green", 2, 1),
switch("blue", 2, 1),
}
world.collidableMatrices.red[1] = {[4] = {}}
world.collidableMatrices.green[1] = {[4] = {}}
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
for _, wiredDoor in ipairs(roomA.wiredDoors) do
assertTrue(wiredDoor.circuitId == "circuit_a", "door layer did not adopt terminal circuit")
assertTrue(world.circuitState.doorPowered[wiredDoor], "white door layer remained locked")
end
local color = world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a")
assertTrue(color[1] == 1 and color[2] == 1 and color[3] == 1, "white switch did not produce white wire")
local redGreenOnly = world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a", {red = true, green = true})
assertTrue(redGreenOnly[1] == 1 and redGreenOnly[2] == 1 and redGreenOnly[3] == 0,
"white wire did not obey the room's visible player channels")
end)
test("world matrices join a circuit across a room boundary", function()
local world, _, _, wiredDoor, blueSwitch = makeWorld()
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
assertTrue(world.wireMatrix[1][2].circuit_a ~= nil)
assertTrue(world.wireMatrix[1][3].circuit_a ~= nil)
assertTrue(world.wiredDoorMatrix[1][1][1].entity == wiredDoor)
assertTrue(world.wiredSwitchMatrix[1][4][1].entity == blueSwitch)
assertTrue(world.circuitState.switchPressed[blueSwitch])
assertTrue(world.circuitState.doorPowered[wiredDoor])
assertTrue(wiredDoor.powered)
assertTrue(world:wireColorAt(world.levels.a, {x = 1, y = 1}, "circuit_a", {red = true, green = true}) == nil,
"blue wire leaked into a room without a blue player channel")
local visibleBlue = world:wireColorAt(world.levels.a, {x = 1, y = 1}, "circuit_a", {blue = true})
assertTrue(visibleBlue[1] == 0 and visibleBlue[2] == 0 and visibleBlue[3] == 1,
"blue wire did not render in a room with a blue player channel")
end)
test("wire color is re-derived when its terminal switch changes", function()
local world, roomA, roomB = makeWorld()
world:resolveCircuits()
local colors = world:wireSwitchColorsAt(roomA, {x = 1, y = 1}, "circuit_a")
assertTrue(colors.blue and not colors.red)
roomB.wiredSwitches = {switch("red", 2, 1)}
world:resolveCircuits()
colors = world:wireSwitchColorsAt(roomA, {x = 1, y = 1}, "circuit_a")
assertTrue(colors.red and not colors.blue, "wire retained the deleted terminal switch color")
end)
test("a switchless wire component has no gameplay color", function()
local world, roomA, roomB = makeWorld()
roomB.wiredSwitches = {}
world:resolveCircuits()
assertTrue(world:wireSwitchColorsAt(roomA, {x = 1, y = 1}, "circuit_a") == nil)
assertTrue(world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a") == nil)
end)
test("a door requires every independent switch run at its terminal", function()
local greenSwitch = switch("green", 1, 2)
local world, roomA, _, wiredDoor = makeWorld()
table.insert(roomA.wiredSwitches, greenSwitch)
roomA.wires[1][1].circuit_b = Wire.SOUTH
roomA.wires[2] = {[1] = {circuit_b = Wire.NORTH}}
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
assertFalse(world.circuitState.doorPowered[wiredDoor])
world.collidableMatrices.green[2] = {[1] = {}}
world:resolveCircuits()
assertTrue(world.circuitState.doorPowered[wiredDoor])
end)
test("one run cannot terminate at two different switches", function()
local second = switch("red", 1, 2)
local world, _, roomB = makeWorld(second)
world:resolveCircuits()
local rooms, err = world:addWireConnection({x = 3, y = 1}, {x = 3, y = 2}, "circuit_a")
assertTrue(#rooms == 0 and err ~= nil, "second switch terminal was accepted")
assertTrue(roomB.wires[2] == nil or roomB.wires[2][1] == nil,
"rejected second terminal left a connected wire cell")
local colors = world:wireSwitchColorsAt(world.levels.a, {x = 1, y = 1}, "circuit_a")
assertTrue(colors.blue and not colors.red, "run stopped deriving color from its sole terminal switch")
end)
test("matching IDs do not bridge a gap or one-sided room edge", function()
local world, _, roomB, wiredDoor = makeWorld()
world.collidableMatrices.blue[1] = {[4] = {}}
roomB.wires[1][1].circuit_a = Wire.EAST -- no reciprocal WEST edge
world:resolveCircuits()
assertFalse(world.circuitState.doorPowered[wiredDoor])
end)
io.write("PASS: " .. passed .. " wire tests\n")

55
wire.lua Normal file
View file

@ -0,0 +1,55 @@
local Wire = {
NORTH = 1,
EAST = 2,
SOUTH = 4,
WEST = 8,
}
Wire.directions = {
{bit = Wire.NORTH, opposite = Wire.SOUTH, x = 0, y = -1},
{bit = Wire.EAST, opposite = Wire.WEST, x = 1, y = 0},
{bit = Wire.SOUTH, opposite = Wire.NORTH, x = 0, y = 1},
{bit = Wire.WEST, opposite = Wire.EAST, x = -1, y = 0},
}
function Wire.has(mask, bit)
return math.floor((mask or 0) / bit) % 2 == 1
end
function Wire.add(mask, bit)
mask = mask or 0
return Wire.has(mask, bit) and mask or mask + bit
end
function Wire.remove(mask, bit)
mask = mask or 0
return Wire.has(mask, bit) and mask - bit or mask
end
function Wire.directionBetween(a, b)
local dx, dy = b.x - a.x, b.y - a.y
for _, direction in ipairs(Wire.directions) do
if direction.x == dx and direction.y == dy then return direction end
end
end
-- Circuit colours are deliberately independent from the RGB gameplay layers.
-- A stable hash makes a route keep the same editor colour after save/load.
local palette = {
{1.00, 0.78, 0.20},
{0.20, 0.92, 0.92},
{1.00, 0.42, 0.72},
{0.62, 1.00, 0.32},
{0.72, 0.55, 1.00},
{1.00, 0.55, 0.22},
}
function Wire.color(circuitId)
local hash = 0
for index = 1, #(circuitId or "") do
hash = (hash * 33 + circuitId:byte(index)) % 65521
end
return palette[hash % #palette + 1]
end
return Wire

30
wired_door.lua Normal file
View file

@ -0,0 +1,30 @@
require "door"
local Wire = require "wire"
WiredDoor = class("WiredDoor", Door)
function WiredDoor:initialize(x, y, color, circuitId)
Door.initialize(self, x, y, color)
self.circuitId = circuitId
self.wired = true
end
function WiredDoor:setPowered(powered)
if powered then
if self.locked then self:unlock() end
elseif not self.locked then
self:lock()
end
self:refreshSprites()
end
function WiredDoor:draw(channel)
Entity.draw(self, channel)
if not self.sprites[channel] then return end
local p = self:getDrawPos()
local color = Wire.color(self.circuitId)
love.graphics.setColor(color[1], color[2], color[3], 1)
love.graphics.setLineWidth(math.max(1, drawScale))
love.graphics.circle("line", p.x + 8 * drawScale, p.y + 8 * drawScale, 3 * drawScale)
end

17
wired_switch.lua Normal file
View file

@ -0,0 +1,17 @@
require "switch"
WiredSwitch = class("WiredSwitch", Switch)
function WiredSwitch:initialize(x, y, color)
Switch.initialize(self, x, y, color)
self.wired = true
end
function WiredSwitch:draw(channel)
Entity.draw(self, channel)
if not self.sprites[channel] then return end
local p = self:getDrawPos()
love.graphics.setColor(1, 1, 1, 0.9)
love.graphics.setLineWidth(math.max(1, drawScale))
love.graphics.rectangle("line", p.x + 5 * drawScale, p.y + 5 * drawScale, 6 * drawScale, 6 * drawScale)
end

761
world.lua
View file

@ -1,8 +1,11 @@
World = class("World") World = class("World")
-- The world is deliberately only a layout: rooms continue to own their puzzle local Movement = require "movement"
-- state. Keeping the two separate lets the editor move a room without local Wire = require "wire"
-- rewriting its level file.
-- Rooms own authored slices (objects and wire fragments). World composes those
-- slices into authoritative live collision and circuit matrices, so moving a
-- room changes placement without rewriting its level file.
function World:initialize() function World:initialize()
self.width, self.height = 18, 18 self.width, self.height = 18, 18
self.rooms = {} self.rooms = {}
@ -10,6 +13,72 @@ function World:initialize()
self.previewCache = {} self.previewCache = {}
self.lastRoom = nil self.lastRoom = nil
self:load() self:load()
self:loadRuntimeState()
self.levels = {}
self.circuitSerial = 0
end
function World:loadLevels()
-- Gameplay saves go to LÖVE's runtime directory. Seed every world slice
-- through the same runtime-first loader, not just the initially visible one.
for _, placed in ipairs(self.rooms) do self.levels[placed.name] = Room:new{name = placed.name, runtime = true} end
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
-- The editor can replace a level with a freshly loaded authored Room. Keep the
-- global simulation pointing at that exact instance, not a stale preload.
function World:setLevel(room)
if not room or not room.name or not self:roomLocation(room.name) then return false end
self.levels[room.name] = room
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
return true
end
function World:newCircuitId()
local used = {}
for _, room in pairs(self.levels or {}) do
for _, door in ipairs(room.wiredDoors or {}) do used[door.circuitId] = true end
for _, row in pairs(room.wires or {}) do
for _, tracks in pairs(row) do for circuitId in pairs(tracks) do used[circuitId] = true end end
end
end
repeat
self.circuitSerial = self.circuitSerial + 1
local id = string.format("wire_%08x_%04x", os.time() % 0xffffffff, self.circuitSerial)
if not used[id] then return id end
until false
end
-- Runtime state is one logical world snapshot: each level slice is written to
-- LÖVE's save directory, and this companion file says which slice was active.
-- The authored world layout remains source-only for the editor.
function World:loadRuntimeState()
local raw = love.filesystem.read("rooms/world.sav")
if not raw then return end
local ok, state = pcall(TSerial.unpack, raw)
if ok and type(state) == "table" and state.lastRoom and self:roomLocation(state.lastRoom) then
self.lastRoom = state.lastRoom
end
end
function World:saveRuntime(dirtyRooms)
for room in pairs(dirtyRooms or {}) do room:save() end
return love.filesystem.write("rooms/world.sav", TSerial.pack({lastRoom = currentRoom and currentRoom.name}, nil, true))
end
function World:markDirty(room)
self.dirtyRooms = self.dirtyRooms or {}
self.dirtyRooms[room] = true
end
function World:flushRuntime()
self:saveRuntime(self.dirtyRooms)
self.dirtyRooms = {}
end end
function World:listRoomNames() function World:listRoomNames()
@ -80,7 +149,13 @@ end
function World:placeRoom(name, x, y) function World:placeRoom(name, x, y)
if not name or not self:isInBounds(x, y) then return false end if not name or not self:isInBounds(x, y) then return false end
self:_putRoom(name, x, y) self:_putRoom(name, x, y)
return self:save() local saved = self:save()
if self.levels then
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
return saved
end end
function World:removeRoom(x, y) function World:removeRoom(x, y)
@ -90,7 +165,13 @@ function World:removeRoom(x, y)
self.roomByName[room.name] = nil self.roomByName[room.name] = nil
table.remove(self.rooms, i) table.remove(self.rooms, i)
if self.lastRoom == room.name then self.lastRoom = nil end if self.lastRoom == room.name then self.lastRoom = nil end
return self:save(), room.name local saved = self:save()
if self.levels then
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
return saved, room.name
end end
end end
return false return false
@ -111,7 +192,6 @@ end
function World:setLastRoom(name) function World:setLastRoom(name)
if name and self.lastRoom ~= name then if name and self.lastRoom ~= name then
self.lastRoom = name self.lastRoom = name
return self:save()
end end
return true return true
end end
@ -128,6 +208,600 @@ function World:getNeighbor(name, exit)
return self:roomAt(room.x + direction.x, room.y + direction.y) return self:roomAt(room.x + direction.x, room.y + direction.y)
end end
-- All placed levels share these sparse matrices. Rendering still uses the
-- selected Room, whose ordinary local matrices remain unchanged.
function World:rebuildCollision()
self.collidableMatrices = {red = {}, green = {}, blue = {}}
self.playerBlockerMatrices = {red = {}, green = {}, blue = {}}
self.entityRooms = {}
local function put(matrix, cell, entity)
matrix[cell.y] = matrix[cell.y] or {}
matrix[cell.y][cell.x] = entity
end
for name, room in pairs(self.levels) do
local location = self:roomLocation(name)
if location then
for color, entities in pairs(room.collideables) do
for _, entity in pairs(entities) do
self.entityRooms[entity] = room
for _, cell in ipairs(entity:getOccupiedCells()) do
put(self.collidableMatrices[color], {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}, entity)
end
end
end
-- NPCs are local room state, but globally collidable: a movement lookup
-- must find them so its normal bump() path can open dialogue.
for _, npc in ipairs(room.npcs) do
self.entityRooms[npc] = room
for _, cell in ipairs(npc:getOccupiedCells()) do
local worldCell = {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}
for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end
end
end
for color, entities in pairs(room.playerBlockers) do
for _, entity in pairs(entities) do
for _, cell in ipairs(entity:getOccupiedCells()) do
put(self.playerBlockerMatrices[color], {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}, entity)
end
end
end
end
end
end
function World:worldCell(room, cell)
local location = self:roomLocation(room.name)
return location and {x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y}
end
function World:roomCell(cell)
local roomX = math.floor((cell.x - 1) / gridWidth) + 1
local roomY = math.floor((cell.y - 1) / gridHeight) + 1
local name = self:roomAt(roomX, roomY)
local room = name and self.levels[name]
return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight}
end
local function cellKey(cell)
return cell.x .. ":" .. cell.y
end
function World:isSwitchPressed(room, switch)
local color = switch.color:getname()
local cell = self:worldCell(room, switch:getGridPos())
local row = cell and self.collidableMatrices[color][cell.y]
local target = row and row[cell.x]
local player = room.player[color]
return target ~= nil or (player and player:occupiesCell(switch:getGridPos())) or false
end
function World:rebuildCircuitMatrices()
self.wireMatrix = {}
self.wiredSwitchMatrix = {}
self.wiredDoorMatrix = {}
local function cellAt(matrix, cell, create)
local row = matrix[cell.y]
if not row and create then row = {}; matrix[cell.y] = row end
local value = row and row[cell.x]
return value, row
end
local function addDevice(matrix, cell, device, room)
local devices, row = cellAt(matrix, cell, true)
if not devices then devices = {}; row[cell.x] = devices end
table.insert(devices, {entity = device, room = room})
end
for name, room in pairs(self.levels or {}) do
if self:roomLocation(name) then
for _, switch in ipairs(room.wiredSwitches or {}) do
addDevice(self.wiredSwitchMatrix, self:worldCell(room, switch:getGridPos()), switch, room)
end
for _, door in ipairs(room.wiredDoors or {}) do
-- A terminal painted beneath the door is authoritative. This also
-- repairs older white doors whose RGB layers were assigned separate
-- IDs before layered placement shared a circuit.
door.circuitId = room:wireCircuitAt(door:getGridPos()) or door.circuitId
addDevice(self.wiredDoorMatrix, self:worldCell(room, door:getGridPos()), door, room)
end
for y, sourceRow in pairs(room.wires or {}) do
for x, sourceTracks in pairs(sourceRow) do
local worldCell = self:worldCell(room, {x = x, y = y})
local tracks, row = cellAt(self.wireMatrix, worldCell, true)
if not tracks then tracks = {}; row[worldCell.x] = tracks end
for circuitId, mask in pairs(sourceTracks) do
tracks[circuitId] = {mask = mask, room = room, localCell = {x = x, y = y}}
end
end
end
end
end
end
-- Rebuild the electrical graph from room-local fragments projected onto the
-- world grid. IDs keep overlapping routes distinct; reciprocal direction bits
-- are what actually make two neighbouring cells electrically continuous.
function World:resolveCircuits()
if not self.collidableMatrices then return end
self:rebuildCircuitMatrices()
local circuits = {}
local switchesAt = {}
local switchPressed = {}
for y, row in pairs(self.wiredSwitchMatrix) do
for x, devices in pairs(row) do
local key = cellKey({x = x, y = y})
for _, device in ipairs(devices) do
local switch, room = device.entity, device.room
local pressed = self:isSwitchPressed(room, switch)
switchPressed[switch] = pressed
-- Entity flags are derived caches used by the existing sprite API.
-- World.circuitState below remains the authoritative logical state.
if pressed then switch:activate() else switch:deactivate() end
switchesAt[key] = switchesAt[key] or {}
table.insert(switchesAt[key], switch)
end
end
end
for y, row in pairs(self.wireMatrix) do
for x, tracks in pairs(row) do
local worldCell = {x = x, y = y}
local key = cellKey(worldCell)
for circuitId, track in pairs(tracks) do
circuits[circuitId] = circuits[circuitId] or {}
circuits[circuitId][key] = {cell = worldCell, mask = track.mask}
end
end
end
local components = {}
for circuitId, nodes in pairs(circuits) do
components[circuitId] = {}
local visited = {}
for key, node in pairs(nodes) do
if not visited[key] then
local component = {
switches = {}, nodes = {}, switchColors = {red = false, green = false, blue = false},
terminalCells = {}, valid = false,
}
local pending, nextNode = {node}, 1
visited[key] = component
while nextNode <= #pending do
local current = pending[nextNode]
nextNode = nextNode + 1
local currentKey = cellKey(current.cell)
component.nodes[currentKey] = true
local degree = 0
for _, direction in ipairs(Wire.directions) do
if Wire.has(current.mask, direction.bit) then
local adjacentKey = cellKey({x = current.cell.x + direction.x, y = current.cell.y + direction.y})
local adjacent = nodes[adjacentKey]
if adjacent and Wire.has(adjacent.mask, direction.opposite) then
degree = degree + 1
if not visited[adjacentKey] then
visited[adjacentKey] = component
table.insert(pending, adjacent)
end
end
end
end
-- A switch connects only when the run literally terminates on its
-- cell. Merely crossing or passing through a switch does not claim it.
if degree <= 1 and switchesAt[currentKey] then
component.terminalCells[currentKey] = switchesAt[currentKey]
end
end
local terminalCount, terminalSwitches = 0
for _, terminal in pairs(component.terminalCells) do
terminalCount = terminalCount + 1
terminalSwitches = terminal
end
-- Multiple RGB entities at one cell are one logical (possibly white)
-- switch. Two different switch cells can never belong to one wire run.
component.valid = terminalCount == 1
component.terminalCount = terminalCount
if component.valid then
for _, switch in ipairs(terminalSwitches) do
component.switches[switch] = true
component.switchColors[switch.color:getname()] = true
end
end
components[circuitId][key] = component
for componentKey in pairs(component.nodes) do components[circuitId][componentKey] = component end
end
end
end
local doorPowered = {}
for y, row in pairs(self.wiredDoorMatrix) do
for x, devices in pairs(row) do
for _, device in ipairs(devices) do
local door = device.entity
local position = {x = x, y = y}
local key = cellKey(position)
local tracks = self.wireMatrix[y] and self.wireMatrix[y][x]
local runCount, powered = 0, tracks ~= nil
for circuitId in pairs(tracks or {}) do
runCount = runCount + 1
local component = components[circuitId] and components[circuitId][key]
if not component or not component.valid then
powered = false
else
for switch in pairs(component.switches) do
if not switchPressed[switch] then powered = false end
end
end
end
powered = powered and runCount > 0
doorPowered[door] = powered
-- locked/collision is a projection of world state onto the physical
-- room entity so the existing movement renderer can consume it.
door:setPowered(powered)
end
end
end
self.circuits = circuits
self.circuitComponents = components
self.circuitState = {
switchPressed = switchPressed,
doorPowered = doorPowered,
components = components,
}
end
function World:wireSwitchColorsAt(room, localCell, circuitId)
local worldCell = self:worldCell(room, localCell)
local component = worldCell and self.circuitComponents and
self.circuitComponents[circuitId] and self.circuitComponents[circuitId][cellKey(worldCell)]
if not component or not component.valid then return nil end
local colors = component.switchColors
if not colors.red and not colors.green and not colors.blue then return nil end
return colors
end
function World:wireColorAt(room, localCell, circuitId, visibleColors)
local colors = self:wireSwitchColorsAt(room, localCell, circuitId)
if not colors then return nil end
visibleColors = visibleColors or {red = true, green = true, blue = true}
local color = {
colors.red and visibleColors.red and 1 or 0,
colors.green and visibleColors.green and 1 or 0,
colors.blue and visibleColors.blue and 1 or 0,
}
if color[1] == 0 and color[2] == 0 and color[3] == 0 then return nil end
return {
color[1], color[2], color[3],
}
end
function World:ensureWireAt(cell, circuitId)
local room, localCell = self:roomCell(cell)
if not room then return {} end
local tracks = room:wireTracksAt(localCell, true)
if tracks[circuitId] == nil then tracks[circuitId] = 0 end
self:resolveCircuits()
return {room}
end
function World:addWireConnection(from, to, circuitId)
local direction = Wire.directionBetween(from, to)
if not direction then return {} end
local fromRoom, fromCell = self:roomCell(from)
local toRoom, toCell = self:roomCell(to)
if not fromRoom or not toRoom then return {} end
local fromTracks = fromRoom:wireTracksAt(fromCell, true)
local toTracks = toRoom:wireTracksAt(toCell, true)
local oldFrom, oldTo = fromTracks[circuitId], toTracks[circuitId]
fromTracks[circuitId] = Wire.add(fromTracks[circuitId], direction.bit)
toTracks[circuitId] = Wire.add(toTracks[circuitId], direction.opposite)
self:resolveCircuits()
local component = self.circuitComponents[circuitId] and self.circuitComponents[circuitId][cellKey(from)]
if component and component.terminalCount > 1 then
fromTracks[circuitId], toTracks[circuitId] = oldFrom, oldTo
if oldFrom == nil and not next(fromTracks) then fromRoom.wires[fromCell.y][fromCell.x] = nil end
if oldTo == nil and not next(toTracks) then toRoom.wires[toCell.y][toCell.x] = nil end
self:resolveCircuits()
return {}, "A wire run can terminate at only one switch. Start another run from the door."
end
return fromRoom == toRoom and {fromRoom} or {fromRoom, toRoom}
end
function World:removeWireAt(cell, circuitId)
local room, localCell = self:roomCell(cell)
if not room then return {} end
local tracks = room:wireTracksAt(localCell)
local mask = tracks and tracks[circuitId]
if mask == nil then return {} end
local changed, seen = {room}, {[room] = true}
for _, direction in ipairs(Wire.directions) do
if Wire.has(mask, direction.bit) then
local neighborRoom, neighborCell = self:roomCell({x = cell.x + direction.x, y = cell.y + direction.y})
local neighborTracks = neighborRoom and neighborRoom:wireTracksAt(neighborCell)
if neighborTracks and neighborTracks[circuitId] ~= nil then
neighborTracks[circuitId] = Wire.remove(neighborTracks[circuitId], direction.opposite)
if not seen[neighborRoom] then seen[neighborRoom] = true; table.insert(changed, neighborRoom) end
end
end
end
room:removeWire(localCell, circuitId)
self:resolveCircuits()
return changed
end
function World:wiredDoorAt(cell)
local room, localCell = self:roomCell(cell)
if not room then return nil end
for _, door in ipairs(room.wiredDoors or {}) do
if door:occupiesCell(localCell) then return door, room end
end
end
function World:circuitAt(cell, preferredCircuit)
local room, localCell = self:roomCell(cell)
local tracks = room and room:wireTracksAt(localCell)
if not tracks then return nil end
if preferredCircuit and tracks[preferredCircuit] ~= nil then return preferredCircuit end
local ids = {}
for circuitId in pairs(tracks) do table.insert(ids, circuitId) end
table.sort(ids)
return ids[#ids]
end
function World:movementContext(room, color)
local proxies = {}
local world = self
local context = {}
function context:worldCell(cell) return world:worldCell(room, cell) end
function context:isInBounds(cell) return world:roomCell(cell) ~= nil end
function context:entityAt(cell)
local row = world.collidableMatrices[color][cell.y]
local entity = row and row[cell.x]
if not entity then return nil end
if proxies[entity] then return proxies[entity] end
local owner = world.entityRooms[entity]
local position = world:worldCell(owner, entity:getGridPos())
local proxy = {entity = entity, room = owner, color = color, x = position.x, y = position.y}
function proxy:canMove() return self.entity:canMove() end
function proxy:canPassOver() return self.entity:canPassOver() end
function proxy:isSticky() return self.entity:isSticky() end
function proxy:isSkewer() return self.entity:isSkewer() end
function proxy:isSpiky() return self.entity:isSpiky() end
function proxy:bump() return self.entity:bump() end
function proxy:getOccupiedCells(offset)
offset = offset or {x = 0, y = 0}
local cells = {}
for _, shapeCell in ipairs(self.entity:getCellShape()) do
table.insert(cells, {x = self.x + shapeCell.x - 1 + offset.x, y = self.y + shapeCell.y - 1 + offset.y})
end
return cells
end
proxies[entity] = proxy
return proxy
end
function context:playerBlockerAt(cell)
local row = world.playerBlockerMatrices[color][cell.y]
return row and row[cell.x]
end
return context
end
-- Player copies live in room files, but joining is a world-coordinate rule.
-- This also lets a copy rejoin immediately when the player crosses into its
-- room, rather than waiting for one more local move.
function World:playerJoinCheck(room, previousPositions, newPositions)
local activeColors = room:getActiveColors()
local deactivate, activate = {}, {}
if previousPositions and newPositions and #activeColors > 1 then
for _, color in ipairs(activeColors) do
if Entity.positionsAreEqual(previousPositions[color], newPositions[color]) then
table.insert(deactivate, color)
end
end
end
for color, player in pairs(room.player) do
if not room:colorIsActive(color) then
local playerCell = self:worldCell(room, player:getGridPos())
local overlaps = playerCell and #activeColors > 0
for _, activeColor in ipairs(activeColors) do
local activeCell = self:worldCell(room, room.player[activeColor]:getGridPos())
if not activeCell or activeCell.x ~= playerCell.x or activeCell.y ~= playerCell.y then overlaps = false; break end
end
if overlaps then table.insert(activate, color) end
end
end
for _, color in ipairs(activate) do room:setActiveColor(color, true) end
for _, color in ipairs(deactivate) do room:setActiveColor(color, false) end
end
function World:movePlayer(room, key)
local directions = {
up = {x = 0, y = -1, axis = "y"}, down = {x = 0, y = 1, axis = "y"},
left = {x = -1, y = 0, axis = "x"}, right = {x = 1, y = 0, axis = "x"},
}
local dir = directions[key]
if not dir then return end
local moved, previous, current = false, {}, {}
for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color]
local pos = player:getGridPos()
previous[color] = pos
local context = self:movementContext(room, color)
local target = context:worldCell({x = pos.x + dir.x, y = pos.y + dir.y})
local targetEntity = context:entityAt(target)
if targetEntity then targetEntity:bump() end
local blocker = context:playerBlockerAt(target)
local canMove, pushing = true, {}
if context:isInBounds(target) and not (blocker and blocker:blocksPlayer()) then
canMove, pushing = Movement.findMovableGroup({target}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
elseif blocker then
canMove = false
end
local pulling, pullingGroup = false, {}
local behind = context:worldCell({x = pos.x - dir.x, y = pos.y - dir.y})
if context:isInBounds(behind) then
pulling, pullingGroup = Movement.findMovableGroup({behind}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
end
if canMove then
local group = {}
for entity in pairs(pushing) do group[entity] = entity end
if pulling then for entity in pairs(pullingGroup) do group[entity] = entity end end
self:moveGroup(group, dir)
player:move(dir.axis, dir[dir.axis])
moved = true
end
current[color] = player:getGridPos()
end
if moved then
self:markDirty(room)
self:playerJoinCheck(room, previous, current)
self:tick(room)
self:flushRuntime()
end
end
function World:switchPlayerColor(room, color)
local player = room.player[color]
if not player then return false end
local activeColors = room:getActiveColors()
local canSwitch = #activeColors ~= 1 or activeColors[1] ~= color
if canSwitch then
for _, possibleColor in ipairs(room:getPossibleColors()) do
room:setActiveColor(possibleColor, possibleColor == color)
end
end
player:playSwitchSound(canSwitch)
if canSwitch then self:markDirty(room); self:flushRuntime() end
return canSwitch
end
function World:tick(room)
room:tickLocal()
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
self:nextRoomCheck(room)
end
function World:nextRoomCheck(room)
for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color]
if player and not room:isInBounds(player:getGridPos()) then
self:nextRoom(room, player:getGridPos())
return
end
end
end
function World:nextRoom(room, pos)
local exit, entry = nil, {x = pos.x, y = pos.y}
if pos.x > gridWidth then exit, entry.x = "right", 1
elseif pos.x < 1 then exit, entry.x = "left", gridWidth
elseif pos.y > gridHeight then exit, entry.y = "bottom", 1
elseif pos.y < 1 then exit, entry.y = "top", gridHeight end
local nextName = self:getNeighbor(room.name, exit) or room.name
local activeColors = room:getActiveColors()
self:markDirty(room)
local destination = self.levels[nextName]
for _, color in ipairs(activeColors) do
local player = room.player[color]
-- Active copies travel with the player. Inactive copies are deliberately
-- left in their room so World:playerJoinCheck can merge them later.
room.player[color] = nil
for i = #room.colorsPlayerHas, 1, -1 do
if room.colorsPlayerHas[i] == color then table.remove(room.colorsPlayerHas, i) end
end
if destination.player[color] then
for i = #destination.colorsPlayerHas, 1, -1 do
if destination.colorsPlayerHas[i] == color then table.remove(destination.colorsPlayerHas, i) end
end
end
destination.player[color] = player
table.insert(destination.colorsPlayerHas, color)
end
room.activeColors = {red = false, green = false, blue = false}
currentRoom = destination
self:markDirty(currentRoom)
for _, color in ipairs(activeColors) do
currentRoom.player[color].x, currentRoom.player[color].y = entry.x, entry.y
currentRoom:setActiveColor(color, true)
end
self:playerJoinCheck(currentRoom)
self.lastRoom = currentRoom.name
self:resolveCircuits()
self:rebuildCollision()
setEditorRoomName(currentRoom.name)
end
function World:moveGroup(group, direction)
local changed = {}
for proxy in pairs(group) do
changed[proxy.room] = true
for _, cell in ipairs(proxy:getOccupiedCells()) do
local room = self:roomCell(cell)
if room then changed[room] = true end
end
for _, cell in ipairs(proxy:getOccupiedCells(direction)) do
local room = self:roomCell(cell)
if room then changed[room] = true end
end
local destination, localCell = self:roomCell({x = proxy.x + direction.x, y = proxy.y + direction.y})
if destination then
if destination ~= proxy.room then
for i = #proxy.room.collideables[proxy.color], 1, -1 do
if proxy.room.collideables[proxy.color][i] == proxy.entity then table.remove(proxy.room.collideables[proxy.color], i); break end
end
table.insert(destination.collideables[proxy.color], proxy.entity)
self.entityRooms[proxy.entity] = destination
changed[proxy.room], changed[destination] = true, true
end
proxy.entity.x, proxy.entity.y = localCell.x, localCell.y
end
end
self:rebuildCollision()
for room in pairs(changed) do room:resolveSwitchesAndDoors() end
self:resolveCircuits()
self:rebuildCollision()
for room in pairs(changed) do self:markDirty(room) end
end
function World:drawOverlaps(room, color)
local location = self:roomLocation(room.name)
if not location then return end
local drawn = {}
for y = 1, gridHeight do
local row = self.collidableMatrices[color][(location.y - 1) * gridHeight + y]
if row then
for x = 1, gridWidth do
local entity = row[(location.x - 1) * gridWidth + x]
local owner = entity and self.entityRooms[entity]
if entity and owner ~= room and not drawn[entity] then
drawn[entity] = true
local ownerLocation = self:roomLocation(owner.name)
love.graphics.push()
love.graphics.translate((ownerLocation.x - location.x) * width, (ownerLocation.y - location.y) * height)
entity:draw(color)
love.graphics.pop()
end
end
end
end
end
function World:getCellFromMousePos(mouseX, mouseY) function World:getCellFromMousePos(mouseX, mouseY)
return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1 return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1
end end
@ -149,8 +823,8 @@ function World:invalidatePreview(name)
self.previewCache[name] = nil self.previewCache[name] = nil
end end
function World:drawRoomPreview(name, x, y, size) function World:drawRoomPreview(name, x, y, size, roomData)
local room = self:getRoomData(name) local room = roomData or self:getRoomData(name)
if not room then return end if not room then return end
local scale = size / (gridWidth * 16) local scale = size / (gridWidth * 16)
love.graphics.setScissor(x, y, size, size) love.graphics.setScissor(x, y, size, size)
@ -189,6 +863,77 @@ function World:drawRoomPreview(name, x, y, size)
love.graphics.setScissor() love.graphics.setScissor()
end end
function World:drawWireEditor(centerRoomX, centerRoomY, selectedCircuit)
local roomsAcross = 3
local roomSize = width / roomsAcross
local cellSize = roomSize / gridWidth
local firstRoomX, firstRoomY = centerRoomX - 1, centerRoomY - 1
love.graphics.setColor(0.035, 0.035, 0.055, 1)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.setBlendMode("alpha")
love.graphics.setColor(0.28, 0.28, 0.38, 0.28)
love.graphics.setLineWidth(1)
for cell = 0, roomsAcross * gridWidth do
local p = cell * cellSize
love.graphics.line(p, 0, p, height)
love.graphics.line(0, p, width, p)
end
love.graphics.setBlendMode("screen")
for viewY = 0, roomsAcross - 1 do
for viewX = 0, roomsAcross - 1 do
local roomX, roomY = firstRoomX + viewX, firstRoomY + viewY
local name = self:isInBounds(roomX, roomY) and self:roomAt(roomX, roomY)
local sx, sy = viewX * roomSize, viewY * roomSize
if name then
local level = self.levels[name]
self:drawRoomPreview(name, sx, sy, roomSize, level and level:serialize())
if level then
level:drawWiresAt(sx, sy, cellSize, selectedCircuit, true)
love.graphics.setBlendMode("alpha")
for _, door in ipairs(level.wiredDoors or {}) do
local pos, color = door:getGridPos(), Wire.color(door.circuitId)
love.graphics.setColor(color[1], color[2], color[3], selectedCircuit == door.circuitId and 1 or 0.65)
love.graphics.setLineWidth(2)
love.graphics.circle("line", sx + (pos.x - 0.5) * cellSize, sy + (pos.y - 0.5) * cellSize, cellSize * 0.22)
end
for _, switch in ipairs(level.wiredSwitches or {}) do
local pos = switch:getGridPos()
love.graphics.setColor(1, 1, 1, switch.activated and 1 or 0.65)
love.graphics.setLineWidth(1)
love.graphics.rectangle("line", sx + (pos.x - 0.68) * cellSize, sy + (pos.y - 0.68) * cellSize, cellSize * 0.36, cellSize * 0.36)
end
love.graphics.setBlendMode("screen")
end
end
love.graphics.setBlendMode("alpha")
love.graphics.setColor(name and 0.8 or 0.18, name and 0.8 or 0.18, name and 0.9 or 0.25, 1)
love.graphics.setLineWidth(viewX == 1 and viewY == 1 and 3 or 1)
love.graphics.rectangle("line", sx, sy, roomSize, roomSize)
if name then
love.graphics.setColor(1, 1, 1, 0.75)
love.graphics.print(name, sx + 4, sy + 3)
end
love.graphics.setBlendMode("screen")
end
end
self.wireEditorProjection = {
firstWorldX = (firstRoomX - 1) * gridWidth + 1,
firstWorldY = (firstRoomY - 1) * gridHeight + 1,
cellSize = cellSize,
}
end
function World:wireEditorCellAt(mouseX, mouseY, centerRoomX, centerRoomY)
local cellSize = width / 3 / gridWidth
local firstWorldX = (centerRoomX - 2) * gridWidth + 1
local firstWorldY = (centerRoomY - 2) * gridHeight + 1
return {
x = firstWorldX + math.floor(mouseX / cellSize),
y = firstWorldY + math.floor(mouseY / cellSize),
}
end
function World:drawHoveredRoomName() function World:drawHoveredRoomName()
local mouseX, mouseY = love.mouse.getPosition() local mouseX, mouseY = love.mouse.getPosition()
local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY) local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)