local Tilemap = require("tilemap") local Entity = require("entity") local Player = require("player") local Camera = require("camera") local Textbox = require("textbox") local Enemy = require("enemy") local World = {} World.__index = World local GRAVITY = 9.81 * 64 local PHYSICS_DT = 1 / 60 function World:new() local self = setmetatable({}, World) self.physicsWorld = nil self.tilemap = nil self.mapData = nil self.groundEntities = {} self.player = nil self.enemies = {} self.entities = {} self.liquidPolygons = {} self.liquidSurfaces = {} self.liquidSurfaceFixtures = {} self.spikes = {} self.textTriggers = {} self.refractionCanvas = nil self.liquidShader = nil self.activeSplashes = {} self.camera = nil self.groundContacts = {} self.waterContacts = {} self.contactCounts = {} self.contactEntity = {} self.contactKind = {} self.pendingPlayerDeath = nil self.playerTextbox = Textbox:new() return self end function World:load(mapPath, tilesets) if self.physicsWorld then self.groundEntities = {} end self.physicsWorld = love.physics.newWorld(0, GRAVITY) self.physicsWorld:setCallbacks( function(fa, fb, contact) local udA, udB = fa:getUserData(), fb:getUserData() local nx, ny = contact:getNormal() self:_onBeginContact(udA, udB, nx, ny, contact) end, function(fa, fb, contact) local udA, udB = fa:getUserData(), fb:getUserData() self:_onEndContact(udA, udB, contact) end ) self.tilemap = Tilemap:new(mapPath, tilesets) self.mapData = self.tilemap:getMapData() if not self.mapData then error("World:load - no map data from " .. tostring(mapPath)) end local groundDims = self.tilemap:getLayerTileDimensions("ground") local tileWidth = groundDims.tilewidth local tileHeight = groundDims.tileheight local groundLayer = self.tilemap:getGroundLayer() if groundLayer and groundLayer.data then local mapW = self.mapData.width or 0 local mapH = self.mapData.height or 0 local data = groundLayer.data local solid = {} for row = 0, mapH - 1 do solid[row] = {} for col = 0, mapW - 1 do local idx = row * mapW + col + 1 local gid = data[idx] solid[row][col] = (gid and gid ~= 0) end end for row = 0, mapH - 1 do local col = 0 while col < mapW do if solid[row][col] then local startCol = col while col < mapW and solid[row][col] do col = col + 1 end local stripWidth = (col - startCol) * tileWidth local x = startCol * tileWidth local y = row * tileHeight local strip = Entity:new(x, y, stripWidth, tileHeight) strip:enablePhysics(self.physicsWorld, "static") strip.fixture:setUserData("ground") table.insert(self.groundEntities, strip) else col = col + 1 end end end end local ok, shader = pcall(love.graphics.newShader, "shaders/missing_texture.glsl") self.missingShader = ok and shader or nil if not ok then print("Warning: missing_texture.glsl not loaded, using fallback (no missing texture)") end self.player = nil self.enemies = {} self.entities = {} self.liquidPolygons = self.tilemap:getEntitiesLiquidPolygons() or {} self.liquidSurfaces = self.tilemap:getEntitiesLiquidSurfaces() or {} self.liquidSurfaceFixtures = {} local spawns = self.tilemap:getEntitiesSpawns() for _, spawn in ipairs(spawns) do local entityType = spawn:get("entity", "") if entityType == "Player" and not self.player then self.player = Player.new(self.physicsWorld, spawn.x, spawn.y) self.player.isPlayer = true table.insert(self.entities, self.player) elseif entityType == "Enemy" then local e = Enemy.new(spawn.x, spawn.y, spawn.width, spawn.height) e:setWorldContext(self) e:setPropertiesFromOptions({ properties = spawn.properties or {}, name = spawn.name, type = spawn.type }) table.insert(self.entities, e) else local e = Entity:new(spawn.x, spawn.y, spawn.width, spawn.height) e:setPropertiesFromOptions({ properties = spawn.properties or {}, name = spawn.name, type = spawn.type }) if entityType ~= "" then table.insert(self.enemies, e) end table.insert(self.entities, e) end end for _, surface in ipairs(self.liquidSurfaces) do local cx = (surface.x or 0) + (surface.width or 0) * 0.5 local cy = (surface.y or 0) + (surface.height or 0) * 0.5 local body = love.physics.newBody(self.physicsWorld, cx, cy, "static") local shape = love.physics.newRectangleShape(surface.width or 0, surface.height or 0) local fixture = love.physics.newFixture(body, shape, 1) fixture:setSensor(true) fixture:setUserData({ type = "water", water = surface }) table.insert(self.liquidSurfaceFixtures, { body = body, fixture = fixture }) end for _, spike in ipairs(self.tilemap:getEntitiesSpikes()) do spike:setWorldPhysics(self.physicsWorld) table.insert(self.spikes, spike) table.insert(self.entities, spike) end for _, textTrigger in ipairs(self.tilemap:getEntitiesTextTriggers()) do textTrigger:setWorldPhysics(self.physicsWorld) table.insert(self.textTriggers, textTrigger) table.insert(self.entities, textTrigger) end local ok, shader = pcall(love.graphics.newShader, "shaders/liquid.glsl") self.liquidShader = ok and shader or nil if not self.liquidShader then print("Warning: liquid.glsl not loaded, using fallback (no refraction)") end end function World:_addContact(entity, kind) self.contactCounts[entity] = self.contactCounts[entity] or { floor = 0, wall = 0, ceiling = 0 } self.contactCounts[entity][kind] = (self.contactCounts[entity][kind] or 0) + 1 entity.contact = entity.contact or { floor = 0, wall = 0, ceiling = 0 } entity.contact[kind] = 1 end function World:_removeContact(entity, kind) self.contactCounts[entity] = self.contactCounts[entity] or { floor = 0, wall = 0, ceiling = 0 } self.contactCounts[entity][kind] = math.max(0, (self.contactCounts[entity][kind] or 0) - 1) entity.contact = entity.contact or { floor = 0, wall = 0, ceiling = 0 } entity.contact[kind] = (self.contactCounts[entity][kind] > 0) and 1 or 0 end function World:_normalToContactType(nx, ny) local ax, ay = math.abs(nx), math.abs(ny) if ay >= ax then if ny < -0.3 then return "floor" end if ny > 0.3 then return "ceiling" end else if ax > 0.3 then return "wall" end end return nil end function World:_isTrackedEntity(ud) return type(ud) == "table" and (ud.body ~= nil or ud.x ~= nil) end function World:_isPlayerLike(ud) return type(ud) == "table" and ud.jump ~= nil end function World:_isHazard(ud) return type(ud) == "table" and (ud.isSpike or ud.isEnemy) end function World:handleTextTrigger(trigger, player) if trigger and player and trigger.isTextTrigger and self:_isPlayerLike(player) then trigger:trigger(self, player) end end function World:_onBeginContact(udA, udB, nx, ny, contact) if type(udA) == "table" and udA.isTextTrigger then self:handleTextTrigger(udA, udB) elseif type(udB) == "table" and udB.isTextTrigger then self:handleTextTrigger(udB, udA) end if udA == "ground" and self:_isTrackedEntity(udB) then local kind = self:_normalToContactType(nx, ny) if kind then self:_addContact(udB, kind) self.contactEntity[contact] = udB self.contactKind[contact] = kind end self.groundContacts[udB] = (self.groundContacts[udB] or 0) + 1 if self:_isPlayerLike(udB) then udB.grounded = true end elseif udB == "ground" and self:_isTrackedEntity(udA) then local kind = self:_normalToContactType(-nx, -ny) if kind then self:_addContact(udA, kind) self.contactEntity[contact] = udA self.contactKind[contact] = kind end self.groundContacts[udA] = (self.groundContacts[udA] or 0) + 1 if self:_isPlayerLike(udA) then udA.grounded = true end end local function doWaterSplash(waterData, entity) if not (waterData and waterData.water and waterData.water.splash) then return end local centerX = 0 local centerY = waterData.water.y or 0 local ok, x1, y1, x2, y2 = pcall(function() return contact:getPositions() end) if ok and x1 then centerX = (x1 + (x2 or x1)) * 0.5 centerY = (y1 + (y2 or y1)) * 0.5 elseif entity and entity.body then centerX = entity.body:getX() centerY = entity.body:getY() else centerX = waterData.water.x or 0 end waterData.water:splash(centerX, 2) table.insert(self.activeSplashes, { x = centerX, y = centerY, t = 0 }) end if type(udA) == "table" and udA.type == "water" and self:_isTrackedEntity(udB) then self.waterContacts[udB] = (self.waterContacts[udB] or 0) + 1 doWaterSplash(udA, udB) elseif type(udB) == "table" and udB.type == "water" and self:_isTrackedEntity(udA) then self.waterContacts[udA] = (self.waterContacts[udA] or 0) + 1 doWaterSplash(udB, udA) end local playerEntity, hazardNx, hazardNy if self:_isHazard(udA) and self:_isPlayerLike(udB) then playerEntity = udB hazardNx, hazardNy = nx, ny elseif self:_isHazard(udB) and self:_isPlayerLike(udA) then playerEntity = udA hazardNx, hazardNy = -nx, -ny end if playerEntity and not playerEntity.isDead then self.pendingPlayerDeath = { nx = hazardNx, ny = hazardNy } end end function World:_onEndContact(udA, udB, contact) local ent = self.contactEntity[contact] local kind = ent and self.contactKind[contact] if ent and kind then self:_removeContact(ent, kind) self.contactEntity[contact] = nil self.contactKind[contact] = nil end if type(udA) == "table" and udA.type == "water" and self:_isTrackedEntity(udB) then self.waterContacts[udB] = math.max(0, (self.waterContacts[udB] or 0) - 1) elseif type(udB) == "table" and udB.type == "water" and self:_isTrackedEntity(udA) then self.waterContacts[udA] = math.max(0, (self.waterContacts[udA] or 0) - 1) end if udA == "ground" and self:_isTrackedEntity(udB) then self.groundContacts[udB] = math.max(0, (self.groundContacts[udB] or 0) - 1) if self:_isPlayerLike(udB) then udB.grounded = (self.groundContacts[udB] or 0) > 0 end elseif udB == "ground" and self:_isTrackedEntity(udA) then self.groundContacts[udA] = math.max(0, (self.groundContacts[udA] or 0) - 1) if self:_isPlayerLike(udA) then udA.grounded = (self.groundContacts[udA] or 0) > 0 end end end function World:setCamera(camera) self.camera = camera self.camera:setLimits(self.tilemap:getCameraLimits()) end function World:getPlayer() return self.player end function World:getPhysicsWorld() return self.physicsWorld end function World:getMapData() return self.mapData end function World:isTileSolidAtPixel(x, y) local groundLayer = self.tilemap:getGroundLayer() if not groundLayer or not groundLayer.data then return false end local dims = self.tilemap:getLayerTileDimensions("ground") local tw = dims.tilewidth or self.tilemap:getTileWidth() local th = dims.tileheight or self.tilemap:getTileHeight() local mapW = self.mapData.width or 0 local mapH = self.mapData.height or 0 local col = math.floor(x / tw) local row = math.floor(y / th) if col < 0 or col >= mapW or row < 0 or row >= mapH then return false end local idx = row * mapW + col + 1 local gid = groundLayer.data[idx] return gid and gid ~= 0 end local function drawTileLayer(layer, mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) if not layer or not layer.visible or not layer.data then return end local w = layer.width or 0 local h = layer.height or 0 if w == 0 or h == 0 then return end local minCol, maxCol, minRow, maxRow if viewMinX and viewMaxX and viewMinY and viewMaxY then minCol = math.max(0, math.floor(viewMinX / mapTileW)) maxCol = math.min(w - 1, math.floor(viewMaxX / mapTileW)) minRow = math.max(0, math.floor(viewMinY / mapTileH)) maxRow = math.min(h - 1, math.floor(viewMaxY / mapTileH)) else minCol, maxCol = 0, w - 1 minRow, maxRow = 0, h - 1 end local batches = {} local fallbacks = {} local data = layer.data for ty = minRow, maxRow do for tx = minCol, maxCol do local idx = ty * w + tx + 1 local gid = data[idx] if gid and gid ~= 0 then local x = tx * mapTileW local y = ty * mapTileH local info = tileGidInfo and tileGidInfo[gid] if info then local img = info.image batches[img] = batches[img] or {} table.insert(batches[img], { quad = info.quad, x = x, y = y }) else table.insert(fallbacks, { x = x, y = y, gid = gid }) end end end end for img, tiles in pairs(batches) do local batch = love.graphics.newSpriteBatch(img, #tiles, "static") for _, t in ipairs(tiles) do batch:add(t.quad, t.x, t.y) end love.graphics.draw(batch) end for _, t in ipairs(fallbacks) do local r = ((t.gid * 17) % 256) / 255 local g = ((t.gid * 31 + 50) % 256) / 255 local b = ((t.gid * 47 + 100) % 256) / 255 love.graphics.setColor(r, g, b, 1) love.graphics.rectangle("fill", t.x, t.y, mapTileW, mapTileH) love.graphics.setColor(1, 1, 1, 1) end end function World:draw() local mapTileW = self.tilemap:getTileWidth() local mapTileH = self.tilemap:getTileHeight() local tileGidInfo = self.tilemap:getTileGidInfo() local viewMinX, viewMinY, viewMaxX, viewMaxY if self.camera then local cx, cy = self.camera.x, self.camera.y local cw = self.camera.width or 0 local ch = self.camera.height or 0 local pad = math.max(mapTileW, mapTileH) * 2 viewMinX = cx - pad viewMinY = cy - pad viewMaxX = cx + cw + pad viewMaxY = cy + ch + pad end local hasLiquid = (#self.liquidPolygons > 0 or #self.liquidSurfaces > 0) local useRefraction = hasLiquid and self.liquidShader if useRefraction then local mainCanvas = love.graphics.getCanvas() local cw, ch = mainCanvas:getDimensions() if not self.refractionCanvas or self.refractionCanvas:getWidth() ~= cw or self.refractionCanvas:getHeight() ~= ch then if self.refractionCanvas then self.refractionCanvas:release() end self.refractionCanvas = love.graphics.newCanvas(cw, ch) self.refractionCanvas:setFilter("linear", "linear") end love.graphics.push() love.graphics.setCanvas(self.refractionCanvas) love.graphics.clear(0, 0, 0, 1) drawTileLayer(self.tilemap:getFargroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getDecorationBackgroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getBackgroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getGroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) for _, spike in ipairs(self.tilemap:getEntitiesSpikes()) do spike:draw() end for _, e in ipairs(self.entities) do if e.draw then e:draw() else World.drawEntityDefault(self, e) end end drawTileLayer(self.tilemap:getForegroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getDecorationForegroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) love.graphics.setCanvas(mainCanvas) love.graphics.pop() love.graphics.push() love.graphics.origin() love.graphics.draw(self.refractionCanvas, 0, 0) love.graphics.pop() local splash = self.activeSplashes[#self.activeSplashes] local splashCenterX, splashCenterY = 0.5, 0.5 local splashTime = 0 if splash and self.camera then local snapX = math.floor(self.camera.x) local snapY = math.floor(self.camera.y) local scale = self.camera._lastScale or 2 splashCenterX = ((splash.x - snapX) * scale) / cw splashCenterY = ((splash.y - snapY) * scale) / ch splashTime = splash.t end self.liquidShader:send("scene", self.refractionCanvas) self.liquidShader:send("time", love.timer.getTime()) self.liquidShader:send("resolution", { cw, ch }) self.liquidShader:send("splashCenter", { splashCenterX, splashCenterY }) self.liquidShader:send("splashTime", splashTime) love.graphics.setShader(self.liquidShader) love.graphics.setColor(0.25, 0.5, 0.9, 0.5) for _, liquid in ipairs(self.liquidPolygons) do if liquid.triangles then for _, tri in ipairs(liquid.triangles) do love.graphics.polygon("fill", tri) end end end for _, surface in ipairs(self.liquidSurfaces) do if surface.drawFill then surface:drawFill() end end love.graphics.setShader() else drawTileLayer(self.tilemap:getBackgroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getDecorationBackgroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getGroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getFargroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getDecorationBackgroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) love.graphics.setColor(0.25, 0.5, 0.9, 0.6) for _, liquid in ipairs(self.liquidPolygons) do if liquid.triangles then for _, tri in ipairs(liquid.triangles) do end end end for _, surface in ipairs(self.liquidSurfaces) do if surface.drawFill then end end end for _, surface in ipairs(self.liquidSurfaces) do if surface.drawLine then surface:drawLine() end end love.graphics.setColor(1, 1, 1, 1) if not useRefraction then for _, e in ipairs(self.entities) do print(e) if e.draw then e:draw() else World.drawEntityDefault(self, e) end end drawTileLayer(self.tilemap:getForegroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getDecorationForegroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) end if DEBUG then World.drawPhysicsBodyOutlines(self.entities) World.drawPhysicsBodyOutlines(self.groundEntities) end self.playerTextbox:draw() end function World.drawPhysicsBodyOutlines(entityList) if not entityList then return end love.graphics.setColor(0, 1, 0, 1) for _, e in ipairs(entityList) do local body = e.body if body and e.physicsWidth and e.physicsHeight then local cx, cy = body:getX(), body:getY() local x = math.floor(cx - e.physicsWidth / 2) local y = math.floor(cy - e.physicsHeight / 2) love.graphics.rectangle("line", x, y, e.physicsWidth, e.physicsHeight) end end love.graphics.setColor(1, 1, 1, 1) end function World.drawEntityDefault(self, entity) love.graphics.setColor(0.2, 0.6, 1, 1) if entity.isPlayer then love.graphics.setColor(1, 0.3, 0.2, 1) end if self.missingShader then love.graphics.setShader(self.missingShader) love.graphics.draw(entity.texture, math.floor(entity.x), math.floor(entity.y)) love.graphics.setShader() else love.graphics.rectangle("fill", math.floor(entity.x), math.floor(entity.y), entity.width, entity.height) end love.graphics.setColor(1, 1, 1, 1) end function World:update(dt) if not self.physicsWorld then return end self.physicsWorld:update(PHYSICS_DT) if self.pendingPlayerDeath and self.player then self.player:die(self.pendingPlayerDeath.nx, self.pendingPlayerDeath.ny) self.pendingPlayerDeath = nil end for _, e in ipairs(self.entities) do if e.body then e:syncFromPhysicsBody() end end if self.player then self.player.isInLiquid = false self.player.waterSurfaceContact = (self.waterContacts[self.player] or 0) > 0 for _, liquid in ipairs(self.liquidPolygons) do if liquid:containsEntity(self.player) then self.player.isInLiquid = true break end end if self.player.isInLiquid and not self.player.wasInLiquid then local px = self.player.x + (self.player.width or 16) / 2 local py = self.player.y - 20 --self.playerTextbox:show("Entered water!", { px, py, "center" }, "write", { life = 2, fontSize = 9, centeredText = true, wrapToFit = true }) end end for _, e in ipairs(self.entities) do if e.update then e:update(dt) end end for _, liquid in ipairs(self.liquidPolygons) do if liquid.update then liquid:update(dt) end end for _, surface in ipairs(self.liquidSurfaces) do if surface.update then surface:update(dt) end end for _, spike in ipairs(self.tilemap:getEntitiesSpikes()) do if spike.update then spike:update(dt) end end for i = #self.activeSplashes, 1, -1 do self.activeSplashes[i].t = self.activeSplashes[i].t + dt * 2 if self.activeSplashes[i].t >= 1 then table.remove(self.activeSplashes, i) end end if #self.activeSplashes > 4 then for _ = 1, #self.activeSplashes - 4 do table.remove(self.activeSplashes, 1) end end if self.camera then self.camera:setTarget(self.player) self.camera:update(dt) end if self.playerTextbox.active and self.player then self.playerTextbox.x = self.player.x + (self.player.width or 16) / 2 self.playerTextbox.y = self.player.y - 20 end self.playerTextbox:update(dt) end return World