Add world-level movement and seam rendering

This commit is contained in:
Your Name 2026-08-11 21:26:19 -04:00
parent a60f46d585
commit 1965e548b4
3 changed files with 386 additions and 27 deletions

295
world.lua
View file

@ -1,5 +1,7 @@
World = class("World")
local Movement = require "movement"
-- 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.
@ -10,6 +12,13 @@ function World:initialize()
self.previewCache = {}
self.lastRoom = nil
self:load()
self.levels = {}
end
function World:loadLevels()
for _, placed in ipairs(self.rooms) do self.levels[placed.name] = Room:new{name = placed.name} end
self:rebuildCollision()
end
function World:listRoomNames()
@ -128,6 +137,292 @@ function World:getNeighbor(name, exit)
return self:roomAt(room.x + direction.x, room.y + direction.y)
end
-- All placed levels share these sparse matrices. Rendering still uses the
-- selected Room, whose ordinary local matrices remain unchanged.
function World:rebuildCollision()
self.collidableMatrices = {red = {}, green = {}, blue = {}}
self.playerBlockerMatrices = {red = {}, green = {}, blue = {}}
self.entityRooms = {}
local function put(matrix, cell, entity)
matrix[cell.y] = matrix[cell.y] or {}
matrix[cell.y][cell.x] = entity
end
for name, room in pairs(self.levels) do
local location = self:roomLocation(name)
if location then
for color, entities in pairs(room.collideables) do
for _, entity in pairs(entities) do
self.entityRooms[entity] = room
for _, cell in ipairs(entity:getOccupiedCells()) do
put(self.collidableMatrices[color], {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}, entity)
end
end
end
-- NPCs are local room state, but globally collidable: a movement lookup
-- must find them so its normal bump() path can open dialogue.
for _, npc in ipairs(room.npcs) do
self.entityRooms[npc] = room
for _, cell in ipairs(npc:getOccupiedCells()) do
local worldCell = {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}
for color in pairs(self.collidableMatrices) do put(self.collidableMatrices[color], worldCell, npc) end
end
end
for color, entities in pairs(room.playerBlockers) do
for _, entity in pairs(entities) do
for _, cell in ipairs(entity:getOccupiedCells()) do
put(self.playerBlockerMatrices[color], {
x = (location.x - 1) * gridWidth + cell.x,
y = (location.y - 1) * gridHeight + cell.y,
}, entity)
end
end
end
end
end
end
function World:worldCell(room, cell)
local location = self:roomLocation(room.name)
return location and {x = (location.x - 1) * gridWidth + cell.x, y = (location.y - 1) * gridHeight + cell.y}
end
function World:roomCell(cell)
local roomX = math.floor((cell.x - 1) / gridWidth) + 1
local roomY = math.floor((cell.y - 1) / gridHeight) + 1
local name = self:roomAt(roomX, roomY)
local room = name and self.levels[name]
return room, {x = cell.x - (roomX - 1) * gridWidth, y = cell.y - (roomY - 1) * gridHeight}
end
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:playerJoinCheck(room, previous, current)
self:tick(room)
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)
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()
room:save()
room.activeColors = {red = false, green = false, blue = false}
currentRoom = self.levels[nextName]
for _, color in ipairs(activeColors) do
if currentRoom.player[color] then
currentRoom.player[color].x, currentRoom.player[color].y = entry.x, entry.y
else
currentRoom:registerPlayer(Player:new(entry.x, entry.y, color), color)
end
currentRoom:setActiveColor(color, true)
end
self:playerJoinCheck(currentRoom)
self:setLastRoom(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 if room ~= currentRoom then room:save() end end
end
function World:drawOverlaps(room, color)
local location = self:roomLocation(room.name)
if not location then return end
local drawn = {}
for y = 1, gridHeight do
local row = self.collidableMatrices[color][(location.y - 1) * gridHeight + y]
if row then
for x = 1, gridWidth do
local entity = row[(location.x - 1) * gridWidth + x]
local owner = entity and self.entityRooms[entity]
if entity and owner ~= room and not drawn[entity] then
drawn[entity] = true
local ownerLocation = self:roomLocation(owner.name)
love.graphics.push()
love.graphics.translate((ownerLocation.x - location.x) * width, (ownerLocation.y - location.y) * height)
entity:draw(color)
love.graphics.pop()
end
end
end
end
end
function World:getCellFromMousePos(mouseX, mouseY)
return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1
end