diff --git a/level_editor/editor.lua b/level_editor/editor.lua index ce1e8af..55987ef 100644 --- a/level_editor/editor.lua +++ b/level_editor/editor.lua @@ -44,6 +44,11 @@ local worldSearchText = "" local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 } local cloneSourceRoom = nil local roomBrush = nil +local wireBrush = nil +local selectedCircuitId = nil +local wireCameraX, wireCameraY = nil, nil +local wireDirtyRooms = {} +local wireOriginalWires = nil local boxPaletteOpen = false local boxPaletteScroll = { scroll = 0 } local boxPaletteSections = {} @@ -273,6 +278,8 @@ local function layoutRoomControls(c, s) end b.scale = math.min(target / spriteW, target / spriteH) b.w, b.h = spriteW * b.scale, spriteH * b.scale + else + b.w, b.h = 72, target end return b end @@ -296,8 +303,8 @@ local function layoutRoomControls(c, s) end placeRow({ "white", "red", "green", "blue" }, 22) - placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box" }, 22) - placeRow({ "save", "clearSaves", "world" }, 28) + placeRow({ "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_wired_switch", "quick_wired_door", "quick_box" }, 22) + placeRow({ "save", "clearSaves", "world", "wire" }, 28) end -- Build the full room-view layout (sections + a scroll list + a back/search @@ -492,10 +499,74 @@ local function saveRoomFromEditor() gameWorld:invalidatePreview(name) if editorView == "world" then rebuildWorldRoomButtons() end currentRoom.name = name + gameWorld:setLevel(currentRoom) editorRoomName = name roomNameField:editText(name) activeField = nil 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 local function clearRuntimeSaves() @@ -520,6 +591,7 @@ local function clearRuntimeSaves() clearSaveConfirmationScope = nil if roomName then currentRoom = Room:new{ name = roomName } + gameWorld:setLevel(currentRoom) setEditorRoomName(currentRoom.name) editorStatus = "Cleared " .. removed .. " runtime save item(s) and reloaded '" .. roomName .. ".'" else @@ -629,7 +701,7 @@ function _initLevelEditor() }, color) 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] if properties then local sprites = {} @@ -648,6 +720,16 @@ function _initLevelEditor() 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{ name = "roomName", text = editorRoomName, @@ -837,11 +919,31 @@ end function assetsView() editingAssetButtons = {} editorView = "room" + wireBrush = nil labels = {} 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() editorView = "world" + wireBrush = nil activeField = nil selectedFolder = nil selectedRoom = nil @@ -850,6 +952,118 @@ function worldView() rebuildWorldRoomButtons() 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 base = string.format("room_%02d_%02d", cellX, cellY) local name, suffix = base, 2 @@ -870,6 +1084,23 @@ local function cloneWorldRoom(sourceName, cloneName) editorStatus = "A level named '" .. cloneName .. "' already exists." return false 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 editorStatus = "Could not create clone '" .. cloneName .. "'." return false @@ -905,6 +1136,7 @@ local function openWorldRoom(cellX, cellY) currentRoom = Room:new{ name = name } editorStatus = "Editing '" .. name .. ".'" end + gameWorld:setLevel(currentRoom) gameWorld:setLastRoom(currentRoom.name) setEditorRoomName(currentRoom.name) assetsView() @@ -958,13 +1190,17 @@ function editorMouseHandler(x, y, button) end -- control cluster (only the buttons that live there) - for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do + for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "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] if b and b:clicIsInBox(x, y) then b:onClic(button) return 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 for _, uiElement in pairs(editingAssetButtons) do 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 if editorView == "room" then beginRoomBrush(x, y, button) + elseif editorView == "wire" then + beginWireBrush(x, y, button) elseif editorView == "world" then local cellX, cellY = gameWorld:getCellFromMousePos(x, y) if not gameWorld:isInBounds(cellX, cellY) then return end @@ -1024,6 +1262,15 @@ function editorMouseHandler(x, y, button) end 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 editorView ~= "room" then roomBrush = nil @@ -1045,6 +1292,7 @@ end function editorMouseReleased(_, _, button) if roomBrush and roomBrush.button == button then roomBrush = nil end + if wireBrush and wireBrush.button == button then wireBrush = nil end end function editorTextHandler(text) @@ -1094,6 +1342,7 @@ function editorCompleteResponse() end function editorCancelResponse() + if editorView == "wire" then discardWireRooms(); return true end if editorView ~= "world" or activeField ~= "cloneName" then return false end activeField = nil cloneSourceRoom = nil @@ -1153,11 +1402,15 @@ function editorDraw() -- control cluster love.graphics.setColor(gColor.white:set()) love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center") - for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "quick_player", "quick_static_wall", "quick_switch", "quick_door", "quick_box", "save", "clearSaves", "world" }) do + 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] - if button then - button:draw() - local asset = name:match("^quick_(.+)$") + if button then + button:draw() + 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 love.graphics.setColor(1, 0.85, 0.2, 1) 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") Layout.unclip() 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 for _, button in pairs(editingAssetButtons) do button:draw() @@ -1186,7 +1465,7 @@ function editorDraw() end -- The room view draws its status inside its own strip (see the room branch); -- 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.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center") end diff --git a/level_editor/object_attributes.lua b/level_editor/object_attributes.lua index 9cab6c9..e63cacc 100644 --- a/level_editor/object_attributes.lua +++ b/level_editor/object_attributes.lua @@ -59,5 +59,23 @@ function getAllAssets() 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) end diff --git a/main.lua b/main.lua index 2d0b76a..a13d9ed 100644 --- a/main.lua +++ b/main.lua @@ -35,6 +35,9 @@ function love.load() _initAccessibility() gameWorld = World:new() _initLevelEditor() + -- getAllAssets runs in _initLevelEditor; deserialize the world only after + -- its asset metadata exists, otherwise every non-player object is skipped. + gameWorld:loadLevels() _initCanvases() @@ -43,10 +46,16 @@ function love.load() -- 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 -- through their own (source-only) load path, so this only affects resume. - currentRoom = Room:new{ - name = gameWorld.lastRoom or "start", - runtime = true - } + local initialRoom = gameWorld.lastRoom or "start" + -- Preserve the old resume behavior for the active room while the rest of the + -- 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) setEditorRoomName(currentRoom.name) @@ -59,17 +68,21 @@ function love.quit() -- An explicit editor save/clear removes the runtime copy intentionally; -- don't immediately recreate it while shutting down. if currentRoom and not suppressRuntimeAutosave then - currentRoom:save() - if gameWorld then gameWorld:setLastRoom(currentRoom.name) end + if gameWorld then + gameWorld:markDirty(currentRoom) + gameWorld:flushRuntime() + else + currentRoom:save() + end end end function love.update(dt) - if editorView == "world" then + if editorView == "world" or editorView == "wire" then Input:stopMoveRepeat() else 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 currentRoom:update(dt) @@ -83,6 +96,8 @@ function love.draw() love.graphics.setBlendMode("screen") if editorView == "world" then gameWorld:draw() + elseif editorView == "wire" then + gameWorld:drawWireEditor(wireEditorCamera()) else currentRoom:draw() end @@ -162,12 +177,14 @@ function love.keypressed(key) return 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) - currentRoom:movePlayer(key) - elseif editorView ~= "world" and Input.isPlayerSwitch(key) then + gameWorld:movePlayer(currentRoom, key) + elseif editorView ~= "world" and editorView ~= "wire" and Input.isPlayerSwitch(key) then local c = Input.playerColor(key) - currentRoom:switchPlayerColor(c) + gameWorld:switchPlayerColor(currentRoom, c) end -- if key == "w" then diff --git a/room.lua b/room.lua index d354618..f38e8cc 100644 --- a/room.lua +++ b/room.lua @@ -8,6 +8,8 @@ require "art" require "assorted" require "switch" require "door" +require "wired_switch" +require "wired_door" require "dialogue_manager" require "generic_moveable" require "generic_sticky" @@ -17,11 +19,15 @@ require "generic_unmoveable" require "generic_player_blocker" local Movement = require "movement" +local Wire = require "wire" function Room:initialize(t) self.player = {} self.switches = { red = {}, green = {}, blue = {} } self.doors = {} + self.wiredSwitches = {} + self.wiredDoors = {} + self.wires = {} self.collideables = { red = {}, green = {}, blue = {} } self.playerBlockers = { red = {}, green = {}, blue = {} } self.npcs = {} @@ -125,6 +131,7 @@ function Room:update(dt) end function Room:draw() + self:drawWiresAt(0, 0, width / gridWidth, nil, false) -- 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 @@ -132,12 +139,120 @@ function Room:draw() for _, channel in ipairs(self.colorsPlayerHas) do if self.player[channel] then self.player[channel]: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 _, 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 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() for color, entities in pairs(self.collideables) do self.collidableMatrices[color] = self:createEntityMatrix(entities) @@ -181,7 +296,7 @@ function Room:createEntityMatrix(entities) for _, entity in pairs(entities) do for _, occupiedCells in pairs(entity:getOccupiedCells()) do --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 -- printMatrix(matrix) @@ -193,7 +308,9 @@ function Room:getCellInMatrix(matrix, cell) return matrix[cell.y][cell.x] 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 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 } -- 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 - if self:isInBounds(initialTargetCell) then + if useWorld then + initialTargetEntity = context:entityAt(worldTarget) + elseif self:isInBounds(initialTargetCell) then initialTargetEntity = self:getCellInMatrix(self.collidableMatrices[color], initialTargetCell) end if initialTargetEntity then initialTargetEntity:bump() end 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) 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_ move = true elseif playerBlocker and playerBlocker:blocksPlayer() then 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 move, entitiesToPush = Movement.findMovableGroup( {initialTargetCell}, dir, @@ -253,7 +383,11 @@ function Room:movePlayer(key) local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y} 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( {initialTargetCell}, dir, function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end, @@ -262,10 +396,15 @@ function Room:movePlayer(key) end if move then - for _, entity in pairs(entitiesToPush) do - entity:move(axis, dir[axis]) + if useWorld then + 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 - if pull == true then + if pull == true and not useWorld then for _, entity in pairs(entitiesToPull) do -- A connected component can wrap around the player and be found by -- both searches. Every entity still moves exactly one grid cell. @@ -281,12 +420,20 @@ function Room:movePlayer(key) end if moved then - self:playerJoinCheck(previousPositions, newPositions) + if useWorld then gameWorld:playerJoinCheck(self, previousPositions, newPositions) + else self:playerJoinCheck(previousPositions, newPositions) end self:tick() 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 -- nothing in that case: there is no corresponding player object to switch -- to (or use as the sound source). @@ -304,6 +451,13 @@ function Room:switchPlayerColor(color) return canSwitch 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) self.activeColors[color] = bool end @@ -361,7 +515,7 @@ function Room:onlyColorIsActive(color) end end -function Room:tick() +function Room:tickLocal() for color, entityArray in pairs(self.collideables) do for _, entity in pairs(entityArray) do entity:tick() @@ -371,6 +525,11 @@ function Room:tick() self:createPlayerBlockerMatrix() self:createSwitchMatrix() 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() end @@ -445,7 +604,14 @@ function Room:switchCheck() for color, switchArray in pairs(self.switches) do local onSwitches = {} 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] -- Players are not stored in collidableMatrices, but a colour copy -- 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"/ -- "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. -function Room:createEntity(class, color, x, y) +function Room:createEntity(class, color, x, y, data) + data = data or {} if class == "npc" then local entity = Sign:new(x, y) self:registerNpc(entity) @@ -502,7 +669,31 @@ function Room:createEntity(class, color, x, y) local behavior = props and props.class or "immoveable" 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 -- record into ordinary colour-layer door objects on load. if not color then @@ -570,6 +761,15 @@ function Room:registerSwitch(entityPointer, color) table.insert(self.switches[color], entityPointer) 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) table.insert(self.colorsPlayerHas, color) self.player[color] = entityPointer @@ -639,6 +839,15 @@ function Room:attemptDelete(mouseX, mouseY, color) for index = #self.doors, 1, -1 do if removedEntities[self.doors[index]] then table.remove(self.doors, index) end end + 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 -- registry rather than in self.collideables. Remove them directly so a -- right-click works no matter which editor colour is currently selected. @@ -694,7 +903,7 @@ end function Room:deserialize(saveTable) 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 -- copies that were off at save time stay off (older savs lack the field and -- keep the old all-on behaviour). @@ -702,6 +911,11 @@ function Room:deserialize(saveTable) self:setActiveColor(obj.color, obj.active) 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 function Room:save() @@ -722,12 +936,43 @@ function Room:saveToSource(name, savePlayerPositions) return writeToSource("rooms/" .. name .. ".sav", room) 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) local objects = {} local function emit(class, color, entity) 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 if includePlayers ~= false then @@ -760,15 +1005,19 @@ function Room:serialize(includePlayers) emit("switch", color, switch) end end + for _, switch in ipairs(self.wiredSwitches) do + emit("wired_switch", switch.color:getname(), switch) + end for _, npc in ipairs(self.npcs) do table.insert(objects, { class = npc.saveClass or "npc", x = npc.x, y = npc.y }) end - return { objects = objects } + return { objects = objects, wires = self:serializeWires() } end function nextRoom(pos) + if gameWorld and currentRoom then return gameWorld:nextRoom(currentRoom, pos) end local exit local newPos = {x = pos.x, y = pos.y} if pos.x > gridWidth then @@ -791,13 +1040,17 @@ function nextRoom(pos) -- Missing map neighbours intentionally wrap back into the room just exited. -- A placed neighbour gets the same carried player state at its opposite edge. - currentRoom = Room:new{ - name = nextName or currentName, - runtime = true, - player = { - x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors() - } - } + local playerState = {x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors()} + currentRoom = gameWorld and gameWorld.levels[nextName or currentName] or Room:new{name = nextName or currentName, runtime = true} + if currentRoom then + currentRoom.activeColors = {red = false, green = false, blue = false} + 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 currentRoom.name then setEditorRoomName(currentRoom.name) end diff --git a/rooms/room_08_13.sav b/rooms/room_08_13.sav new file mode 100644 index 0000000..4d12216 --- /dev/null +++ b/rooms/room_08_13.sav @@ -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" + } +} +} \ No newline at end of file diff --git a/rooms/room_09_13.sav b/rooms/room_09_13.sav new file mode 100644 index 0000000..d396bf8 --- /dev/null +++ b/rooms/room_09_13.sav @@ -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" + } +} +} \ No newline at end of file diff --git a/rooms/room_13_08.sav b/rooms/room_13_08.sav new file mode 100644 index 0000000..3e784f7 --- /dev/null +++ b/rooms/room_13_08.sav @@ -0,0 +1,4 @@ +{ +objects={ +} +} \ No newline at end of file diff --git a/rooms/room_14_07.sav b/rooms/room_14_07.sav new file mode 100644 index 0000000..3e784f7 --- /dev/null +++ b/rooms/room_14_07.sav @@ -0,0 +1,4 @@ +{ +objects={ +} +} \ No newline at end of file diff --git a/rooms/village_2.sav b/rooms/village_2.sav index 9b14861..763c8b7 100644 --- a/rooms/village_2.sav +++ b/rooms/village_2.sav @@ -1,255 +1,369 @@ { objects={ { - color="blue", class="door", + color="blue", x=6, 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", + color="red", x=6, 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", + color="green", x=6, y=1 }, { - color="blue", class="static_wall", - x=1, - y=1 - }, - { - color="red", - class="static_wall", - x=1, - y=1 - }, - { color="green", - class="static_wall", x=1, y=1 }, { - color="blue", class="static_wall", + color="green", x=2, y=1 }, { - color="red", class="static_wall", - x=2, - y=1 - }, - { color="green", - class="static_wall", - x=2, - y=1 - }, - { - color="blue", - class="static_wall", x=3, y=1 }, { - color="green", 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, - y=1 - }, - { - color="red", - class="static_wall", - x=3, - y=1 - }, - { - color="red", - class="static_wall", - x=4, - y=1 + y=6 }, { + class="spiky_box_2x1_11", 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, - y=11 - }, - { - color="blue", - class="static_wall", - x=1, - y=11 + y=6 }, { + class="box", color="green", - class="static_wall", - x=1, - y=11 - }, - { - color="red", - class="static_wall", - x=11, - y=11 - }, - { - color="blue", - class="static_wall", - x=11, - y=11 + x=5, + y=6 }, { + class="box_1x2_1-1", color="green", - class="static_wall", - x=11, - y=11 - }, - { - color="red", - class="switch", x=6, y=6 }, { - color="blue", - class="switch", - x=6, + class="spiky_box_1x2_1-1", + color="green", + x=7, y=6 }, { + class="box_2x2_11-10", color="green", - class="switch", - x=6, + x=8, + y=5 + }, + { + class="spiky_box_2x1_11", + color="green", + x=9, + y=6 + }, + { + class="spiky_box_2x1_11", + color="green", + x=11, y=6 } } diff --git a/rooms/village_3.sav b/rooms/village_3.sav index 766b174..0aaefe2 100644 --- a/rooms/village_3.sav +++ b/rooms/village_3.sav @@ -1,574 +1,549 @@ { objects={ { - y=2, + 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=6, + 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=11, + y=2 + }, + { + class="static_wall", + color="blue", + x=11, + y=3 + }, + { + class="static_wall", + color="blue", + x=11, + y=4 + }, + { + class="static_wall", + color="blue", + x=11, + y=5 + }, + { + class="static_wall", + color="blue", + x=11, + y=6 + }, + { + class="static_wall", + color="blue", + x=11, + y=7 + }, + { + class="static_wall", + color="blue", + x=11, + y=8 + }, + { + class="static_wall", + color="blue", + x=11, + y=9 + }, + { + class="static_wall", + color="blue", + x=11, + y=10 + }, + { + class="static_wall", + color="blue", + x=1, + y=11 + }, + { + class="static_wall", + color="blue", + x=2, + y=11 + }, + { + class="static_wall", + color="blue", + x=3, + y=11 + }, + { + class="static_wall", + color="blue", + x=5, + y=11 + }, + { + class="static_wall", + color="blue", + x=6, + y=11 + }, + { + class="static_wall", + color="blue", + x=7, + y=11 + }, + { + class="static_wall", + color="blue", + x=8, + y=11 + }, + { + class="static_wall", + color="blue", + x=9, + y=11 + }, + { + class="static_wall", + color="blue", + x=10, + y=11 + }, + { + class="static_wall", + color="blue", + x=11, + y=11 + }, + { + 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=6, + 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=11, + y=2 + }, + { + class="static_wall", + color="red", + x=11, + y=3 + }, + { + class="static_wall", + color="red", + x=11, + y=4 + }, + { + class="static_wall", + color="red", + x=11, + y=5 + }, + { + class="static_wall", + color="red", + x=11, + y=6 + }, + { + class="static_wall", + color="red", + x=11, + y=7 + }, + { + class="static_wall", + color="red", + x=11, + y=8 + }, + { + class="static_wall", + color="red", + x=11, + y=9 + }, + { + class="static_wall", + color="red", + x=11, + y=10 + }, + { + class="static_wall", + color="red", + x=1, + y=11 + }, + { + class="static_wall", + color="red", + x=2, + y=11 + }, + { + class="static_wall", + color="red", + x=3, + y=11 + }, + { + class="static_wall", + color="red", + x=5, + y=11 + }, + { + class="static_wall", + color="red", + x=6, + y=11 + }, + { + class="static_wall", + color="red", + x=7, + y=11 + }, + { + class="static_wall", + color="red", + x=8, + y=11 + }, + { + class="static_wall", + color="red", + x=9, + y=11 + }, + { + class="static_wall", + color="red", + x=10, + y=11 + }, + { + class="static_wall", + color="red", + x=11, + y=11 + }, + { + class="static_wall", + color="green", + x=1, + y=1 + }, + { + class="static_wall", + color="green", + x=2, + y=1 + }, + { + class="static_wall", + color="green", + x=3, + y=1 + }, + { + class="static_wall", + color="green", + x=4, + y=1 + }, + { + class="static_wall", + color="green", + x=5, + y=1 + }, + { + class="static_wall", + color="green", + x=6, + 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=11, + y=2 + }, + { + class="static_wall", + color="green", + x=11, + y=3 + }, + { + class="static_wall", + color="green", + x=11, + y=4 + }, + { + class="static_wall", + color="green", + x=11, + y=5 + }, + { + class="static_wall", + color="green", + x=11, + y=6 + }, + { + class="static_wall", + color="green", + x=11, + y=7 + }, + { + class="static_wall", + color="green", + x=11, + y=8 + }, + { + class="static_wall", + color="green", + x=11, + y=9 + }, + { + class="static_wall", + color="green", + x=11, + y=10 + }, + { + class="static_wall", + color="green", + x=1, + y=11 + }, + { + class="static_wall", + color="green", + x=2, + y=11 + }, + { + class="static_wall", + color="green", + x=3, + y=11 + }, + { + class="static_wall", + color="green", + x=5, + y=11 + }, + { + class="static_wall", + color="green", + x=6, + y=11 + }, + { + class="static_wall", + color="green", + x=7, + y=11 + }, + { + class="static_wall", + color="green", + x=8, + y=11 + }, + { + class="static_wall", + color="green", + x=9, + y=11 + }, + { + class="static_wall", + color="green", + x=10, + y=11 + }, + { + class="static_wall", + color="green", + x=11, + y=11 + }, + { class="npc", x=5, - name="slime_woman" - }, - { - y=1, - class="static_wall", - x=1, - color="green" - }, - { - y=1, - class="static_wall", - x=1, - color="blue" - }, - { - y=1, - class="static_wall", - x=1, - color="red" - }, - { - y=1, - class="static_wall", - x=2, - color="red" - }, - { - y=1, - class="static_wall", - x=2, - color="blue" - }, - { - y=1, - class="static_wall", - x=2, - color="green" - }, - { - y=1, - class="static_wall", - x=3, - color="red" - }, - { - y=1, - class="static_wall", - x=3, - color="blue" - }, - { - y=1, - class="static_wall", - x=3, - color="green" - }, - { - y=1, - class="static_wall", - x=4, - color="green" - }, - { - y=1, - class="static_wall", - x=4, - color="red" - }, - { - y=1, - class="static_wall", - x=4, - color="blue" - }, - { - y=1, - class="static_wall", - x=5, - color="red" - }, - { - y=1, - class="static_wall", - x=5, - color="blue" - }, - { - y=1, - class="static_wall", - x=5, - color="green" - }, - { - y=1, - class="static_wall", - x=6, - color="blue" - }, - { - y=1, - class="static_wall", - x=6, - color="red" - }, - { - y=1, - class="static_wall", - x=6, - color="green" - }, - { - y=1, - class="static_wall", - x=7, - color="blue" - }, - { - y=1, - class="static_wall", - x=7, - color="red" - }, - { - y=1, - class="static_wall", - x=7, - color="green" - }, - { - y=1, - class="static_wall", - x=8, - color="green" - }, - { - y=1, - class="static_wall", - x=8, - color="red" - }, - { - y=1, - class="static_wall", - x=8, - color="blue" - }, - { - y=1, - class="static_wall", - x=9, - color="blue" - }, - { - y=1, - class="static_wall", - x=9, - color="green" - }, - { - y=1, - class="static_wall", - x=9, - color="red" - }, - { - y=1, - class="static_wall", - x=10, - color="green" - }, - { - y=1, - class="static_wall", - x=10, - color="blue" - }, - { - y=1, - class="static_wall", - x=10, - color="red" - }, - { - y=1, - class="static_wall", - x=11, - color="green" - }, - { - y=1, - class="static_wall", - x=11, - color="blue" - }, - { - y=1, - class="static_wall", - x=11, - color="red" - }, - { - y=2, - class="static_wall", - x=11, - color="blue" - }, - { - y=2, - class="static_wall", - x=11, - color="red" - }, - { - y=2, - class="static_wall", - x=11, - color="green" - }, - { - y=3, - class="static_wall", - x=11, - color="green" - }, - { - y=3, - class="static_wall", - x=11, - color="blue" - }, - { - y=3, - class="static_wall", - x=11, - color="red" - }, - { - y=4, - class="static_wall", - x=11, - color="blue" - }, - { - y=4, - class="static_wall", - x=11, - color="red" - }, - { - y=4, - class="static_wall", - x=11, - color="green" - }, - { - y=5, - class="static_wall", - x=11, - color="green" - }, - { - y=5, - class="static_wall", - x=11, - color="red" - }, - { - y=5, - class="static_wall", - x=11, - color="blue" - }, - { - y=6, - class="static_wall", - x=11, - color="green" - }, - { - y=6, - class="static_wall", - x=11, - color="red" - }, - { - y=6, - class="static_wall", - x=11, - color="blue" - }, - { - y=7, - class="static_wall", - x=11, - color="blue" - }, - { - y=7, - class="static_wall", - x=11, - color="green" - }, - { - y=7, - class="static_wall", - x=11, - color="red" - }, - { - y=8, - class="static_wall", - x=11, - color="red" - }, - { - y=8, - class="static_wall", - x=11, - color="blue" - }, - { - y=8, - class="static_wall", - x=11, - color="green" - }, - { - y=9, - class="static_wall", - x=11, - color="blue" - }, - { - y=9, - class="static_wall", - x=11, - color="red" - }, - { - y=9, - class="static_wall", - x=11, - color="green" - }, - { - y=10, - class="static_wall", - x=11, - color="blue" - }, - { - y=10, - class="static_wall", - x=11, - color="red" - }, - { - y=10, - class="static_wall", - x=11, - color="green" - }, - { - y=11, - class="static_wall", - x=1, - color="blue" - }, - { - y=11, - class="static_wall", - x=1, - color="green" - }, - { - y=11, - class="static_wall", - x=1, - color="red" - }, - { - y=11, - class="static_wall", - x=2, - color="green" - }, - { - y=11, - class="static_wall", - x=2, - color="blue" - }, - { - y=11, - class="static_wall", - x=2, - color="red" - }, - { - y=11, - class="static_wall", - x=3, - color="red" - }, - { - y=11, - class="static_wall", - x=3, - color="green" - }, - { - y=11, - class="static_wall", - x=3, - color="blue" - }, - { - y=11, - class="static_wall", - x=4, - color="green" - }, - { - y=11, - class="static_wall", - x=4, - color="red" - }, - { - y=11, - class="static_wall", - x=4, - color="blue" - }, - { - y=11, - class="static_wall", - x=5, - color="blue" - }, - { - y=11, - class="static_wall", - x=5, - color="green" - }, - { - y=11, - class="static_wall", - x=5, - color="red" - }, - { - y=11, - class="static_wall", - x=6, - color="blue" - }, - { - y=11, - class="static_wall", - x=6, - color="green" - }, - { - y=11, - class="static_wall", - x=6, - color="red" - }, - { - y=11, - class="static_wall", - x=7, - color="red" - }, - { - y=11, - class="static_wall", - x=7, - color="green" - }, - { - y=11, - class="static_wall", - x=7, - color="blue" - }, - { - y=11, - class="static_wall", - x=8, - color="blue" - }, - { - y=11, - class="static_wall", - x=8, - color="red" - }, - { - y=11, - class="static_wall", - x=8, - color="green" - }, - { - y=11, - class="static_wall", - x=9, - color="red" - }, - { - y=11, - class="static_wall", - x=9, - color="green" - }, - { - y=11, - class="static_wall", - x=9, - color="blue" - }, - { - y=11, - class="static_wall", - x=10, - color="green" - }, - { - y=11, - class="static_wall", - x=10, - color="red" - }, - { - y=11, - class="static_wall", - x=10, - color="blue" - }, - { - y=11, - class="static_wall", - x=11, - color="red" - }, - { - y=11, - class="static_wall", - x=11, - color="blue" - }, - { - y=11, - class="static_wall", - x=11, - color="green" - }, - { - y=2, - class="box", - x=4, - color="blue" + y=2 } } } \ No newline at end of file diff --git a/rooms/world.sav b/rooms/world.sav index 149089c..a9bb9de 100644 --- a/rooms/world.sav +++ b/rooms/world.sav @@ -1,212 +1,232 @@ { -height=18, -lastRoom="white_primary", rooms={ { - name="3", x=10, + name="3", y=10 }, { + x=12, name="5", - x=12, y=10 }, { - name="village_1", x=12, + name="village_1", y=9 }, { - name="village_3", - x=13, - y=8 - }, - { + x=11, name="village_4", - x=11, y=8 }, { - name="yellow_1", x=11, + name="yellow_1", y=7 }, { - name="yellow_3", x=11, + name="yellow_3", y=6 }, { - name="yellow_2", x=12, + name="yellow_2", y=7 }, { - name="yellow_4", x=11, + name="yellow_4", y=5 }, { - name="yellow_5", x=11, + name="yellow_5", y=4 }, { - name="yellow_6", x=10, + name="yellow_6", y=5 }, { + x=1, name="default", - x=1, y=1 }, { + x=3, name="old_yellow_3", - x=3, y=1 }, { - name="test_patt", x=5, + name="test_patt", y=1 }, { + x=7, name="test_patt_update", - x=7, y=1 }, { + x=9, name="white_asymmetrical", - x=9, y=1 }, { - name="white_ok", x=11, + name="white_ok", y=1 }, { - name="white_primary", x=13, + name="white_primary", y=1 }, { - name="yellow_arches", x=15, + name="yellow_arches", y=1 }, { - name="yellow_asymmetrical", x=17, + name="yellow_asymmetrical", y=1 }, { - name="yellow_cc_moustache", x=1, + name="yellow_cc_moustache", y=3 }, { - name="yellow_color_changing_levvel_1", x=3, + name="yellow_color_changing_levvel_1", y=3 }, { - name="yellow_smile", x=7, + name="yellow_smile", y=3 }, { - name="2", x=9, + name="2", y=10 }, { - name="room_11_09", x=11, + name="room_11_09", y=9 }, { - name="yellow_skull", x=9, + name="yellow_skull", y=3 }, { - name="jorge_room", x=5, + name="jorge_room", y=5 }, { - name="4", x=11, + name="4", y=10 }, { - name="room_01_05", x=1, + name="room_01_05", y=5 }, { - name="ok", x=2, + name="ok", y=8 }, { - name="room_05_18", x=5, + name="room_05_18", y=18 }, { - name="village_start", x=9, + name="village_start", y=11 }, { - name="spiky_test", x=4, + name="spiky_test", y=8 }, { + x=5, name="corner_trimino_test", - x=5, y=7 }, { - name="tetromino_loop_t", x=6, + name="tetromino_loop_t", y=7 }, { - name="corner_trimino_test_copy", x=5, + name="corner_trimino_test_copy", y=6 }, { - name="blocker_test", x=7, + name="blocker_test", y=7 }, { - name="spiky_test_2", x=5, + name="spiky_test_2", y=8 }, { - name="spiky_test_3", x=5, + name="spiky_test_3", y=9 }, { - name="jorge2", x=3, + name="jorge2", y=14 }, { - name="room_08_07", x=8, + name="room_08_07", 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 } \ No newline at end of file diff --git a/tests/test_wire.lua b/tests/test_wire.lua new file mode 100644 index 0000000..c9bd12a --- /dev/null +++ b/tests/test_wire.lua @@ -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") diff --git a/wire.lua b/wire.lua new file mode 100644 index 0000000..0ee273c --- /dev/null +++ b/wire.lua @@ -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 diff --git a/wired_door.lua b/wired_door.lua new file mode 100644 index 0000000..8d30528 --- /dev/null +++ b/wired_door.lua @@ -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 diff --git a/wired_switch.lua b/wired_switch.lua new file mode 100644 index 0000000..cc8030d --- /dev/null +++ b/wired_switch.lua @@ -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 diff --git a/world.lua b/world.lua index 7a4abe7..6e92bd6 100644 --- a/world.lua +++ b/world.lua @@ -1,8 +1,11 @@ World = class("World") --- The world is deliberately only a layout: rooms continue to own their puzzle --- state. Keeping the two separate lets the editor move a room without --- rewriting its level file. +local Movement = require "movement" +local Wire = require "wire" + +-- 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() self.width, self.height = 18, 18 self.rooms = {} @@ -10,6 +13,72 @@ function World:initialize() self.previewCache = {} self.lastRoom = nil 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 function World:listRoomNames() @@ -80,7 +149,13 @@ end function World:placeRoom(name, x, y) if not name or not self:isInBounds(x, y) then return false end 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 function World:removeRoom(x, y) @@ -90,7 +165,13 @@ function World:removeRoom(x, y) self.roomByName[room.name] = nil table.remove(self.rooms, i) 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 return false @@ -111,7 +192,6 @@ end function World:setLastRoom(name) if name and self.lastRoom ~= name then self.lastRoom = name - return self:save() end return true end @@ -128,6 +208,600 @@ function World:getNeighbor(name, exit) return self:roomAt(room.x + direction.x, room.y + direction.y) 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) return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1 end @@ -149,8 +823,8 @@ function World:invalidatePreview(name) self.previewCache[name] = nil end -function World:drawRoomPreview(name, x, y, size) - local room = self:getRoomData(name) +function World:drawRoomPreview(name, x, y, size, roomData) + local room = roomData or self:getRoomData(name) if not room then return end local scale = size / (gridWidth * 16) love.graphics.setScissor(x, y, size, size) @@ -189,6 +863,77 @@ function World:drawRoomPreview(name, x, y, size) love.graphics.setScissor() 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() local mouseX, mouseY = love.mouse.getPosition() local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)