chroma_solstice/world.lua

581 lines
20 KiB
Lua
Raw Normal View History

2026-08-07 01:39:47 -04:00
World = class("World")
local Movement = require "movement"
2026-08-07 01:39:47 -04:00
-- The world is deliberately only a layout: rooms continue to own their puzzle
-- state. Keeping the two separate lets the editor move a room without
-- rewriting its level file.
function World:initialize()
self.width, self.height = 18, 18
self.rooms = {}
self.roomByName = {}
self.previewCache = {}
2026-08-07 03:22:28 -04:00
self.lastRoom = nil
2026-08-07 01:39:47 -04:00
self:load()
self:loadRuntimeState()
self.levels = {}
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()
2026-08-07 01:39:47 -04:00
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()
return true
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
2026-08-07 01:39:47 -04:00
function World:listRoomNames()
local names = {}
for _, file in ipairs(love.filesystem.getDirectoryItems("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
2026-08-07 03:22:28 -04:00
self.lastRoom = save.lastRoom
2026-08-07 01:39:47 -04:00
for _, room in ipairs(save.rooms or {}) do
if room.name and self:isInBounds(room.x, room.y) then
self:_putRoom(room.name, room.x, room.y)
end
end
end
function World:save()
local rooms = {}
for _, room in ipairs(self.rooms) do
table.insert(rooms, { name = room.name, x = room.x, y = room.y })
end
2026-08-07 03:22:28 -04:00
return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, lastRoom = self.lastRoom, rooms = rooms }, nil, true))
2026-08-07 01:39:47 -04:00
end
function World:isInBounds(x, y)
return x and y and x >= 1 and x <= self.width and y >= 1 and y <= self.height
end
function World:roomAt(x, y)
for _, room in ipairs(self.rooms) do
if room.x == x and room.y == y then return room.name end
end
return nil
end
function World:roomLocation(name)
return self.roomByName[name]
end
function World:_putRoom(name, x, y)
local existing = self.roomByName[name]
if existing then
for i = #self.rooms, 1, -1 do
if self.rooms[i] == existing then table.remove(self.rooms, i) end
end
end
for i = #self.rooms, 1, -1 do
if self.rooms[i].x == x and self.rooms[i].y == y then
self.roomByName[self.rooms[i].name] = nil
table.remove(self.rooms, i)
end
end
local room = { name = name, x = x, y = y }
table.insert(self.rooms, room)
self.roomByName[name] = room
return room
end
function World:placeRoom(name, x, y)
if not name or not self:isInBounds(x, y) then return false end
self:_putRoom(name, x, y)
return self:save()
end
function World:removeRoom(x, y)
for i = #self.rooms, 1, -1 do
local room = self.rooms[i]
if room.x == x and room.y == y then
self.roomByName[room.name] = nil
table.remove(self.rooms, i)
2026-08-07 03:22:28 -04:00
if self.lastRoom == room.name then self.lastRoom = nil end
2026-08-07 01:39:47 -04:00
return self:save(), 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
2026-08-07 03:22:28 -04:00
if self.lastRoom == oldName then self.lastRoom = newName end
2026-08-07 01:39:47 -04:00
self:invalidatePreview(oldName)
self:invalidatePreview(newName)
return self:save()
end
2026-08-07 03:22:28 -04:00
function World:setLastRoom(name)
if name and self.lastRoom ~= name then
self.lastRoom = name
end
return true
end
2026-08-07 01:39:47 -04:00
function World:getNeighbor(name, exit)
local room = self:roomLocation(name)
if not room then return nil end
local directions = {
left = { x = -1, y = 0 }, right = { x = 1, y = 0 },
top = { x = 0, y = -1 }, bottom = { x = 0, y = 1 },
}
local direction = directions[exit]
if not direction then return nil end
return self:roomAt(room.x + direction.x, room.y + direction.y)
end
-- All placed levels share these sparse matrices. Rendering still uses the
-- selected Room, whose ordinary local matrices remain unchanged.
function World:rebuildCollision()
self.collidableMatrices = {red = {}, green = {}, blue = {}}
self.playerBlockerMatrices = {red = {}, green = {}, blue = {}}
self.entityRooms = {}
local function put(matrix, cell, entity)
matrix[cell.y] = matrix[cell.y] or {}
matrix[cell.y][cell.x] = entity
end
for name, room in pairs(self.levels) do
local location = self:roomLocation(name)
if location then
for color, entities in pairs(room.collideables) do
for _, entity in pairs(entities) do
self.entityRooms[entity] = room
for _, cell in ipairs(entity:getOccupiedCells()) do
put(self.collidableMatrices[color], {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}, entity)
end
end
end
-- NPCs are local room state, but globally collidable: a movement lookup
-- must find them so its normal bump() path can open dialogue.
for _, npc in ipairs(room.npcs) do
self.entityRooms[npc] = room
for _, cell in ipairs(npc:getOccupiedCells()) do
local worldCell = {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}
for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end
end
end
for color, entities in pairs(room.playerBlockers) do
for _, entity in pairs(entities) do
for _, cell in ipairs(entity:getOccupiedCells()) do
put(self.playerBlockerMatrices[color], {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}, entity)
end
end
end
end
end
end
function World:worldCell(room, cell)
local location = self:roomLocation(room.name)
return location and {x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y}
end
function World:roomCell(cell)
local roomX = math.floor((cell.x - 1) / gridWidth) + 1
local roomY = math.floor((cell.y - 1) / gridHeight) + 1
local name = self:roomAt(roomX, roomY)
local room = name and self.levels[name]
return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight}
end
function World:movementContext(room, color)
local proxies = {}
local world = self
local context = {}
function context:worldCell(cell) return world:worldCell(room, cell) end
function context:isInBounds(cell) return world:roomCell(cell) ~= nil end
function context:entityAt(cell)
local row = world.collidableMatrices[color][cell.y]
local entity = row and row[cell.x]
if not entity then return nil end
if proxies[entity] then return proxies[entity] end
local owner = world.entityRooms[entity]
local position = world:worldCell(owner, entity:getGridPos())
local proxy = {entity = entity, room = owner, color = color, x = position.x, y = position.y}
function proxy:canMove() return self.entity:canMove() end
function proxy:canPassOver() return self.entity:canPassOver() end
function proxy:isSticky() return self.entity:isSticky() end
function proxy:isSkewer() return self.entity:isSkewer() end
function proxy:isSpiky() return self.entity:isSpiky() end
function proxy:bump() return self.entity:bump() end
function proxy:getOccupiedCells(offset)
offset = offset or {x = 0, y = 0}
local cells = {}
for _, shapeCell in ipairs(self.entity:getCellShape()) do
table.insert(cells, {x = self.x + shapeCell.x - 1 + offset.x, y = self.y + shapeCell.y - 1 + offset.y})
end
return cells
end
proxies[entity] = proxy
return proxy
end
function context:playerBlockerAt(cell)
local row = world.playerBlockerMatrices[color][cell.y]
return row and row[cell.x]
end
return context
end
-- Player copies live in room files, but joining is a world-coordinate rule.
-- This also lets a copy rejoin immediately when the player crosses into its
-- room, rather than waiting for one more local move.
function World:playerJoinCheck(room, previousPositions, newPositions)
local activeColors = room:getActiveColors()
local deactivate, activate = {}, {}
if previousPositions and newPositions and #activeColors > 1 then
for _, color in ipairs(activeColors) do
if Entity.positionsAreEqual(previousPositions[color], newPositions[color]) then
table.insert(deactivate, color)
end
end
end
for color, player in pairs(room.player) do
if not room:colorIsActive(color) then
local playerCell = self:worldCell(room, player:getGridPos())
local overlaps = playerCell and #activeColors > 0
for _, activeColor in ipairs(activeColors) do
local activeCell = self:worldCell(room, room.player[activeColor]:getGridPos())
if not activeCell or activeCell.x ~= playerCell.x or activeCell.y ~= playerCell.y then overlaps = false; break end
end
if overlaps then table.insert(activate, color) end
end
end
for _, color in ipairs(activate) do room:setActiveColor(color, true) end
for _, color in ipairs(deactivate) do room:setActiveColor(color, false) end
end
function World:movePlayer(room, key)
local directions = {
up = {x = 0, y = -1, axis = "y"}, down = {x = 0, y = 1, axis = "y"},
left = {x = -1, y = 0, axis = "x"}, right = {x = 1, y = 0, axis = "x"},
}
local dir = directions[key]
if not dir then return end
local moved, previous, current = false, {}, {}
for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color]
local pos = player:getGridPos()
previous[color] = pos
local context = self:movementContext(room, color)
local target = context:worldCell({x = pos.x + dir.x, y = pos.y + dir.y})
local targetEntity = context:entityAt(target)
if targetEntity then targetEntity:bump() end
local blocker = context:playerBlockerAt(target)
local canMove, pushing = true, {}
if context:isInBounds(target) and not (blocker and blocker:blocksPlayer()) then
canMove, pushing = Movement.findMovableGroup({target}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
elseif blocker then
canMove = false
end
local pulling, pullingGroup = false, {}
local behind = context:worldCell({x = pos.x - dir.x, y = pos.y - dir.y})
if context:isInBounds(behind) then
pulling, pullingGroup = Movement.findMovableGroup({behind}, dir,
function(cell) return context:entityAt(cell) end,
function(cell) return context:isInBounds(cell) end)
end
if canMove then
local group = {}
for entity in pairs(pushing) do group[entity] = entity end
if pulling then for entity in pairs(pullingGroup) do group[entity] = entity end end
self:moveGroup(group, dir)
player:move(dir.axis, dir[dir.axis])
moved = true
end
current[color] = player:getGridPos()
end
if moved then
self:markDirty(room)
self:playerJoinCheck(room, previous, current)
self:tick(room)
self:flushRuntime()
end
end
function World:switchPlayerColor(room, color)
local player = room.player[color]
if not player then return false end
local activeColors = room:getActiveColors()
local canSwitch = #activeColors ~= 1 or activeColors[1] ~= color
if canSwitch then
for _, possibleColor in ipairs(room:getPossibleColors()) do
room:setActiveColor(possibleColor, possibleColor == color)
end
end
player:playSwitchSound(canSwitch)
if canSwitch then self:markDirty(room); self:flushRuntime() end
return canSwitch
end
function World:tick(room)
room:tickLocal()
self:rebuildCollision()
self:nextRoomCheck(room)
end
function World:nextRoomCheck(room)
for _, color in ipairs(room:getActiveColors()) do
local player = room.player[color]
if player and not room:isInBounds(player:getGridPos()) then
self:nextRoom(room, player:getGridPos())
return
end
end
end
function World:nextRoom(room, pos)
local exit, entry = nil, {x = pos.x, y = pos.y}
if pos.x > gridWidth then exit, entry.x = "right", 1
elseif pos.x < 1 then exit, entry.x = "left", gridWidth
elseif pos.y > gridHeight then exit, entry.y = "bottom", 1
elseif pos.y < 1 then exit, entry.y = "top", gridHeight end
local nextName = self:getNeighbor(room.name, exit) or room.name
local activeColors = room:getActiveColors()
self:markDirty(room)
local destination = self.levels[nextName]
for _, color in ipairs(activeColors) do
local player = room.player[color]
-- Active copies travel with the player. Inactive copies are deliberately
-- left in their room so World:playerJoinCheck can merge them later.
room.player[color] = nil
for i = #room.colorsPlayerHas, 1, -1 do
if room.colorsPlayerHas[i] == color then table.remove(room.colorsPlayerHas, i) end
end
if destination.player[color] then
for i = #destination.colorsPlayerHas, 1, -1 do
if destination.colorsPlayerHas[i] == color then table.remove(destination.colorsPlayerHas, i) end
end
end
destination.player[color] = player
table.insert(destination.colorsPlayerHas, color)
end
room.activeColors = {red = false, green = false, blue = false}
currentRoom = destination
self:markDirty(currentRoom)
for _, color in ipairs(activeColors) do
currentRoom.player[color].x, currentRoom.player[color].y = entry.x, entry.y
currentRoom:setActiveColor(color, true)
end
self:playerJoinCheck(currentRoom)
self.lastRoom = currentRoom.name
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: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
2026-08-07 01:39:47 -04:00
function World:getCellFromMousePos(mouseX, mouseY)
return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1
end
function World:getRoomData(name)
-- The current room is live: its preview reflects unsaved edits immediately.
if currentRoom and currentRoom.name == name then return currentRoom:serialize() end
if not self.previewCache[name] then
local raw = readFromSource("rooms/" .. name .. ".sav")
if not raw then return nil end
local ok, room = pcall(TSerial.unpack, raw)
if not ok then return nil end
self.previewCache[name] = room
end
return self.previewCache[name]
end
function World:invalidatePreview(name)
self.previewCache[name] = nil
end
function World:drawRoomPreview(name, x, y, size)
local room = 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
2026-08-07 20:56:42 -04:00
love.graphics.setColor(1, 1, 1, 1)
2026-08-07 01:39:47 -04:00
love.graphics.print("?", x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
else
local props = globalAssetProperties[object.class]
if props and props.sprites then
2026-08-07 03:22:28 -04:00
local colors
if object.class == "door" then
colors = object.color and { object.color } or { "red", "green", "blue" }
else
colors = { object.color }
end
2026-08-07 01:39:47 -04:00
for _, color in ipairs(colors) do
local path = props.sprites[color]
if path then
love.graphics.setColor(gColor[color]:set())
local sprite = loadSprite(path)
if props.goopy and drawGoopySprite then
drawGoopySprite(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
elseif props.skewer and drawSkewerSprite then
drawSkewerSprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
2026-08-11 20:08:58 -04:00
elseif props.spiky and drawSpikySprite then
drawSpikySprite(sprite, props.cells and props.cells[color], x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, scale)
else
love.graphics.draw(sprite, x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
end
2026-08-07 01:39:47 -04:00
end
end
end
end
end
love.graphics.setScissor()
end
2026-08-11 16:24:51 -04:00
function World:drawHoveredRoomName()
local mouseX, mouseY = love.mouse.getPosition()
local cellX, cellY = self:getCellFromMousePos(mouseX, mouseY)
local name = self:isInBounds(cellX, cellY) and self:roomAt(cellX, cellY)
if not name then return end
local font = love.graphics.getFont()
local padding = 6
local tooltipW = math.min(font:getWidth(name) + padding * 2, width - 8)
local tooltipH = font:getHeight() + padding * 2
local tooltipX = math.min(mouseX + 14, width - tooltipW - 4)
local tooltipY = math.min(mouseY + 14, height - tooltipH - 4)
tooltipX = math.max(4, tooltipX)
tooltipY = math.max(4, tooltipY)
-- Keep the label anchored beside the pointer, while keeping it entirely in
-- the playable map rather than allowing it to spill into the editor panel.
love.graphics.setColor(0, 0, 0, 0.9)
love.graphics.rectangle("fill", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("line", tooltipX, tooltipY, tooltipW, tooltipH)
love.graphics.printf(name, tooltipX + padding, tooltipY + padding, tooltipW - padding * 2, "left")
end
2026-08-07 01:39:47 -04:00
function World:draw()
local cell = width / self.width
2026-08-07 20:56:42 -04:00
love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
2026-08-07 01:39:47 -04:00
love.graphics.rectangle("fill", 0, 0, width, height)
for y = 1, self.height do
for x = 1, self.width do
local screenX, screenY = (x - 1) * cell, (y - 1) * cell
local name = self:roomAt(x, y)
if name then self:drawRoomPreview(name, screenX, screenY, cell) end
2026-08-07 20:56:42 -04:00
love.graphics.setColor(name and 1 or 70 / 255, name and 1 or 70 / 255, name and 1 or 90 / 255, 1)
2026-08-07 01:39:47 -04:00
love.graphics.rectangle("line", screenX, screenY, cell, cell)
end
end
2026-08-11 16:24:51 -04:00
self:drawHoveredRoomName()
2026-08-07 01:39:47 -04:00
end