2026-08-07 01:39:47 -04:00
|
|
|
World = class("World")
|
|
|
|
|
|
2026-08-11 21:26:19 -04:00
|
|
|
local Movement = require "movement"
|
2026-08-11 22:55:58 -04:00
|
|
|
local Wire = require "wire"
|
2026-08-11 21:26:19 -04:00
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
-- 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.
|
2026-08-07 01:39:47 -04:00
|
|
|
function World:initialize()
|
|
|
|
|
self.width, self.height = 18, 18
|
|
|
|
|
self.rooms = {}
|
|
|
|
|
self.roomByName = {}
|
|
|
|
|
self.previewCache = {}
|
2026-08-07 03:22:28 -04:00
|
|
|
self.lastRoom = nil
|
2026-08-07 01:39:47 -04:00
|
|
|
self:load()
|
2026-08-11 21:49:32 -04:00
|
|
|
self:loadRuntimeState()
|
2026-08-11 21:26:19 -04:00
|
|
|
self.levels = {}
|
2026-08-11 22:55:58 -04:00
|
|
|
self.circuitSerial = 0
|
2026-08-11 21:26:19 -04:00
|
|
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:loadLevels()
|
2026-08-11 21:49:32 -04:00
|
|
|
-- 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
|
2026-08-11 21:26:19 -04:00
|
|
|
self:rebuildCollision()
|
2026-08-11 22:55:58 -04:00
|
|
|
self:resolveCircuits()
|
|
|
|
|
self:rebuildCollision()
|
2026-08-07 01:39:47 -04:00
|
|
|
end
|
|
|
|
|
|
2026-08-11 21:49:32 -04:00
|
|
|
-- 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()
|
2026-08-11 22:55:58 -04:00
|
|
|
self:resolveCircuits()
|
|
|
|
|
self:rebuildCollision()
|
2026-08-11 21:49:32 -04:00
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-11 21:49:32 -04:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-07 01:39:47 -04:00
|
|
|
function World:listRoomNames()
|
|
|
|
|
local names = {}
|
2026-08-12 17:20:11 -04:00
|
|
|
for _, file in ipairs(getSourceDirectoryItems("rooms")) do
|
2026-08-07 01:39:47 -04:00
|
|
|
local name = file:match("^(.+)%.sav$")
|
|
|
|
|
if name and name ~= "world" then table.insert(names, name) end
|
|
|
|
|
end
|
|
|
|
|
table.sort(names)
|
|
|
|
|
return names
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:load()
|
|
|
|
|
local raw = readFromSource("rooms/world.sav")
|
|
|
|
|
if not raw then return end
|
|
|
|
|
local ok, save = pcall(TSerial.unpack, raw)
|
|
|
|
|
if not ok or type(save) ~= "table" then return end
|
2026-08-07 03:22:28 -04:00
|
|
|
self.lastRoom = save.lastRoom
|
2026-08-07 01:39:47 -04:00
|
|
|
for _, room in ipairs(save.rooms or {}) do
|
|
|
|
|
if room.name and self:isInBounds(room.x, room.y) then
|
|
|
|
|
self:_putRoom(room.name, room.x, room.y)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:save()
|
|
|
|
|
local rooms = {}
|
|
|
|
|
for _, room in ipairs(self.rooms) do
|
|
|
|
|
table.insert(rooms, { name = room.name, x = room.x, y = room.y })
|
|
|
|
|
end
|
2026-08-07 03:22:28 -04:00
|
|
|
return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, lastRoom = self.lastRoom, rooms = rooms }, nil, true))
|
2026-08-07 01:39:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:isInBounds(x, y)
|
|
|
|
|
return x and y and x >= 1 and x <= self.width and y >= 1 and y <= self.height
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:roomAt(x, y)
|
|
|
|
|
for _, room in ipairs(self.rooms) do
|
|
|
|
|
if room.x == x and room.y == y then return room.name end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:roomLocation(name)
|
|
|
|
|
return self.roomByName[name]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:_putRoom(name, x, y)
|
|
|
|
|
local existing = self.roomByName[name]
|
|
|
|
|
if existing then
|
|
|
|
|
for i = #self.rooms, 1, -1 do
|
|
|
|
|
if self.rooms[i] == existing then table.remove(self.rooms, i) end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
for i = #self.rooms, 1, -1 do
|
|
|
|
|
if self.rooms[i].x == x and self.rooms[i].y == y then
|
|
|
|
|
self.roomByName[self.rooms[i].name] = nil
|
|
|
|
|
table.remove(self.rooms, i)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
local room = { name = name, x = x, y = y }
|
|
|
|
|
table.insert(self.rooms, room)
|
|
|
|
|
self.roomByName[name] = room
|
|
|
|
|
return room
|
|
|
|
|
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)
|
2026-08-11 22:55:58 -04:00
|
|
|
local saved = self:save()
|
|
|
|
|
if self.levels then
|
|
|
|
|
self:rebuildCollision()
|
|
|
|
|
self:resolveCircuits()
|
|
|
|
|
self:rebuildCollision()
|
|
|
|
|
end
|
|
|
|
|
return saved
|
2026-08-07 01:39:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:removeRoom(x, y)
|
|
|
|
|
for i = #self.rooms, 1, -1 do
|
|
|
|
|
local room = self.rooms[i]
|
|
|
|
|
if room.x == x and room.y == y then
|
|
|
|
|
self.roomByName[room.name] = nil
|
|
|
|
|
table.remove(self.rooms, i)
|
2026-08-07 03:22:28 -04:00
|
|
|
if self.lastRoom == room.name then self.lastRoom = nil end
|
2026-08-11 22:55:58 -04:00
|
|
|
local saved = self:save()
|
|
|
|
|
if self.levels then
|
|
|
|
|
self:rebuildCollision()
|
|
|
|
|
self:resolveCircuits()
|
|
|
|
|
self:rebuildCollision()
|
|
|
|
|
end
|
|
|
|
|
return saved, room.name
|
2026-08-07 01:39:47 -04:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
return false
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:renameRoom(oldName, newName)
|
|
|
|
|
local room = self:roomLocation(oldName)
|
|
|
|
|
if not room or oldName == newName then return false end
|
|
|
|
|
self.roomByName[oldName] = nil
|
|
|
|
|
room.name = newName
|
|
|
|
|
self.roomByName[newName] = room
|
2026-08-07 03:22:28 -04:00
|
|
|
if self.lastRoom == oldName then self.lastRoom = newName end
|
2026-08-07 01:39:47 -04:00
|
|
|
self:invalidatePreview(oldName)
|
|
|
|
|
self:invalidatePreview(newName)
|
|
|
|
|
return self:save()
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-07 03:22:28 -04:00
|
|
|
function World:setLastRoom(name)
|
|
|
|
|
if name and self.lastRoom ~= name then
|
|
|
|
|
self.lastRoom = name
|
|
|
|
|
end
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-07 01:39:47 -04:00
|
|
|
function World:getNeighbor(name, exit)
|
|
|
|
|
local room = self:roomLocation(name)
|
|
|
|
|
if not room then return nil end
|
|
|
|
|
local directions = {
|
|
|
|
|
left = { x = -1, y = 0 }, right = { x = 1, y = 0 },
|
|
|
|
|
top = { x = 0, y = -1 }, bottom = { x = 0, y = 1 },
|
|
|
|
|
}
|
|
|
|
|
local direction = directions[exit]
|
|
|
|
|
if not direction then return nil end
|
|
|
|
|
return self:roomAt(room.x + direction.x, room.y + direction.y)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 21:26:19 -04:00
|
|
|
-- 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 = {}}
|
2026-08-21 15:56:53 -04:00
|
|
|
-- Per-colour set of world cells occupied by a player copy. NPC pathfinding
|
|
|
|
|
-- treats these as solid (a walker can't push the player) — see pathfinding.lua.
|
|
|
|
|
self.playerCells = {red = {}, green = {}, blue = {}}
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
2026-08-21 15:56:53 -04:00
|
|
|
for color, player in pairs(room.player) do
|
|
|
|
|
for _, cell in ipairs(player:getOccupiedCells()) do
|
|
|
|
|
local x = (location.x - 1) * gridWidth + cell.x
|
|
|
|
|
local y = (location.y - 1) * gridHeight + cell.y
|
|
|
|
|
self.playerCells[color][x .. ":" .. y] = true
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
2026-08-21 15:56:53 -04:00
|
|
|
-- NPC copies are per-colour room residents (see npc.lua). Each colour
|
|
|
|
|
-- stamps only its own copy's cells — "red can only be hit by red" — at the
|
|
|
|
|
-- copy's position in the room it currently stands in. The controller
|
|
|
|
|
-- entity (copy.npc) is stored so a bump opens its dialogue.
|
|
|
|
|
for color, copies in pairs(room.npcCopies) do
|
|
|
|
|
for _, copy in ipairs(copies) do
|
|
|
|
|
self.entityRooms[copy.npc] = room
|
|
|
|
|
for _, shapeCell in ipairs(copy.npc:getCellShape()) do
|
|
|
|
|
put(self.collidableMatrices[color], {
|
|
|
|
|
x = (location.x - 1) * gridWidth + copy.x + shapeCell.x - 1,
|
|
|
|
|
y = (location.y - 1) * gridHeight + copy.y + shapeCell.y - 1,
|
|
|
|
|
}, copy.npc)
|
|
|
|
|
end
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-21 15:56:53 -04:00
|
|
|
-- Marks are globally unique named destinations. This resolves one for an NPC's
|
|
|
|
|
-- Walker:sendTo, scanning every loaded room's mark registry.
|
|
|
|
|
function World:markByName(name)
|
|
|
|
|
for _, room in pairs(self.levels or {}) do
|
|
|
|
|
for _, mark in ipairs(room.marks or {}) do
|
|
|
|
|
if mark.markName == name then return mark, room end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-12 00:21:53 -04:00
|
|
|
-- Check every rendered/colliding colour layer. A red or green player/box can
|
|
|
|
|
-- visibly occupy a blue door cell, so checking only the door's own layer lets
|
|
|
|
|
-- wired doors close through an apparent occupant. Other door layers at the
|
|
|
|
|
-- same location are intentionally ignored: they are one layered doorway.
|
|
|
|
|
function World:doorCanClose(room, door)
|
|
|
|
|
local cells = door.getOccupiedCells and door:getOccupiedCells() or {door:getGridPos()}
|
|
|
|
|
for _, cell in ipairs(cells) do
|
|
|
|
|
for _, player in pairs(room.player or {}) do
|
|
|
|
|
if player:occupiesCell(cell) then return false end
|
|
|
|
|
end
|
|
|
|
|
for _, entities in pairs(room.collideables or {}) do
|
|
|
|
|
for _, entity in ipairs(entities) do
|
|
|
|
|
if entity ~= door and not entity.isDoor and entity:occupiesCell(cell) and
|
|
|
|
|
not (entity.canPassOver and entity:canPassOver()) then
|
|
|
|
|
return false
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
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]
|
2026-08-12 00:12:26 -04:00
|
|
|
-- A circuit can be started with a click before the author drags its
|
|
|
|
|
-- first segment. Those zero-length fragments are not a completed
|
|
|
|
|
-- electrical run, so they must not permanently lock a door that also
|
|
|
|
|
-- has real door-to-switch routes. Only valid components participate
|
|
|
|
|
-- in the door's AND condition.
|
|
|
|
|
local runCount, powered = 0, true
|
2026-08-11 22:55:58 -04:00
|
|
|
for circuitId in pairs(tracks or {}) do
|
|
|
|
|
local component = components[circuitId] and components[circuitId][key]
|
2026-08-12 00:12:26 -04:00
|
|
|
if component and component.valid then
|
|
|
|
|
runCount = runCount + 1
|
2026-08-11 22:55:58 -04:00
|
|
|
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.
|
2026-08-12 00:21:53 -04:00
|
|
|
door:setPowered(powered, self:doorCanClose(device.room, door))
|
2026-08-11 22:55:58 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-12 00:30:32 -04:00
|
|
|
-- A valid wire is bright only while every switch at its sole terminal cell is
|
|
|
|
|
-- pressed. Rendering reads this derived state rather than entity sprite flags
|
|
|
|
|
-- so every segment of a cross-room component agrees in the same frame.
|
|
|
|
|
function World:wirePoweredAt(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 false end
|
|
|
|
|
for switch in pairs(component.switches) do
|
|
|
|
|
if not (self.circuitState and self.circuitState.switchPressed[switch]) then return false end
|
|
|
|
|
end
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-11 21:26:19 -04:00
|
|
|
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)
|
2026-08-12 02:55:29 -04:00
|
|
|
self.roomBrowseMode = false
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
2026-08-12 12:27:52 -04:00
|
|
|
if Sound then Sound.play("playerMove") end
|
2026-08-11 21:49:32 -04:00
|
|
|
self:markDirty(room)
|
2026-08-11 21:26:19 -04:00
|
|
|
self:playerJoinCheck(room, previous, current)
|
|
|
|
|
self:tick(room)
|
2026-08-11 21:49:32 -04:00
|
|
|
self:flushRuntime()
|
2026-08-11 21:26:19 -04:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:switchPlayerColor(room, color)
|
2026-08-12 02:55:29 -04:00
|
|
|
self.roomBrowseMode = false
|
2026-08-11 21:26:19 -04:00
|
|
|
local player = room.player[color]
|
2026-08-12 00:12:26 -04:00
|
|
|
if not player then
|
|
|
|
|
-- A colour copy can be parked in another world room. Number keys are a
|
|
|
|
|
-- direct way to resume controlling it: keep every copy where it is, but
|
|
|
|
|
-- make its owner the active room and select that colour there.
|
|
|
|
|
local destination
|
|
|
|
|
for name, candidate in pairs(self.levels or {}) do
|
|
|
|
|
if self:roomLocation(name) and candidate.player[color] then
|
|
|
|
|
destination = candidate
|
|
|
|
|
break
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
if not destination then return false end
|
|
|
|
|
|
|
|
|
|
for _, possibleColor in ipairs(room:getPossibleColors()) do
|
|
|
|
|
room:setActiveColor(possibleColor, false)
|
|
|
|
|
end
|
|
|
|
|
for _, possibleColor in ipairs(destination:getPossibleColors()) do
|
|
|
|
|
destination:setActiveColor(possibleColor, possibleColor == color)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
currentRoom = destination
|
|
|
|
|
self.lastRoom = destination.name
|
|
|
|
|
destination.player[color]:playSwitchSound(true)
|
|
|
|
|
self:markDirty(room)
|
|
|
|
|
self:markDirty(destination)
|
|
|
|
|
self:flushRuntime()
|
|
|
|
|
setEditorRoomName(destination.name)
|
|
|
|
|
return true
|
|
|
|
|
end
|
2026-08-11 21:26:19 -04:00
|
|
|
local activeColors = room:getActiveColors()
|
2026-08-12 00:30:13 -04:00
|
|
|
local canJoin = not room:colorIsActive(color) and #activeColors > 0
|
|
|
|
|
if canJoin then
|
|
|
|
|
local target = player:getGridPos()
|
|
|
|
|
for _, activeColor in ipairs(activeColors) do
|
|
|
|
|
local activePlayer = room.player[activeColor]
|
|
|
|
|
local activePos = activePlayer and activePlayer:getGridPos()
|
|
|
|
|
if not activePos or activePos.x ~= target.x or activePos.y ~= target.y then
|
|
|
|
|
canJoin = false
|
|
|
|
|
break
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local canSwitch = canJoin or #activeColors ~= 1 or activeColors[1] ~= color
|
|
|
|
|
if canJoin then
|
|
|
|
|
room:setActiveColor(color, true)
|
|
|
|
|
elseif canSwitch then
|
2026-08-11 21:26:19 -04:00
|
|
|
for _, possibleColor in ipairs(room:getPossibleColors()) do
|
|
|
|
|
room:setActiveColor(possibleColor, possibleColor == color)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
player:playSwitchSound(canSwitch)
|
2026-08-11 21:49:32 -04:00
|
|
|
if canSwitch then self:markDirty(room); self:flushRuntime() end
|
2026-08-11 21:26:19 -04:00
|
|
|
return canSwitch
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-12 02:55:29 -04:00
|
|
|
-- Developer room browser. It changes only the displayed room; players,
|
|
|
|
|
-- collision, and runtime save state remain untouched.
|
|
|
|
|
function World:browseRoom(room, key)
|
|
|
|
|
local exits = {a = "left", d = "right", w = "top", s = "bottom"}
|
|
|
|
|
local destinationName = exits[key] and self:getNeighbor(room.name, exits[key])
|
|
|
|
|
local destination = destinationName and self.levels[destinationName]
|
|
|
|
|
if not destination then return false end
|
|
|
|
|
currentRoom = destination
|
|
|
|
|
self.roomBrowseMode = true
|
|
|
|
|
if setEditorRoomName then setEditorRoomName(destination.name) end
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 21:26:19 -04:00
|
|
|
function World:tick(room)
|
|
|
|
|
room:tickLocal()
|
|
|
|
|
self:rebuildCollision()
|
2026-08-11 22:55:58 -04:00
|
|
|
self:resolveCircuits()
|
|
|
|
|
self:rebuildCollision()
|
2026-08-11 21:26:19 -04:00
|
|
|
self:nextRoomCheck(room)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-21 15:56:53 -04:00
|
|
|
-- Real-time NPC movement across every placed room, independent of player turns.
|
|
|
|
|
-- Snapshot the NPC list first: Walker:arrive can hand an NPC to another room's
|
|
|
|
|
-- registry, which would otherwise mutate a list we are iterating.
|
|
|
|
|
function World:update(dt)
|
|
|
|
|
local walking = {}
|
|
|
|
|
for name, room in pairs(self.levels or {}) do
|
|
|
|
|
if self:roomLocation(name) then
|
|
|
|
|
for _, npc in ipairs(room.npcs) do
|
|
|
|
|
if npc.step then table.insert(walking, npc) end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
local moved = false
|
|
|
|
|
for _, npc in ipairs(walking) do
|
|
|
|
|
if npc:step(dt) then moved = true end
|
|
|
|
|
end
|
|
|
|
|
if moved then self:rebuildCollision() end
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
2026-08-12 12:27:52 -04:00
|
|
|
if Sound and nextName ~= room.name then Sound.play("levelChange") end
|
2026-08-12 02:50:48 -04:00
|
|
|
local sourceLocation = self:roomLocation(room.name)
|
|
|
|
|
local destinationLocation = self:roomLocation(nextName)
|
2026-08-11 21:26:19 -04:00
|
|
|
local activeColors = room:getActiveColors()
|
2026-08-11 21:49:32 -04:00
|
|
|
self:markDirty(room)
|
|
|
|
|
local destination = self.levels[nextName]
|
2026-08-11 21:26:19 -04:00
|
|
|
for _, color in ipairs(activeColors) do
|
2026-08-11 21:49:32 -04:00
|
|
|
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
|
2026-08-11 21:26:19 -04:00
|
|
|
end
|
2026-08-11 21:49:32 -04:00
|
|
|
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
|
2026-08-12 02:50:48 -04:00
|
|
|
if sourceLocation and destinationLocation and nextName ~= room.name then
|
|
|
|
|
self.roomTransition = {
|
|
|
|
|
startedAt = love.timer.getTime(), duration = 0.28,
|
|
|
|
|
dx = destinationLocation.x - sourceLocation.x,
|
|
|
|
|
dy = destinationLocation.y - sourceLocation.y,
|
|
|
|
|
}
|
|
|
|
|
end
|
2026-08-11 21:49:32 -04:00
|
|
|
self:markDirty(currentRoom)
|
|
|
|
|
for _, color in ipairs(activeColors) do
|
|
|
|
|
currentRoom.player[color].x, currentRoom.player[color].y = entry.x, entry.y
|
2026-08-11 21:26:19 -04:00
|
|
|
currentRoom:setActiveColor(color, true)
|
|
|
|
|
end
|
|
|
|
|
self:playerJoinCheck(currentRoom)
|
2026-08-11 21:49:32 -04:00
|
|
|
self.lastRoom = currentRoom.name
|
2026-08-11 22:55:58 -04:00
|
|
|
self:resolveCircuits()
|
|
|
|
|
self:rebuildCollision()
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
2026-08-11 22:55:58 -04:00
|
|
|
self:resolveCircuits()
|
2026-08-11 21:26:19 -04:00
|
|
|
self:rebuildCollision()
|
2026-08-11 21:49:32 -04:00
|
|
|
for room in pairs(changed) do self:markDirty(room) end
|
2026-08-11 21:26:19 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-12 02:58:01 -04:00
|
|
|
-- The neighbourhood renderer and editor pointer mapping share this exact
|
|
|
|
|
-- transform. Keeping it in one place prevents a visual zoom adjustment from
|
|
|
|
|
-- silently shifting placement/deletion by a tile.
|
|
|
|
|
function World:roomNeighborhoodTransform(room)
|
2026-08-12 02:50:48 -04:00
|
|
|
local location = self:roomLocation(room.name)
|
2026-08-12 02:58:01 -04:00
|
|
|
if not location then return nil end
|
2026-08-12 02:50:48 -04:00
|
|
|
|
|
|
|
|
-- Leave enough margin to read several cells of each neighbour, not merely
|
|
|
|
|
-- its boundary wall. This keeps a nearby separated player discoverable.
|
|
|
|
|
local zoom = 0.78
|
|
|
|
|
local originX = (width - width * zoom) / 2
|
|
|
|
|
local originY = (height - height * zoom) / 2
|
|
|
|
|
local cameraX, cameraY = 0, 0
|
|
|
|
|
local transition = self.roomTransition
|
|
|
|
|
if transition then
|
|
|
|
|
local progress = math.min(1, (love.timer.getTime() - transition.startedAt) / transition.duration)
|
|
|
|
|
-- Smoothstep keeps the scene anchored at both ends instead of snapping
|
|
|
|
|
-- into or out of its small neighbourhood preview.
|
|
|
|
|
local eased = progress * progress * (3 - 2 * progress)
|
|
|
|
|
cameraX = transition.dx * (1 - eased)
|
|
|
|
|
cameraY = transition.dy * (1 - eased)
|
|
|
|
|
if progress >= 1 then self.roomTransition = nil end
|
|
|
|
|
end
|
2026-08-12 02:58:01 -04:00
|
|
|
return {location = location, zoom = zoom, originX = originX, originY = originY,
|
|
|
|
|
cameraX = cameraX, cameraY = cameraY}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Convert a gameplay-screen coordinate to a cell in the centered room. The
|
|
|
|
|
-- surrounding rooms are preview-only while editing; browse to one with WASD
|
|
|
|
|
-- before changing it. Returns nil for the preview margin or any other room.
|
|
|
|
|
function World:screenCellInRoom(room, mouseX, mouseY)
|
|
|
|
|
local transform = self:roomNeighborhoodTransform(room)
|
|
|
|
|
if not transform then return nil end
|
|
|
|
|
local roomOffsetX = (mouseX - transform.originX) / (width * transform.zoom) - transform.cameraX
|
|
|
|
|
local roomOffsetY = (mouseY - transform.originY) / (height * transform.zoom) - transform.cameraY
|
|
|
|
|
local offsetX, offsetY = math.floor(roomOffsetX), math.floor(roomOffsetY)
|
|
|
|
|
if offsetX ~= 0 or offsetY ~= 0 then return nil end
|
|
|
|
|
local localX = (roomOffsetX - offsetX) * width
|
|
|
|
|
local localY = (roomOffsetY - offsetY) * height
|
|
|
|
|
local cell = {
|
|
|
|
|
x = math.floor(localX * gridWidth / width) + 1,
|
|
|
|
|
y = math.floor(localY * gridHeight / height) + 1,
|
|
|
|
|
}
|
|
|
|
|
if cell.x < 1 or cell.x > gridWidth or cell.y < 1 or cell.y > gridHeight then return nil end
|
|
|
|
|
return cell
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Render the active room at a small zoom-out, together with the eight world
|
|
|
|
|
-- neighbours around it. This is display-only: individual rooms still own
|
|
|
|
|
-- their data, collision, and authored boundary walls.
|
|
|
|
|
function World:drawRoomNeighborhood(room)
|
|
|
|
|
if self.drawingRoomNeighborhood then return end
|
|
|
|
|
local transform = self:roomNeighborhoodTransform(room)
|
|
|
|
|
if not transform then return end
|
2026-08-12 02:50:48 -04:00
|
|
|
self.drawingRoomNeighborhood = true
|
2026-08-12 03:02:07 -04:00
|
|
|
local currentChannels = room:getRenderChannels()
|
|
|
|
|
local roomWidth, roomHeight = width * transform.zoom, height * transform.zoom
|
|
|
|
|
|
|
|
|
|
local function drawAt(offsetX, offsetY, level, channels)
|
|
|
|
|
love.graphics.push()
|
|
|
|
|
love.graphics.translate(transform.originX + (offsetX + transform.cameraX) * roomWidth,
|
|
|
|
|
transform.originY + (offsetY + transform.cameraY) * roomHeight)
|
|
|
|
|
love.graphics.scale(transform.zoom, transform.zoom)
|
|
|
|
|
level:draw(channels)
|
|
|
|
|
love.graphics.pop()
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-12 02:50:48 -04:00
|
|
|
for roomY = -1, 1 do
|
|
|
|
|
for roomX = -1, 1 do
|
2026-08-12 02:58:01 -04:00
|
|
|
local name = self:roomAt(transform.location.x + roomX, transform.location.y + roomY)
|
2026-08-12 02:50:48 -04:00
|
|
|
local adjacent = name and self.levels[name]
|
|
|
|
|
if adjacent then
|
2026-08-12 03:02:07 -04:00
|
|
|
drawAt(roomX, roomY, adjacent)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Each cardinal neighbour receives a second, real render through the active
|
|
|
|
|
-- colours in the current room. The shader only chooses a wispy portion of
|
|
|
|
|
-- the shared visible margin, preserving all literal RGB passes and sprites.
|
|
|
|
|
if porousRoomCanvas and porousRoomBoundaryShader and #currentChannels > 0 then
|
|
|
|
|
local targetCanvas = love.graphics.getCanvas()
|
|
|
|
|
local directions = {
|
|
|
|
|
{ x = -1, y = 0 }, { x = 1, y = 0 }, { x = 0, y = -1 }, { x = 0, y = 1 },
|
|
|
|
|
}
|
|
|
|
|
for _, direction in ipairs(directions) do
|
|
|
|
|
local name = self:roomAt(transform.location.x + direction.x, transform.location.y + direction.y)
|
|
|
|
|
local adjacent = name and self.levels[name]
|
|
|
|
|
if adjacent then
|
|
|
|
|
love.graphics.setCanvas(porousRoomCanvas)
|
|
|
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
|
|
|
drawAt(direction.x, direction.y, adjacent, currentChannels)
|
|
|
|
|
|
|
|
|
|
love.graphics.setCanvas(targetCanvas)
|
|
|
|
|
local roomLeft = transform.originX + (direction.x + transform.cameraX) * roomWidth
|
|
|
|
|
local roomTop = transform.originY + (direction.y + transform.cameraY) * roomHeight
|
|
|
|
|
porousRoomBoundaryShader:send("porousTime", love.timer.getTime())
|
|
|
|
|
porousRoomBoundaryShader:send("porousRoomRect", {roomLeft, roomTop, roomWidth, roomHeight})
|
|
|
|
|
porousRoomBoundaryShader:send("porousDirection", {direction.x, direction.y})
|
|
|
|
|
-- Exactly the amount of the neighbour exposed by the zoom-out.
|
|
|
|
|
porousRoomBoundaryShader:send("porousDepth", (1 - transform.zoom) * width / 2)
|
|
|
|
|
love.graphics.setBlendMode("alpha")
|
|
|
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
|
|
|
love.graphics.setShader(porousRoomBoundaryShader)
|
|
|
|
|
love.graphics.draw(porousRoomCanvas, 0, 0)
|
|
|
|
|
love.graphics.setShader()
|
2026-08-12 02:50:48 -04:00
|
|
|
end
|
|
|
|
|
end
|
2026-08-12 03:02:07 -04:00
|
|
|
love.graphics.setBlendMode("screen")
|
2026-08-12 02:50:48 -04:00
|
|
|
end
|
|
|
|
|
self.drawingRoomNeighborhood = false
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-07 01:39:47 -04:00
|
|
|
function World:getCellFromMousePos(mouseX, mouseY)
|
|
|
|
|
return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:getRoomData(name)
|
|
|
|
|
-- The current room is live: its preview reflects unsaved edits immediately.
|
|
|
|
|
if currentRoom and currentRoom.name == name then return currentRoom:serialize() end
|
|
|
|
|
if not self.previewCache[name] then
|
|
|
|
|
local raw = readFromSource("rooms/" .. name .. ".sav")
|
|
|
|
|
if not raw then return nil end
|
|
|
|
|
local ok, room = pcall(TSerial.unpack, raw)
|
|
|
|
|
if not ok then return nil end
|
|
|
|
|
self.previewCache[name] = room
|
|
|
|
|
end
|
|
|
|
|
return self.previewCache[name]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function World:invalidatePreview(name)
|
|
|
|
|
self.previewCache[name] = nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
function World:drawRoomPreview(name, x, y, size, roomData)
|
|
|
|
|
local room = roomData or self:getRoomData(name)
|
2026-08-07 01:39:47 -04:00
|
|
|
if not room then return end
|
|
|
|
|
local scale = size / (gridWidth * 16)
|
|
|
|
|
love.graphics.setScissor(x, y, size, size)
|
|
|
|
|
for _, object in ipairs(room.objects or {}) do
|
|
|
|
|
if object.class == "npc" then
|
2026-08-07 20:56:42 -04:00
|
|
|
love.graphics.setColor(1, 1, 1, 1)
|
2026-08-07 01:39:47 -04:00
|
|
|
love.graphics.print("?", x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
|
|
|
|
|
else
|
|
|
|
|
local props = globalAssetProperties[object.class]
|
|
|
|
|
if props and props.sprites then
|
2026-08-07 03:22:28 -04:00
|
|
|
local colors
|
|
|
|
|
if object.class == "door" then
|
|
|
|
|
colors = object.color and { object.color } or { "red", "green", "blue" }
|
|
|
|
|
else
|
|
|
|
|
colors = { object.color }
|
|
|
|
|
end
|
2026-08-07 01:39:47 -04:00
|
|
|
for _, color in ipairs(colors) do
|
|
|
|
|
local path = props.sprites[color]
|
|
|
|
|
if path then
|
|
|
|
|
love.graphics.setColor(gColor[color]:set())
|
2026-08-11 15:07:11 -04:00
|
|
|
local sprite = loadSprite(path)
|
|
|
|
|
if props.goopy and drawGoopySprite then
|
|
|
|
|
drawGoopySprite(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
|
|
|
|
|
elseif props.skewer and drawSkewerSprite then
|
|
|
|
|
drawSkewerSprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
|
2026-08-11 20:08:58 -04:00
|
|
|
elseif props.spiky and drawSpikySprite then
|
|
|
|
|
drawSpikySprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
|
2026-08-11 15:07:11 -04:00
|
|
|
else
|
|
|
|
|
love.graphics.draw(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
|
|
|
|
|
end
|
2026-08-07 01:39:47 -04:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
love.graphics.setScissor()
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-11 22:55:58 -04:00
|
|
|
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
|
|
|
|
|
|
2026-08-11 16:24:51 -04:00
|
|
|
function World:drawHoveredRoomName()
|
|
|
|
|
local mouseX, mouseY = love.mouse.getPosition()
|
|
|
|
|
local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)
|
|
|
|
|
local name = self:isInBounds(cellX, cellY) and self:roomAt(cellX, cellY)
|
|
|
|
|
if not name then return end
|
|
|
|
|
|
|
|
|
|
local font = love.graphics.getFont()
|
|
|
|
|
local padding = 6
|
|
|
|
|
local tooltipW = math.min(font:getWidth(name) + padding * 2, width - 8)
|
|
|
|
|
local tooltipH = font:getHeight() + padding * 2
|
|
|
|
|
local tooltipX = math.min(mouseX + 14, width - tooltipW - 4)
|
|
|
|
|
local tooltipY = math.min(mouseY + 14, height - tooltipH - 4)
|
|
|
|
|
tooltipX = math.max(4, tooltipX)
|
|
|
|
|
tooltipY = math.max(4, tooltipY)
|
|
|
|
|
|
|
|
|
|
-- Keep the label anchored beside the pointer, while keeping it entirely in
|
|
|
|
|
-- the playable map rather than allowing it to spill into the editor panel.
|
|
|
|
|
love.graphics.setColor(0, 0, 0, 0.9)
|
|
|
|
|
love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH)
|
|
|
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
|
|
|
love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH)
|
|
|
|
|
love.graphics.printf(name, tooltipX + padding, tooltipY + padding, tooltipW - padding * 2, "left")
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-07 01:39:47 -04:00
|
|
|
function World:draw()
|
|
|
|
|
local cell = width / self.width
|
2026-08-07 20:56:42 -04:00
|
|
|
love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
|
2026-08-07 01:39:47 -04:00
|
|
|
love.graphics.rectangle("fill", 0, 0, width, height)
|
|
|
|
|
for y = 1, self.height do
|
|
|
|
|
for x = 1, self.width do
|
|
|
|
|
local screenX, screenY = (x - 1) * cell, (y - 1) * cell
|
|
|
|
|
local name = self:roomAt(x, y)
|
|
|
|
|
if name then self:drawRoomPreview(name, screenX, screenY, cell) end
|
2026-08-07 20:56:42 -04:00
|
|
|
love.graphics.setColor(name and 1 or 70 / 255, name and 1 or 70 / 255, name and 1 or 90 / 255, 1)
|
2026-08-07 01:39:47 -04:00
|
|
|
love.graphics.rectangle("line", screenX, screenY, cell, cell)
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-08-11 16:24:51 -04:00
|
|
|
self:drawHoveredRoomName()
|
2026-08-07 01:39:47 -04:00
|
|
|
end
|