diff --git a/main.lua b/main.lua index 2d0b76a..245a2e9 100644 --- a/main.lua +++ b/main.lua @@ -35,6 +35,9 @@ function love.load() _initAccessibility() gameWorld = World:new() _initLevelEditor() + -- getAllAssets runs in _initLevelEditor; deserialize the world only after + -- its asset metadata exists, otherwise every non-player object is skipped. + gameWorld:loadLevels() _initCanvases() @@ -43,10 +46,14 @@ function love.load() -- Resume the game: prefer the runtime autosave so the last saved position is -- restored, falling back to the authored source level. Editor room-opens go -- through their own (source-only) load path, so this only affects resume. - currentRoom = Room:new{ - name = gameWorld.lastRoom or "start", - runtime = true - } + local initialRoom = gameWorld.lastRoom or "start" + -- Preserve the old resume behavior for the active room while the rest of the + -- world comes from the authored level files. + currentRoom = Room:new{name = initialRoom, runtime = true} + if gameWorld.levels[initialRoom] then + gameWorld.levels[initialRoom] = currentRoom + gameWorld:rebuildCollision() + end gameWorld:setLastRoom(currentRoom.name) setEditorRoomName(currentRoom.name) @@ -69,7 +76,7 @@ function love.update(dt) Input:stopMoveRepeat() else local repeatedMove = Input:handler(dt) - if repeatedMove and not Dialogue.isOpen() then currentRoom:movePlayer(repeatedMove) end + if repeatedMove and not Dialogue.isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end end currentRoom:update(dt) @@ -164,10 +171,10 @@ function love.keypressed(key) if editorView ~= "world" and Input.isPlayerMove(key) then Input:beginMove(key) - currentRoom:movePlayer(key) + gameWorld:movePlayer(currentRoom, key) elseif editorView ~= "world" and Input.isPlayerSwitch(key) then local c = Input.playerColor(key) - currentRoom:switchPlayerColor(c) + gameWorld:switchPlayerColor(currentRoom, c) end -- if key == "w" then diff --git a/room.lua b/room.lua index d354618..fe5531f 100644 --- a/room.lua +++ b/room.lua @@ -132,6 +132,7 @@ function Room:draw() for _, channel in ipairs(self.colorsPlayerHas) 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 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 _, npc in ipairs(self.npcs) do npc:draw(channel) end @@ -181,7 +182,7 @@ function Room:createEntityMatrix(entities) for _, entity in pairs(entities) do for _, occupiedCells in pairs(entity:getOccupiedCells()) do --we store a table reference so we can call a method on it during a chec_ - matrix[occupiedCells.y][occupiedCells.x] = entity + if self:isInBounds(occupiedCells) then matrix[occupiedCells.y][occupiedCells.x] = entity end end end -- printMatrix(matrix) @@ -193,7 +194,9 @@ function Room:getCellInMatrix(matrix, cell) return matrix[cell.y][cell.x] end -function Room:movePlayer(key) +-- Kept for isolated room tests/editor previews. The live game goes through +-- World:movePlayer so no gameplay decision is room-local. +function Room:movePlayerLocal(key) local moved = false local previousPositions = {} --these are so we can perform the overlap chec_ @@ -224,23 +227,36 @@ function Room:movePlayer(key) x = pos.x + dir.x, y = pos.y + dir.y } -- this is for reference for each color as we change the targetCells table + local useWorld = gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self + local context = useWorld and gameWorld:movementContext(self, color) + local worldTarget = useWorld and context:worldCell(initialTargetCell) local initialTargetEntity - if self:isInBounds(initialTargetCell) then + if useWorld then + initialTargetEntity = context:entityAt(worldTarget) + elseif self:isInBounds(initialTargetCell) then initialTargetEntity = self:getCellInMatrix(self.collidableMatrices[color], initialTargetCell) end if initialTargetEntity then initialTargetEntity:bump() end local playerBlocker - if self:isInBounds(initialTargetCell) and self.playerBlockerMatrices and self.playerBlockerMatrices[color] then + if useWorld then + playerBlocker = context:playerBlockerAt(worldTarget) + elseif self:isInBounds(initialTargetCell) and self.playerBlockerMatrices and self.playerBlockerMatrices[color] then playerBlocker = self:getCellInMatrix(self.playerBlockerMatrices[color], initialTargetCell) end - if not self:isInBounds(initialTargetCell) then + if useWorld and not context:isInBounds(worldTarget) then + move = true + elseif not useWorld and not self:isInBounds(initialTargetCell) then -- let's us move out of bounds to Change Rooms without pulling a bloc_ move = true elseif playerBlocker and playerBlocker:blocksPlayer() then move = false + elseif useWorld then + move, entitiesToPush = Movement.findMovableGroup({worldTarget}, dir, + function(cell) return context:entityAt(cell) end, + function(cell) return context:isInBounds(cell) end) else move, entitiesToPush = Movement.findMovableGroup( {initialTargetCell}, dir, @@ -253,7 +269,11 @@ function Room:movePlayer(key) local initialTargetCell = {x = pos.x - dir.x, y = pos.y - dir.y} local pull = false - if self:isInBounds(initialTargetCell) then + if useWorld and context:isInBounds(context:worldCell(initialTargetCell)) then + pull, entitiesToPull = Movement.findMovableGroup({context:worldCell(initialTargetCell)}, dir, + function(cell) return context:entityAt(cell) end, + function(cell) return context:isInBounds(cell) end) + elseif not useWorld and self:isInBounds(initialTargetCell) then pull, entitiesToPull = Movement.findMovableGroup( {initialTargetCell}, dir, function(cell) return self:getCellInMatrix(self.collidableMatrices[color], cell) end, @@ -262,10 +282,15 @@ function Room:movePlayer(key) end if move then - for _, entity in pairs(entitiesToPush) do - entity:move(axis, dir[axis]) + if useWorld then + local group = {} + for entity in pairs(entitiesToPush) do group[entity] = entity end + if pull then for entity in pairs(entitiesToPull) do group[entity] = entity end end + gameWorld:moveGroup(group, dir) + else + for _, entity in pairs(entitiesToPush) do entity:move(axis, dir[axis]) end end - if pull == true then + if pull == true and not useWorld then for _, entity in pairs(entitiesToPull) do -- A connected component can wrap around the player and be found by -- both searches. Every entity still moves exactly one grid cell. @@ -281,12 +306,20 @@ function Room:movePlayer(key) end if moved then - self:playerJoinCheck(previousPositions, newPositions) + if useWorld then gameWorld:playerJoinCheck(self, previousPositions, newPositions) + else self:playerJoinCheck(previousPositions, newPositions) end self:tick() end end -function Room:switchPlayerColor(color) +function Room:movePlayer(key) + if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then + return gameWorld:movePlayer(self, key) + end + return self:movePlayerLocal(key) +end + +function Room:switchPlayerColorLocal(color) -- Number keys can request a colour the player has not collected yet. Do -- nothing in that case: there is no corresponding player object to switch -- to (or use as the sound source). @@ -304,6 +337,13 @@ function Room:switchPlayerColor(color) return canSwitch end +function Room:switchPlayerColor(color) + if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then + return gameWorld:switchPlayerColor(self, color) + end + return self:switchPlayerColorLocal(color) +end + function Room:setActiveColor(color, bool) self.activeColors[color] = bool end @@ -361,7 +401,7 @@ function Room:onlyColorIsActive(color) end end -function Room:tick() +function Room:tickLocal() for color, entityArray in pairs(self.collideables) do for _, entity in pairs(entityArray) do entity:tick() @@ -371,6 +411,11 @@ function Room:tick() self:createPlayerBlockerMatrix() self:createSwitchMatrix() self:resolveSwitchesAndDoors() +end + +function Room:tick() + if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then return gameWorld:tick(self) end + self:tickLocal() self:nextRoomCheck() end @@ -445,7 +490,14 @@ function Room:switchCheck() for color, switchArray in pairs(self.switches) do local onSwitches = {} for _, switch in pairs(switchArray) do - local targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos()) + local targetCell + if gameWorld and gameWorld.levels and gameWorld.levels[self.name] == self then + local cell = gameWorld:worldCell(self, switch:getGridPos()) + local row = cell and gameWorld.collidableMatrices[color][cell.y] + targetCell = row and row[cell.x] + else + targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos()) + end local player = self.player[color] -- Players are not stored in collidableMatrices, but a colour copy -- standing on its matching switch presses it just like a block does. @@ -769,6 +821,7 @@ function Room:serialize(includePlayers) end function nextRoom(pos) + if gameWorld and currentRoom then return gameWorld:nextRoom(currentRoom, pos) end local exit local newPos = {x = pos.x, y = pos.y} if pos.x > gridWidth then @@ -791,13 +844,17 @@ function nextRoom(pos) -- Missing map neighbours intentionally wrap back into the room just exited. -- A placed neighbour gets the same carried player state at its opposite edge. - currentRoom = Room:new{ - name = nextName or currentName, - runtime = true, - player = { - x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors() - } - } + local playerState = {x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors()} + currentRoom = gameWorld and gameWorld.levels[nextName or currentName] or Room:new{name = nextName or currentName, runtime = true} + if currentRoom then + currentRoom.activeColors = {red = false, green = false, blue = false} + for _, color in ipairs(playerState.activeColors) do + if currentRoom.player[color] then currentRoom.player[color].x, currentRoom.player[color].y = playerState.x, playerState.y + else currentRoom:registerPlayer(Player:new(playerState.x, playerState.y, color), color) end + currentRoom:setActiveColor(color, true) + end + gameWorld:playerJoinCheck(currentRoom) + end if gameWorld then gameWorld:setLastRoom(currentRoom.name) end if currentRoom.name then setEditorRoomName(currentRoom.name) end diff --git a/world.lua b/world.lua index 7a4abe7..8c8aeaf 100644 --- a/world.lua +++ b/world.lua @@ -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