Compare commits

...

11 commits

Author SHA1 Message Date
Your Name
38eaba2fa6 room changes 2026-08-12 09:00:44 -04:00
Your Name
8fae34b803 Unify editor input with room preview transform 2026-08-12 03:01:22 -04:00
Your Name
3c5e6daf06 Add white room browsing mode 2026-08-12 03:01:22 -04:00
Your Name
794b4dc162 Preview adjacent rooms during transitions 2026-08-12 02:50:48 -04:00
Your Name
23fe9d1c58 Test regular door occupancy resolution 2026-08-12 00:37:49 -04:00
Your Name
17140fe39f Add powered neon wire rendering 2026-08-12 00:37:49 -04:00
Your Name
cb1b5006b9 Join co-located player colors 2026-08-12 00:30:13 -04:00
Your Name
e200ddcaad wire puzzle good 2026-08-12 00:22:04 -04:00
Your Name
9731202739 Prevent doors closing through occupants 2026-08-12 00:21:53 -04:00
Your Name
bf09a8844a profiler 2026-08-12 00:12:34 -04:00
Your Name
6a5c2fc951 better wirer behavior etc 2026-08-12 00:12:26 -04:00
29 changed files with 12422 additions and 8514 deletions

View file

@ -11,6 +11,7 @@ function Door:initialize(x, y, color)
collision = "unmoveable",
sprite = loadSprite("art/game/door_" .. color:sub(1, 1) .. ".png"),
})
self.isDoor = true
self.locked = true
self:refreshSprites()
end
@ -27,10 +28,10 @@ function Door:refreshSprites()
self.sprites = { [self.doorColor] = sprite }
end
function Door:setColors(colors)
function Door:setColors(colors, canClose)
if colors[self.doorColor] then
if self.locked then self:unlock() end
else
elseif canClose ~= false then
if not self.locked then self:lock() end
end

View file

@ -58,21 +58,14 @@ local boxPaletteSections = {}
-- erase that tile more than once.
local function roomCellAt(x, y)
if x < 0 or x >= width or y < 0 or y >= height then return nil end
local cell = currentRoom:getCellFromMousePos(x, y)
cell.x = constrain(cell.x, 1, gridWidth)
cell.y = constrain(cell.y, 1, gridHeight)
return cell
return currentRoom:getCellFromMousePos(x, y)
end
local function paintRoomCell(cell, button)
-- Use the centre of the cell so Room's existing pointer-based edit helpers
-- perform the same operation as a normal click.
local x = (cell.x - .5) * width / gridWidth
local y = (cell.y - .5) * height / gridHeight
if button == 1 and selectedAsset then
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor)
currentRoom:attemptPlaceAtCell(selectedAsset, gameAssets[selectedAsset], cell, selectedColor)
elseif button == 2 then
currentRoom:attemptDelete(x, y, selectedColor)
currentRoom:attemptDeleteAtCell(cell, selectedColor)
end
end
@ -938,7 +931,7 @@ function wireView()
wireDirtyRooms = {}
editorStatus = selectedCircuitId
and "Circuit selected. Drag to draw; right-drag erases; arrow keys pan."
or "Click a wired door to select its circuit, then drag to its switches."
or "Drag from a wired door and release on a switch."
end
function worldView()
@ -1027,10 +1020,12 @@ local function beginWireBrush(x, y, button)
end
end
if not selectedCircuitId then
editorStatus = "Start by clicking a wired door."
editorStatus = "Start by dragging from a wired door."
return
end
rememberWireRooms(gameWorld:ensureWireAt(cell, selectedCircuitId))
-- Do not materialize a wire until this stroke actually crosses into an
-- adjacent cell. Previously a click on a door wrote a zero-length track,
-- which later behaved like an orphaned circuit underneath that door.
else
-- Prefer the selected circuit when several routes overlap here; otherwise
-- adopt the top route. Repeated right-clicks can therefore remove every
@ -1439,7 +1434,7 @@ function editorDraw()
love.graphics.printf("No circuit selected", 10, 54, sb.w - 20, "center")
end
love.graphics.printf(
"Click a door to start a new run, or a wire to edit that run. Each run ends at one switch; start another run from the door for another switch.\n\nLeft-drag draws. Right-drag erases. Independent wires may share a cell without connecting.\n\nArrow keys pan by one room.",
"Drag from a door to start a new run, or drag from a wire to edit that run. Release on a switch to terminate it; start another run from the door for another switch.\n\nLeft-drag draws. Right-drag erases. Independent wires may share a cell without connecting.\n\nArrow keys pan by one room.",
24, 100, sb.w - 48, "left"
)
if editorStatus then

View file

@ -8,6 +8,7 @@ require "libs/TSerial"
require "_debug_helpers"
require "_helpers"
require "input"
require "profiler"
require "color"
require "room"
require "world"
@ -128,7 +129,10 @@ function love.draw()
-- Blitting the canvas 1:1 afterwards means no rescaling of the final image
-- (crisp, nearest-neighbor throughout — nothing is averaged).
love.graphics.setCanvas(sideBarCanvas)
love.graphics.clear()
-- The gameplay view now intentionally extends beyond its old room bounds.
-- Keep the editor panel opaque so that neighbourhood preview never shows
-- through empty areas of the UI.
love.graphics.clear(0, 0, 0, 1)
love.graphics.push()
love.graphics.scale(editorRenderScale, editorRenderScale)
editorDraw()
@ -145,6 +149,7 @@ function love.draw()
love.graphics.draw(finalCanvas, 0, 0)
love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y)
love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10)
PerfProfiler:draw()
Dialogue.draw()
love.graphics.setCanvas()
@ -164,6 +169,13 @@ function love.draw()
end
function love.keypressed(key)
-- Developer CPU sampling: record a representative moving sequence, then
-- press F9 again to write folded stacks for flamegraph.pl.
if key == "f9" then
PerfProfiler:toggle()
return
end
-- Available even while a dialogue is open, so the player never has to
-- dismiss something important just to change the colour treatment.
if key == "f4" then
@ -185,6 +197,8 @@ function love.keypressed(key)
elseif editorView ~= "world" and editorView ~= "wire" and Input.isPlayerSwitch(key) then
local c = Input.playerColor(key)
gameWorld:switchPlayerColor(currentRoom, c)
elseif editorView ~= "world" and editorView ~= "wire" and gameWorld:browseRoom(currentRoom, key) then
return
end
-- if key == "w" then
@ -292,6 +306,10 @@ function _initCanvases()
gameCanvas = love.graphics.newCanvas()
finalCanvas = love.graphics.newCanvas()
presentationCanvas = love.graphics.newCanvas()
-- Reused for each cardinal neighbour's alternate real-colour view at the
-- porous room boundary. Full-screen dimensions keep it pixel-aligned with
-- the gameplay canvas during shader compositing.
porousRoomCanvas = love.graphics.newCanvas()
sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h))
sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled
end

59
profiler.lua Normal file
View file

@ -0,0 +1,59 @@
-- Developer-only CPU sampler. It is inert until F9 is pressed.
--
-- On stop it writes `chroma-solstice.folded` in LÖVE's save directory. The
-- file uses Brendan Gregg's folded-stack format and can be given directly to
-- FlameGraph's flamegraph.pl.
PerfProfiler = {
active = false,
samples = {},
status = "F9: start CPU profile",
}
local ok, profile = pcall(require, "jit.profile")
PerfProfiler.available = ok
if not ok then
PerfProfiler.status = "LuaJIT sampler unavailable"
end
local function sample(thread, count, vmstate)
-- Negative depth produces caller -> callee order, which is what folded
-- flamegraphs expect. Function-level samples keep the output compact.
local stack = profile.dumpstack(thread, "pF;", -64):gsub(";$", "")
if stack ~= "" then
PerfProfiler.samples[stack] = (PerfProfiler.samples[stack] or 0) + count
end
end
function PerfProfiler:toggle()
if not self.available then return false end
if not self.active then
self.samples = {}
profile.start("fi2", sample)
self.active = true
self.status = "CPU profile recording (F9 to write)"
return true
end
profile.stop()
self.active = false
local lines, sampleCount = {}, 0
for stack, count in pairs(self.samples) do
table.insert(lines, stack .. " " .. count)
sampleCount = sampleCount + count
end
table.sort(lines)
local path = "chroma-solstice.folded"
local saved = love.filesystem.write(path, table.concat(lines, "\n") .. "\n")
local directory = love.filesystem.getSaveDirectory()
self.status = saved and ("CPU profile saved (" .. sampleCount .. " samples); see terminal")
or "Could not write CPU profile"
if saved then print("CPU profile: " .. directory .. "/" .. path) else print(self.status) end
return saved
end
function PerfProfiler:draw()
if not self.active and self.status == "F9: start CPU profile" then return end
love.graphics.setColor(self.active and 1 or 0.6, self.active and 0.75 or 1, self.active and 0.2 or 0.6, 1)
love.graphics.print(self.active and "PROFILING: F9 to save flamegraph data" or self.status, 10, 28)
love.graphics.setColor(1, 1, 1, 1)
end

121
room.lua
View file

@ -130,16 +130,40 @@ function Room:update(dt)
end
end
function Room:draw()
self:drawWiresAt(0, 0, width / gridWidth, nil, false)
-- A room's view is defined by the colour player copies that are in that room,
-- independent of which of those copies is currently being controlled. A caller
-- may supply another set for World's porous boundary without altering sprites
-- or colour blending.
function Room:getRenderChannels()
if gameWorld and gameWorld.roomBrowseMode then return {"red", "green", "blue"} end
local channels = {}
for _, color in ipairs({"red", "green", "blue"}) do
if self.player[color] then table.insert(channels, color) end
end
return channels
end
-- draw only the channels the player has. Each object just exposes sprites per
function Room:draw(drawChannels)
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
return gameWorld:drawRoomNeighborhood(self)
end
drawChannels = drawChannels or self:getRenderChannels()
self:drawWiresAt(0, 0, width / gridWidth, nil, false, drawChannels)
-- Draw only the channels active in this view. Each object just exposes sprites per
-- channel via Entity:draw(channel); a door registered on all three channels
-- naturally draws its red sprite on the red pass, etc. No per-object logic.
for _, channel in ipairs(self.colorsPlayerHas) do
for _, channel in ipairs(drawChannels) do
if self.player[channel] then self.player[channel]:draw(channel) end
for _, entity in pairs(self.collideables[channel]) do entity:draw(channel) end
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then gameWorld:drawOverlaps(self, channel) end
-- The zoomed-out neighbourhood already draws every room directly. Calling
-- the legacy seam helper from each miniature room duplicates foreign
-- entities, which can screen-blend red and green into a false yellow.
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
gameWorld:drawOverlaps(self, channel)
end
for _, blocker in pairs(self.playerBlockers[channel]) do blocker:draw(channel) end
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
for _, switch in ipairs(self.wiredSwitches) do switch:draw(channel) end
@ -179,7 +203,7 @@ function Room:removeWire(cell, circuitId)
return true
end
function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorColors)
function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorColors, renderChannels)
local ids = {}
for _, row in pairs(self.wires) do
for _, tracks in pairs(row) do
@ -191,8 +215,11 @@ function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorCol
table.sort(ordered)
love.graphics.setBlendMode("alpha")
local visibleColors = {red = false, green = false, blue = false}
for _, color in ipairs(self.colorsPlayerHas or {}) do visibleColors[color] = true end
local showAllChannels = gameWorld and gameWorld.roomBrowseMode
local visibleColors = {red = showAllChannels, green = showAllChannels, blue = showAllChannels}
if not showAllChannels then
for _, color in ipairs(renderChannels or self:getRenderChannels()) do visibleColors[color] = true end
end
local editorIsOn = editorView ~= nil and editorView ~= "off"
for _, circuitId in ipairs(ordered) do
local selected = not selectedCircuit or selectedCircuit == circuitId
@ -239,12 +266,21 @@ function Room:drawWiresAt(originX, originY, cellSize, selectedCircuit, editorCol
for _, line in ipairs(lines) do love.graphics.line(line) end
local wireBlend = (editorColors or not switchColors) and "alpha" or "screen"
love.graphics.setBlendMode(wireBlend)
local previousShader = love.graphics.getShader()
if not editorColors and switchColors and wireNeonShader then
local powered = gameWorld:wirePoweredAt(self, localCell, circuitId)
wireNeonShader:send("wirePower", powered and 1 or 0)
wireNeonShader:send("wireTime", love.timer.getTime())
wireNeonShader:send("wirePhase", (#circuitId * 0.137) + x * 0.071 + y * 0.113)
love.graphics.setShader(wireNeonShader)
end
love.graphics.setLineWidth(math.max(1, cellSize * 0.045))
for _, color in ipairs(drawColors) do
love.graphics.setColor(color[1], color[2], color[3], selected and 1 or 0.32)
for _, line in ipairs(lines) do love.graphics.line(line) end
love.graphics.circle("fill", cx, cy, math.max(0.75, cellSize * 0.0275))
end
love.graphics.setShader(previousShader)
end
end
end
@ -440,8 +476,24 @@ function Room:switchPlayerColorLocal(color)
local targetPlayer = self.player[color]
if not targetPlayer then return false end
local canSwitch = not self:onlyColorIsActive(color)
if canSwitch then
local activeColors = self:getActiveColors()
local canJoin = not self:colorIsActive(color) and #activeColors > 0
if canJoin then
local target = targetPlayer:getGridPos()
for _, activeColor in ipairs(activeColors) do
local activePlayer = self.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 not self:onlyColorIsActive(color)
if canJoin then
self:setActiveColor(color, true)
elseif canSwitch then
for _, c in ipairs(self:getPossibleColors()) do
self:setActiveColor(c, c == color)
end
@ -538,10 +590,29 @@ end
function Room:resolveSwitchesAndDoors()
local setDoorColors = self:switchCheck()
for _, door in pairs(self.doors) do
door:setColors(setDoorColors)
door:setColors(setDoorColors, self:doorCanClose(door))
end
end
-- Match World:doorCanClose for ordinary room-local doors. All colour layers
-- count, while other layers of the same doorway do not obstruct each other.
function Room:doorCanClose(door)
for _, cell in ipairs(door:getOccupiedCells()) do
for _, player in pairs(self.player or {}) do
if player:occupiesCell(cell) then return false end
end
for _, entities in pairs(self.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 Room:playerJoinCheck(previousPositions, newPositions)
local activate = {}
local deactivate = {}
@ -783,14 +854,18 @@ function Room:registerNpc(entity)
end
function Room:getCellFromMousePos(mouseX, mouseY)
local cell = {}
cell.x = math.ceil(mouseX * gridWidth / width)
cell.y = math.ceil(mouseY * gridHeight / height)
return cell
if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self and
not gameWorld.drawingRoomNeighborhood then
return gameWorld:screenCellInRoom(self, mouseX, mouseY)
end
return {
x = math.floor(mouseX * gridWidth / width) + 1,
y = math.floor(mouseY * gridHeight / height) + 1,
}
end
function Room:attemptDelete(mouseX, mouseY, color)
local cell = self:getCellFromMousePos(mouseX, mouseY)
function Room:attemptDeleteAtCell(cell, color)
if not cell then return end
-- "white" (or nil) deletes every channel; otherwise just the picked one
local colors
@ -857,9 +932,13 @@ function Room:attemptDelete(mouseX, mouseY, color)
self:tick()
end
function Room:attemptPlace(name, props, mouseX, mouseY, color)
function Room:attemptDelete(mouseX, mouseY, color)
return self:attemptDeleteAtCell(self:getCellFromMousePos(mouseX, mouseY), color)
end
function Room:attemptPlaceAtCell(name, props, cell, color)
if not props or not props.sprites then return end
local cell = self:getCellFromMousePos(mouseX, mouseY)
if not cell then return end
-- Jorge is one dialogable NPC with three display channels, not three
-- independently placeable color-layer entities.
if name == "jorge" then
@ -886,6 +965,10 @@ function Room:attemptPlace(name, props, mouseX, mouseY, color)
self:tick()
end
function Room:attemptPlace(name, props, mouseX, mouseY, color)
return self:attemptPlaceAtCell(name, props, self:getCellFromMousePos(mouseX, mouseY), color)
end
function Room:load(name, preferRuntime)
-- Editor/startup loads must use the project source level. Runtime saves are
-- only consulted for an in-progress room-to-room game transition, otherwise

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,76 +1,60 @@
{
wires={
},
objects={
{
color="red",
y=4,
x=8,
class="toilet"
class="toilet",
y=4
},
{
color="red",
y=4,
x=6,
class="toilet"
class="toilet",
y=4
},
{
color="red",
y=4,
x=4,
class="toilet"
class="toilet",
y=4
},
{
color="green",
y=4,
x=8,
class="toilet"
class="toilet",
y=4
},
{
color="green",
y=4,
x=6,
class="toilet"
class="toilet",
y=4
},
{
color="green",
y=4,
x=4,
class="toilet"
class="toilet",
y=4
},
{
color="blue",
y=4,
x=8,
class="toilet"
class="toilet",
y=4
},
{
color="blue",
y=4,
x=6,
class="toilet"
class="toilet",
y=4
},
{
color="blue",
y=4,
x=4,
class="toilet"
},
{
color="red",
y=8,
x=6,
class="player"
},
{
color="green",
y=8,
x=6,
class="player"
},
{
color="blue",
y=8,
x=6,
class="player"
class="toilet",
y=4
}
}
}

File diff suppressed because it is too large Load diff

View file

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

1012
rooms/room_10_15.sav Normal file

File diff suppressed because it is too large Load diff

6
rooms/room_13_13.sav Normal file
View file

@ -0,0 +1,6 @@
{
objects={
},
wires={
}
}

1165
rooms/room_13_14.sav Normal file

File diff suppressed because it is too large Load diff

1101
rooms/room_14_14.sav Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,548 +1,586 @@
{
wires={
},
objects={
{
class="static_wall",
color="blue",
color="red",
x=1,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=2,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=3,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=4,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=5,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=6,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=7,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=8,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=9,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=10,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=1
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=2
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=3
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=4
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=5
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=6
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=7
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=8
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=9
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=10
},
{
class="static_wall",
color="blue",
color="red",
x=1,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=2,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=3,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=5,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=6,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=7,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=8,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=9,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=10,
class="static_wall",
y=11
},
{
class="static_wall",
color="blue",
color="red",
x=11,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
x=1,
y=1
x=8,
class="box",
y=4
},
{
class="static_wall",
color="red",
x=2,
y=1
x=7,
class="spiky_box_2x1_11",
y=3
},
{
class="static_wall",
color="red",
x=3,
y=1
x=6,
class="spiky_box_2x1_11",
y=4
},
{
class="static_wall",
color="red",
x=4,
class="spiky_box_2x1_11",
y=4
},
{
color="red",
x=3,
class="box_2x2_11-10",
y=3
},
{
color="green",
x=1,
class="static_wall",
y=1
},
{
color="green",
x=2,
class="static_wall",
color="red",
y=1
},
{
color="green",
x=3,
class="static_wall",
y=1
},
{
color="green",
x=4,
class="static_wall",
y=1
},
{
color="green",
x=5,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=6,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=7,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=8,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=9,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=10,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=1
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=2
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=3
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=4
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=5
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=6
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=7
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=8
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=9
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=10
},
{
class="static_wall",
color="red",
color="green",
x=1,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=2,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=3,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=5,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=6,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=7,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=8,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=9,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=10,
class="static_wall",
y=11
},
{
class="static_wall",
color="red",
color="green",
x=11,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
x=1,
y=1
},
{
class="static_wall",
color="green",
x=2,
y=1
},
{
class="static_wall",
color="green",
x=3,
y=1
},
{
class="static_wall",
color="green",
x=4,
class="box",
y=3
},
{
color="blue",
x=1,
class="static_wall",
y=1
},
{
color="blue",
x=2,
class="static_wall",
color="green",
y=1
},
{
color="blue",
x=3,
class="static_wall",
y=1
},
{
color="blue",
x=4,
class="static_wall",
y=1
},
{
color="blue",
x=5,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=6,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=7,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=8,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=9,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=10,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=1
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=2
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=3
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=4
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=5
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=6
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=7
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=8
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=9
},
{
class="static_wall",
color="green",
color="blue",
x=11,
class="static_wall",
y=10
},
{
class="static_wall",
color="green",
color="blue",
x=1,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=2,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=3,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=5,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=6,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=7,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=8,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=9,
class="static_wall",
y=11
},
{
class="static_wall",
color="green",
color="blue",
x=10,
y=11
},
{
class="static_wall",
color="green",
x=11,
y=11
},
{
color="blue",
x=11,
class="static_wall",
y=11
},
{
x=5,
class="npc",
x=5,
y=2
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

@ -89,6 +89,83 @@ goopShader = love.graphics.newShader[[
}
]]
-- Wires are drawn as primitives rather than textured sprites, but LÖVE still
-- runs this shader over its default white texture. Inactive runs stay visibly
-- connected at half brightness; powered runs get a travelling neon shimmer,
-- occasional tube-like noise, and a sub-pixel bend rather than a global fade.
wireNeonShader = love.graphics.newShader[[
extern number wirePower;
extern number wireTime;
extern number wirePhase;
float wireHash(vec2 point) {
return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453123);
}
vec4 position(mat4 transformProjection, vec4 vertexPosition) {
if (wirePower < 0.5) return transformProjection * vertexPosition;
// Every segment sharing an endpoint receives the same offset, so corners
// stay connected while the live current gives the tube a gentle movement.
float wave = sin(vertexPosition.x * 0.085 + vertexPosition.y * 0.13 +
wireTime * 4.4 + wirePhase * 9.0);
float wobble = wave * 0.32;
vertexPosition.xy += vec2(wobble, -wobble * 0.55);
return transformProjection * vertexPosition;
}
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
if (wirePower < 0.5) return vec4(color.rgb * 0.50, color.a * 0.72);
float travel = dot(screenCoords, vec2(0.070, 0.043)) - wireTime * 6.8 + wirePhase * 13.0;
float shimmer = sin(travel) * 0.075 + sin(travel * 2.37 + 1.7) * 0.035;
vec2 noiseCell = floor(screenCoords * 0.34) + floor(wireTime * 15.0) * vec2(1.0, -1.0);
float noise = wireHash(noiseCell + wirePhase);
float flicker = max(0.0, noise - 0.82) * 0.32;
float brightness = 0.92 + shimmer + flicker;
float alpha = 0.84 + shimmer * 0.80 + flicker * 0.55;
return vec4(color.rgb * brightness, color.a * alpha);
}
]]
-- World renders a neighbouring room twice for the porous seam: once through
-- its own active channels and once through the current room's channels. This
-- shader only controls the alpha of that second, already-authored render, so
-- it never approximates the game's literal RGB colour treatment.
porousRoomBoundaryShader = love.graphics.newShader[[
extern number porousTime;
extern vec4 porousRoomRect;
extern vec2 porousDirection;
extern number porousDepth;
float porousWave(vec2 point) {
float broad = sin(point.x * 0.095 + point.y * 0.071 + porousTime * 1.45);
float fine = sin(point.x * 0.231 - point.y * 0.187 - porousTime * 2.20);
return broad * 0.105 + fine * 0.050;
}
vec4 effect(vec4 color, Image texture, vec2 textureCoords, vec2 screenCoords) {
vec4 alternateView = Texel(texture, textureCoords) * color;
vec2 local = screenCoords - porousRoomRect.xy;
vec2 size = porousRoomRect.zw;
if (local.x < 0.0 || local.y < 0.0 || local.x > size.x || local.y > size.y)
return vec4(0.0);
float depth;
if (porousDirection.x > 0.5) depth = local.x;
else if (porousDirection.x < -0.5) depth = size.x - local.x;
else if (porousDirection.y > 0.5) depth = local.y;
else depth = size.y - local.y;
float towardSeam = 1.0 - smoothstep(0.0, porousDepth, depth);
// Animated fingers soften the fifty-fifty transition across the visible
// overlap while preserving the pixels of the real alternate room render.
float mask = smoothstep(0.18, 0.82, towardSeam + porousWave(screenCoords));
alternateView.a *= mask;
return alternateView;
}
]]
function drawGoopySprite(sprite, x, y, scale)
local w, h = sprite:getWidth(), sprite:getHeight()
local time = love.timer.getTime()

View file

@ -169,6 +169,15 @@ test("a switchless wire component has no gameplay color", function()
assertTrue(world:wireColorAt(roomA, {x = 1, y = 1}, "circuit_a") == nil)
end)
test("wire power follows its terminal switch", function()
local world, roomA = makeWorld()
world:resolveCircuits()
assertFalse(world:wirePoweredAt(roomA, {x = 1, y = 1}, "circuit_a"))
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
assertTrue(world:wirePoweredAt(roomA, {x = 1, y = 1}, "circuit_a"))
end)
test("a door requires every independent switch run at its terminal", function()
local greenSwitch = switch("green", 1, 2)
local world, roomA, _, wiredDoor = makeWorld()
@ -183,6 +192,220 @@ test("a door requires every independent switch run at its terminal", function()
assertTrue(world.circuitState.doorPowered[wiredDoor])
end)
test("an orphaned stub at a door does not block a valid switch run", function()
local world, roomA, _, wiredDoor = makeWorld()
roomA.wires[1][1].abandoned_stroke = 0
world.collidableMatrices.blue[1] = {[4] = {}}
world:resolveCircuits()
assertTrue(world.circuitState.doorPowered[wiredDoor])
assertTrue(wiredDoor.powered)
end)
test("real wired and regular doors stay open while any player or collidable blocks the doorway", function()
local oldLoadSprite = loadSprite
loadSprite = function()
return {getWidth = function() return 16 end, getHeight = function() return 16 end}
end
require "color"
require "wired_door"
local function occupant(x, y)
local entity = {x = x, y = y}
function entity:occupiesCell(cell) return self.x == cell.x and self.y == cell.y end
function entity:canPassOver() return false end
return entity
end
local wired = WiredDoor:new(2, 2, "blue", "circuit_a")
local ordinary = Door:new(4, 2, "red")
local room = {
player = {green = occupant(2, 2)},
collideables = {
red = {ordinary}, green = {occupant(4, 2)}, blue = {wired},
},
}
local world = setmetatable({}, {__index = World})
wired:setPowered(true)
assertFalse(wired.locked, "wired door did not open")
assertFalse(world:doorCanClose(room, wired), "green player did not block blue wired door")
wired:setPowered(false, world:doorCanClose(room, wired))
assertFalse(wired.locked, "wired door shut on player")
ordinary:setColors({red = true})
assertFalse(ordinary.locked, "regular door did not open")
assertFalse(world:doorCanClose(room, ordinary), "green collidable did not block red regular door")
ordinary:setColors({red = false}, world:doorCanClose(room, ordinary))
assertFalse(ordinary.locked, "regular door shut on collidable")
room.player.green.x, room.player.green.y = 1, 1
room.collideables.green[1].x, room.collideables.green[1].y = 1, 1
wired:setPowered(false, world:doorCanClose(room, wired))
ordinary:setColors({red = false}, world:doorCanClose(room, ordinary))
assertTrue(wired.locked, "wired door did not close once clear")
assertTrue(ordinary.locked, "regular door did not close once clear")
loadSprite = oldLoadSprite
end)
test("circuit resolution passes doorway occupancy to a wired door", function()
local world, roomA, _, wiredDoor = makeWorld()
local player = {x = 1, y = 1}
function player:occupiesCell(cell) return self.x == cell.x and self.y == cell.y end
roomA.player.green = player
function wiredDoor:setPowered(powered, canClose)
self.powered, self.canClose = powered, canClose
end
world:resolveCircuits()
assertFalse(wiredDoor.powered, "unpressed switch unexpectedly powered door")
assertFalse(wiredDoor.canClose, "resolver omitted player doorway obstruction")
end)
test("room switch resolution keeps a regular door open around an occupant", function()
local oldLove, oldLoadSprite = love, loadSprite
love = {audio = {newSource = function()
return {stop = function() end, play = function() end}
end}}
loadSprite = function()
return {getWidth = function() return 16 end, getHeight = function() return 16 end}
end
require "room"
local door = Door:new(2, 2, "red")
local player, block = {x = 2, y = 2}, {x = 1, y = 1}
function player:occupiesCell(cell) return self.x == cell.x and self.y == cell.y end
function block:occupiesCell(cell) return self.x == cell.x and self.y == cell.y end
function block:canPassOver() return false end
local room = setmetatable({
player = {blue = player}, doors = {door},
collideables = {red = {door}, green = {block}, blue = {}},
}, {__index = Room})
room.switchCheck = function() return {red = true} end
room:resolveSwitchesAndDoors()
assertFalse(door.locked, "switch did not open regular door")
room.switchCheck = function() return {red = false} end
room:resolveSwitchesAndDoors()
assertFalse(door.locked, "regular door shut through player during room resolution")
room.player.blue.x, room.player.blue.y = 1, 1
room.collideables.green[1].x, room.collideables.green[1].y = 2, 2
room:resolveSwitchesAndDoors()
assertFalse(door.locked, "regular door shut through collidable during room resolution")
room.collideables.green[1].x, room.collideables.green[1].y = 1, 1
room:resolveSwitchesAndDoors()
assertTrue(door.locked, "regular door did not close once clear")
love, loadSprite = oldLove, oldLoadSprite
end)
test("selecting a parked colour changes to its room", function()
local function player()
local entity = {switched = false}
function entity:playSwitchSound(success) self.switched = success end
return entity
end
local function room(name, players, active)
local entity = {name = name, player = players, activeColors = active, colorsPlayerHas = {}}
for color in pairs(players) do table.insert(entity.colorsPlayerHas, color) end
function entity:getPossibleColors() return self.colorsPlayerHas end
function entity:setActiveColor(color, value) self.activeColors[color] = value end
function entity:getActiveColors()
local colors = {}
for color, value in pairs(self.activeColors) do if value then table.insert(colors, color) end end
return colors
end
return entity
end
local roomA = room("a", {red = player()}, {red = true, green = false, blue = false})
local roomB = room("b", {blue = player()}, {red = false, green = false, blue = false})
local world = setmetatable({
levels = {a = roomA, b = roomB},
roomByName = {a = {name = "a", x = 1, y = 1}, b = {name = "b", x = 2, y = 1}},
}, {__index = World})
world.flushRuntime = function(self) self.flushed = true end
local oldCurrentRoom, oldSetEditorRoomName = currentRoom, setEditorRoomName
currentRoom = roomA
setEditorRoomName = function() end
assertTrue(world:switchPlayerColor(roomA, "blue"))
assertTrue(currentRoom == roomB)
assertFalse(roomA.activeColors.red)
assertTrue(roomB.activeColors.blue)
assertTrue(roomB.player.blue.switched)
assertTrue(world.flushed)
currentRoom, setEditorRoomName = oldCurrentRoom, oldSetEditorRoomName
end)
test("co-located colours join the active player selection", function()
local function player(x, y)
local entity = {x = x, y = y, switched = false}
function entity:getGridPos() return {x = self.x, y = self.y} end
function entity:playSwitchSound(success) self.switched = success end
return entity
end
local room = {
name = "a",
player = {red = player(3, 4), green = player(3, 4), blue = player(6, 4)},
activeColors = {red = true, green = false, blue = false},
colorsPlayerHas = {"red", "green", "blue"},
}
function room:getPossibleColors() return self.colorsPlayerHas end
function room:getActiveColors()
local colors = {}
for _, color in ipairs(self.colorsPlayerHas) do
if self.activeColors[color] then table.insert(colors, color) end
end
return colors
end
function room:colorIsActive(color) return self.activeColors[color] end
function room:setActiveColor(color, value) self.activeColors[color] = value end
local world = setmetatable({
levels = {a = room}, roomByName = {a = {name = "a", x = 1, y = 1}},
}, {__index = World})
world.flushRuntime = function() end
assertTrue(world:switchPlayerColor(room, "green"))
assertTrue(room.activeColors.red and room.activeColors.green,
"co-located red and green did not become active together")
assertTrue(world:switchPlayerColor(room, "blue"))
assertTrue(room.activeColors.blue and not room.activeColors.red and not room.activeColors.green,
"separated blue player should still select by itself")
end)
test("WASD room browsing changes view without moving players", function()
local player = {x = 3, y = 4}
local roomA = {name = "a", player = {red = player}}
local roomB = {name = "b", player = {green = {x = 7, y = 8}}}
local world = setmetatable({
levels = {a = roomA, b = roomB},
roomByName = {a = {name = "a", x = 1, y = 1}, b = {name = "b", x = 2, y = 1}},
rooms = {{name = "a", x = 1, y = 1}, {name = "b", x = 2, y = 1}},
}, {__index = World})
local oldCurrentRoom, oldSetEditorRoomName = currentRoom, setEditorRoomName
currentRoom = roomA
setEditorRoomName = function() end
assertTrue(world:browseRoom(roomA, "d"))
assertTrue(currentRoom == roomB)
assertTrue(world.roomBrowseMode)
assertTrue(player.x == 3 and player.y == 4, "browsing moved the player")
currentRoom, setEditorRoomName = oldCurrentRoom, oldSetEditorRoomName
end)
test("screen-to-cell mapping uses the neighbourhood render transform", function()
local oldWidth, oldHeight, oldGridWidth, oldGridHeight = width, height, gridWidth, gridHeight
width, height, gridWidth, gridHeight = 110, 110, 11, 11
local room = {name = "a"}
local world = setmetatable({}, {__index = World})
world.roomNeighborhoodTransform = function()
return {zoom = 0.78, originX = 22, originY = 22, cameraX = 0, cameraY = 0}
end
local cell = world:screenCellInRoom(room,
22 + width * 0.78 * (4.5 / 11), 22 + height * 0.78 * (7.5 / 11))
assertTrue(cell.x == 5 and cell.y == 8, "rendered cell did not map back to itself")
assertTrue(world:screenCellInRoom(room, 1, 1) == nil, "preview margin mapped into active room")
width, height, gridWidth, gridHeight = oldWidth, oldHeight, oldGridWidth, oldGridHeight
end)
test("one run cannot terminate at two different switches", function()
local second = switch("red", 1, 2)
local world, _, roomB = makeWorld(second)

View file

@ -10,10 +10,10 @@ function WiredDoor:initialize(x, y, color, circuitId)
self.wired = true
end
function WiredDoor:setPowered(powered)
function WiredDoor:setPowered(powered, canClose)
if powered then
if self.locked then self:unlock() end
elseif not self.locked then
elseif canClose ~= false and not self.locked then
self:lock()
end
self:refreshSprites()

237
world.lua
View file

@ -284,6 +284,28 @@ function World:isSwitchPressed(room, switch)
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 = {}
@ -429,13 +451,16 @@ function World:resolveCircuits()
local position = {x = x, y = y}
local key = cellKey(position)
local tracks = self.wireMatrix[y] and self.wireMatrix[y][x]
local runCount, powered = 0, tracks ~= nil
-- 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
runCount = runCount + 1
local component = components[circuitId] and components[circuitId][key]
if not component or not component.valid then
powered = false
else
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
@ -445,7 +470,7 @@ function World:resolveCircuits()
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)
door:setPowered(powered, self:doorCanClose(device.room, door))
end
end
end
@ -469,6 +494,20 @@ function World:wireSwitchColorsAt(room, localCell, circuitId)
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
@ -624,6 +663,7 @@ function World:playerJoinCheck(room, previousPositions, newPositions)
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"},
@ -675,11 +715,55 @@ function World:movePlayer(room, key)
end
function World:switchPlayerColor(room, color)
self.roomBrowseMode = false
local player = room.player[color]
if not player then return false end
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 canSwitch = #activeColors ~= 1 or activeColors[1] ~= color
if canSwitch then
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
@ -689,6 +773,19 @@ function World:switchPlayerColor(room, color)
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()
@ -714,6 +811,8 @@ function World:nextRoom(room, pos)
elseif pos.y > gridHeight then exit, entry.y = "bottom", 1
elseif pos.y < 1 then exit, entry.y = "top", gridHeight end
local nextName = self:getNeighbor(room.name, exit) or room.name
local sourceLocation = self:roomLocation(room.name)
local destinationLocation = self:roomLocation(nextName)
local activeColors = room:getActiveColors()
self:markDirty(room)
local destination = self.levels[nextName]
@ -735,6 +834,13 @@ function World:nextRoom(room, pos)
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
@ -802,6 +908,119 @@ function World:drawOverlaps(room, color)
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