chroma_solstice/world.lua
Your Name 7d84f595e4 Replace box variants with a moveable adhesion property
Collapse the spiky/sticky/skewer box-variant assets into a single per-
instance `adhesion` property on any moveable block, chosen in the editor
via a dropdown next to the colour picker and stored in the .sav alongside
the class.

- spiky -> sticky: soft grab on every true edge (goopy shader)
- skewer -> gluey: rigid weld on every true edge (spiked outline)
- old trailing-face sticky is removed; its saves fold into new sticky

Move the spike geometry + drawGlueySprite into shaders.lua, fold drawing
and the adhesion flag into GenericMoveable, and delete the three
Generic{Sticky,Spiky,Skewer} classes and box_variants.lua. Room:createEntity
normalises legacy spiky_/skewer_/sticky_ class prefixes so old saves load,
and serialization emits `adhesion`. World/map previews and the movement
proxy follow the rename.

Backfill rooms/*.sav to the new format via tools/backfill_adhesion.lua
(41 objects across 8 files). Update tests to the two-mode model; the two
tests asserting the removed lateral-shear limitation are dropped and a
legacy-load migration test added. 27/27 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 16:34:01 -04:00

1237 lines
45 KiB
Lua

World = class("World")
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 = {}
self.roomByName = {}
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()
local names = {}
for _, file in ipairs(getSourceDirectoryItems("rooms")) do
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
self.lastRoom = save.lastRoom
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
return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, lastRoom = self.lastRoom, rooms = rooms }, nil, true))
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)
local saved = self:save()
if self.levels then
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
return saved
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)
if self.lastRoom == room.name then self.lastRoom = nil end
local saved = self:save()
if self.levels then
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
end
return saved, room.name
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
if self.lastRoom == oldName then self.lastRoom = newName end
self:invalidatePreview(oldName)
self:invalidatePreview(newName)
return self:save()
end
function World:setLastRoom(name)
if name and self.lastRoom ~= name then
self.lastRoom = name
end
return true
end
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
-- 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 = {}}
-- 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 = {}}
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, 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
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
-- 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
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
-- 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
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
-- 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
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]
-- 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
for circuitId in pairs(tracks or {}) do
local component = components[circuitId] and components[circuitId][key]
if component and component.valid then
runCount = runCount + 1
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, self:doorCanClose(device.room, door))
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
-- 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
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:isGluey() return self.entity:isGluey() 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)
self.roomBrowseMode = false
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
if Sound then Sound.play("playerMove") end
self:markDirty(room)
self:playerJoinCheck(room, previous, current)
self:tick(room)
self:flushRuntime()
end
end
function World:switchPlayerColor(room, color)
self.roomBrowseMode = false
local player = room.player[color]
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
local activeColors = room:getActiveColors()
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
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
-- 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
function World:tick(room)
room:tickLocal()
self:rebuildCollision()
self:resolveCircuits()
self:rebuildCollision()
self:nextRoomCheck(room)
end
-- 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
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
if Sound and nextName ~= room.name then Sound.play("levelChange") end
local sourceLocation = self:roomLocation(room.name)
local destinationLocation = self:roomLocation(nextName)
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
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
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
-- 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)
local location = self:roomLocation(room.name)
if not location then return nil end
-- 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
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
self.drawingRoomNeighborhood = true
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
for roomY = -1, 1 do
for roomX = -1, 1 do
local name = self:roomAt(transform.location.x + roomX, transform.location.y + roomY)
local adjacent = name and self.levels[name]
if adjacent then
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()
end
end
love.graphics.setBlendMode("screen")
end
self.drawingRoomNeighborhood = false
end
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
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)
for _, object in ipairs(room.objects or {}) do
if object.class == "npc" then
love.graphics.setColor(1, 1, 1, 1)
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
local colors
if object.class == "door" then
colors = object.color and { object.color } or { "red", "green", "blue" }
else
colors = { object.color }
end
for _, color in ipairs(colors) do
local path = props.sprites[color]
if path then
love.graphics.setColor(gColor[color]:set())
local sprite = loadSprite(path)
local dx, dy = x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale
if object.adhesion == "gluey" and drawGlueySprite then
drawGlueySprite(sprite, props.cells and props.cells[color], dx, dy, scale)
elseif object.adhesion == "sticky" and drawGoopySprite then
drawGoopySprite(sprite, dx, dy, scale)
else
love.graphics.draw(sprite, dx, dy, 0, scale, scale)
end
end
end
end
end
end
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)
local name = self:isInBounds(cellX, cellY) and self:roomAt(cellX, cellY)
if not name then return end
local font = love.graphics.getFont()
local padding = 6
local tooltipW = math.min(font:getWidth(name) + padding * 2, width - 8)
local tooltipH = font:getHeight() + padding * 2
local tooltipX = math.min(mouseX + 14, width - tooltipW - 4)
local tooltipY = math.min(mouseY + 14, height - tooltipH - 4)
tooltipX = math.max(4, tooltipX)
tooltipY = math.max(4, tooltipY)
-- Keep the label anchored beside the pointer, while keeping it entirely in
-- the playable map rather than allowing it to spill into the editor panel.
love.graphics.setColor(0, 0, 0, 0.9)
love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.printf(name, tooltipX + padding, tooltipY + padding, tooltipW - padding * 2, "left")
end
function World:draw()
local cell = width / self.width
love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
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
love.graphics.setColor(name and 1 or 70 / 255, name and 1 or 70 / 255, name and 1 or 90 / 255, 1)
love.graphics.rectangle("line", screenX, screenY, cell, cell)
end
end
self:drawHoveredRoomName()
end