From aeb596379bbf1bec84efb294ff5bbbee922364ba Mon Sep 17 00:00:00 2001 From: cursed22bc Date: Sat, 7 Mar 2026 23:43:32 +0200 Subject: basic water render and physics --- entity.lua | 14 +++++ liquid.lua | 98 +++++++++++++++++++++++++++++++ liquidSurface.lua | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ player.lua | 53 +++++++++++++++-- tilemap.lua | 21 ++++++- world.lua | 87 +++++++++++++++++++++++++++- 6 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 liquid.lua create mode 100644 liquidSurface.lua diff --git a/entity.lua b/entity.lua index d291717..34e37f0 100644 --- a/entity.lua +++ b/entity.lua @@ -38,6 +38,20 @@ function Entity:syncFromPhysicsBody() self.y = self.body:getY() - self.height + self.physicsHeight / 2 end +function getEntityCorners(entity) + local x = entity.x + local y = entity.y + local w = entity.physicsWidth + local h = entity.physicsHeight + + return { + {x = x, y = y}, + {x = x + w, y = y}, + {x = x, y = y + h}, + {x = x + w, y = y + h} + } +end + function Entity:getPhysicsBody() return self.body end diff --git a/liquid.lua b/liquid.lua new file mode 100644 index 0000000..cdddb02 --- /dev/null +++ b/liquid.lua @@ -0,0 +1,98 @@ +local Liquid = {} +Liquid.__index = Liquid + +local function buildWorldPolygonPoints(entity) + local points = {} + + if not entity.polygon then + return points + end + + for i, point in ipairs(entity.polygon) do + points[#points+1] = { + x = entity.x + point.x, + y = entity.y + point.y + } + end + + return points +end + +function Liquid:new(source) + local self = setmetatable({}, Liquid) + self.entity = source + self.x = source.x or 0 + self.y = source.y or 0 + self.width = source.width or 0 + self.height = source.height or 0 + self.shape = source.shape + self.polygon = source.polygon or {} + self.worldPolygon = buildWorldPolygonPoints(source) + self.properties = source.properties or {} + self.type = "liquid" + self.triangles = {} + + local flat = {} + for _, p in ipairs(self.worldPolygon) do + table.insert(flat, p.x) + table.insert(flat, p.y) + end + + if #flat >= 6 then + self.triangles = love.math.triangulate(flat) + end + return self +end + +function Liquid:getWorldPolygon() + return self.worldPolygon +end + +function Liquid:containsEntityFully(entity) + local corners = getEntityCorners(entity) + + for _, c in ipairs(corners) do + if not self:containsPoint(c.x, c.y) then + return false + end + end + + return true +end + +function Liquid:containsPoint(px, py) + local points = self.worldPolygon + local count = #points + if count < 3 then return false end + + local inside = false + local j = count + for i = 1, count do + local xi, yi = points[i].x, points[i].y + local xj, yj = points[j].x, points[j].y + local intersects = ((yi > py) ~= (yj > py)) + and (px < (xj - xi) * (py - yi) / ((yj - yi) + 1e-9) + xi) + if intersects then + inside = not inside + end + j = i + end + return inside +end + +function Liquid:containsEntity(entity) + if not entity then return false end + local ex = entity.x or 0 + local ey = entity.y or 0 + local ew = entity.width or 0 + local eh = entity.height or 0 + + local sampleX = ex + ew * 0.5 + local sampleY = ey + eh + return self:containsPoint(sampleX, sampleY) +end + +function Liquid:update(dt) +end + +return Liquid \ No newline at end of file diff --git a/liquidSurface.lua b/liquidSurface.lua new file mode 100644 index 0000000..6d9b24e --- /dev/null +++ b/liquidSurface.lua @@ -0,0 +1,169 @@ +local LiquidSurface = {} +LiquidSurface.__index = LiquidSurface + +local DEFAULT_COLORS = { + water = { 0.35, 0.65, 1, 0.75 }, + lava = { 1, 0.35, 0.1, 0.8 } +} + +local function toNumber(value, fallback) + local number = tonumber(value) + if number == nil then + return fallback + end + return number +end + +local function getSurfaceBounds(entity) + local polygon = entity and entity.polygon or nil + if not polygon or #polygon == 0 then + return entity.x or 0, entity.y or 0, entity.width or 0, entity.height or 0 + end + + local minX, minY = math.huge, math.huge + local maxX, maxY = -math.huge, -math.huge + for _, point in ipairs(polygon) do + local px = (entity.x or 0) + (point.x or 0) + local py = (entity.y or 0) + (point.y or 0) + minX = math.min(minX, px) + minY = math.min(minY, py) + maxX = math.max(maxX, px) + maxY = math.max(maxY, py) + end + + return minX, minY, maxX - minX, maxY - minY +end + +function LiquidSurface:new(source) + local self = setmetatable({}, LiquidSurface) + local properties = source.properties or {} + + local x, y, width, height = getSurfaceBounds(source) + local configuredHeight = toNumber(properties.height, height) + self.x = x + self.y = y + self.width = math.max(2, width) + self.height = math.max(1, configuredHeight) + self.bottomY = self.y + self.height + + self.type = "water" + self.liquidType = properties.liquid_type or "water" + self.color = DEFAULT_COLORS[self.liquidType] or DEFAULT_COLORS.water + + self.tension = toNumber(properties.tension, 0.015) + self.dampening = toNumber(properties.dampening, 0.001) + self.spread = toNumber(properties.spread, 0.1) + self.propagationPasses = math.max(1, math.floor(toNumber(properties.passes, 8))) + self.maxSpeed = toNumber(properties.max_speed, 8) + self.maxWaveAmplitude = toNumber(properties.max_wave_amplitude, self.height * 0.4) + + self.columnsLength = math.max(2, math.floor(self.width)) + self.columns = {} + for _ = 1, self.columnsLength do + table.insert(self.columns, self:createColumn()) + end + + return self +end + +function LiquidSurface:createColumn() + return { + height = self.height, + targetHeight = self.height, + speed = 0 + } +end + +function LiquidSurface:isFixedColumn(index) + return index <= 2 or index >= self.columnsLength - 1 +end + +function LiquidSurface:updateColumn(index, dtScale) + if self:isFixedColumn(index) then return end + + local column = self.columns[index] + local heightDiff = column.targetHeight - column.height + local accel = self.tension * heightDiff - column.speed * self.dampening + + column.speed = column.speed + accel * dtScale + column.speed = math.max(-self.maxSpeed, math.min(self.maxSpeed, column.speed)) + + column.height = column.height + column.speed * dtScale + local lo = column.targetHeight - self.maxWaveAmplitude + local hi = column.targetHeight + self.maxWaveAmplitude + column.height = math.max(lo, math.min(hi, column.height)) +end + +function LiquidSurface:splash(worldX, speed) + if worldX < self.x or worldX >= (self.x + self.columnsLength) then + return + end + + local index = math.min(self.columnsLength, math.max(1, math.floor(worldX - self.x) + 1)) + if self:isFixedColumn(index) then + return + end + + self.columns[index].speed = self.columns[index].speed - (speed or 2) +end + +function LiquidSurface:isTouched(x, y, dx, dy) + return math.abs(y - self.y) < math.abs(dy) + and x >= self.x + and x < (self.x + self.columnsLength) +end + +function LiquidSurface:update(dt) + local dtScale = math.max(0, dt or 0) * 60 + + for i = 1, self.columnsLength do + self:updateColumn(i, dtScale) + end + + local leftDeltas = {} + local rightDeltas = {} + + for _ = 1, self.propagationPasses do + for i = 1, self.columnsLength do + if i > 1 and not self:isFixedColumn(i - 1) then + leftDeltas[i] = self.spread * (self.columns[i].height - self.columns[i - 1].height) * dtScale + self.columns[i - 1].speed = self.columns[i - 1].speed + leftDeltas[i] + end + if i < self.columnsLength and not self:isFixedColumn(i + 1) then + rightDeltas[i] = self.spread * (self.columns[i].height - self.columns[i + 1].height) * dtScale + self.columns[i + 1].speed = self.columns[i + 1].speed + rightDeltas[i] + end + end + + for i = 1, self.columnsLength do + if i > 1 and not self:isFixedColumn(i - 1) and leftDeltas[i] then + self.columns[i - 1].height = self.columns[i - 1].height + leftDeltas[i] + end + if i < self.columnsLength and not self:isFixedColumn(i + 1) and rightDeltas[i] then + self.columns[i + 1].height = self.columns[i + 1].height + rightDeltas[i] + end + end + end +end + +function LiquidSurface:draw() + love.graphics.setColor(self.color) + + for i = 2, self.columnsLength do + local p1x = self.x + (i - 2) + local p1y = self.bottomY - self.columns[i - 1].height + local p2x = self.x + (i - 1) + local p2y = self.bottomY - self.columns[i].height + local p3x, p3y = p2x, self.bottomY + local p4x, p4y = p1x, self.bottomY + + love.graphics.polygon("fill", + p1x, p1y, p3x, p3y, p2x, p2y, + p3x, p3y, p4x, p4y, p2x, p2y + ) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +return LiquidSurface diff --git a/player.lua b/player.lua index 7995cc6..b6bc4b5 100644 --- a/player.lua +++ b/player.lua @@ -5,7 +5,8 @@ local Player = {} Player.__index = Player setmetatable(Player, { __index = Entity }) -local MOVE_SPEED = 70 +local MOVE_SPEED = 65 +local SWIM_SPEED = 15 local JUMP_FORCE = -200 local GROUND_LAYER = "ground" @@ -57,6 +58,9 @@ function Player.new(world, spawnX, spawnY) self.vy = 0 self.grounded = false + self.isInLiquid = false + self.wasInLiquid = false + self.waterSurfaceContact = false return self end @@ -98,6 +102,7 @@ function Player:update(dt) local c = self.contact or { floor = 0, wall = 0, ceiling = 0 } local onFloor = c.floor == 1 + local onWaterSurface = self.waterSurfaceContact local prevOnFloor = self.wasOnFloor local prevState = self.state @@ -105,7 +110,35 @@ function Player:update(dt) if love.keyboard.isDown("left", "a") then move = move - 1 end if love.keyboard.isDown("right", "d") then move = move + 1 end - if onFloor then + if self.isInLiquid then + local vx, vy = self.body:getLinearVelocity() + local mass = self.body:getMass() + local gx, gy = self.body:getWorld():getGravity() + + if not self.wasInLiquid then + vx = vx * 0.4 + vy = math.min(vy, 60) + self.body:setLinearVelocity(vx, vy) + end + + self.body:setLinearDamping(6) + + local buoyancy = 0.9 + self.body:applyForce(0, -gy * mass * buoyancy) + + local moveY = 0 + if love.keyboard.isDown("up", "w") then moveY = moveY - 1 end + if love.keyboard.isDown("down", "s") then moveY = moveY + 1 end + + local inputLen = math.sqrt(move * move + moveY * moveY) + if inputLen > 1 then + move = move / inputLen + moveY = moveY / inputLen + end + + self.body:applyForce(move * SWIM_SPEED, moveY * SWIM_SPEED) + elseif onFloor or onWaterSurface then + self.body:setLinearDamping(0) self.availableJumps = 1 if move ~= 0 then self.lastFacing = move @@ -114,18 +147,23 @@ function Player:update(dt) self.body:setLinearVelocity(0, vy) end else + self.body:setLinearDamping(0) if move ~= 0 then self.lastFacing = move end self.body:setLinearVelocity(move * MOVE_SPEED * 0.7, vy) end local desiredState - if onFloor then + if self.isInLiquid then + desiredState = "swimming" + elseif onFloor then desiredState = (move ~= 0) and "running" or "idle" else desiredState = (vy < 0) and "jumping" or "falling" end - if self.state == "ground_hit" or self.state == "stop_running" then + if self.isInLiquid then + self.state = desiredState + elseif self.state == "ground_hit" or self.state == "stop_running" then if not onFloor or self.currentAnim:isFinished() then self.state = desiredState end @@ -143,12 +181,14 @@ function Player:update(dt) end self.wasOnFloor = onFloor + self.wasInLiquid = self.isInLiquid local animMap = { idle = self.animations.idle, running = self.animations.running, jumping = self.animations.going_up, falling = self.animations.going_down, + swimming = self.animations.going_down, stop_running = self.animations.stop_running, ground_hit = self.animations.ground_hit, } @@ -158,7 +198,10 @@ end function Player:jump() local c = self.contact or { floor = 0, wall = 0, ceiling = 0 } - local canJump = (c.floor == 1 or c.wall == 1) and (self.availableJumps or 0) > 0 + local onWaterSurface = self.waterSurfaceContact + local canJump = (c.floor == 1 or c.wall == 1 or onWaterSurface) and (self.availableJumps or 0) > 0 + if not canJump then return false end + if self.isInLiquid and not onWaterSurface then return false end if canJump then self.body:setLinearVelocity(self.body:getLinearVelocity(), JUMP_FORCE) self.state = "jumping" diff --git a/tilemap.lua b/tilemap.lua index ab2a52c..8ad1c9c 100644 --- a/tilemap.lua +++ b/tilemap.lua @@ -1,4 +1,6 @@ local Entity = require("entity") +local Liquid = require("liquid") +local LiquidSurface = require("liquidSurface") local Tilemap = {} Tilemap.__index = Tilemap @@ -34,8 +36,11 @@ local function objectToEntity(obj) type = obj.type, id = obj.id, rotation = obj.rotation, - visible = obj.visible + visible = obj.visible, + shape = obj.shape }) + entity.polygon = obj.polygon + entity.polyline = obj.polyline return entity end @@ -45,6 +50,8 @@ function Tilemap:new(mapPath, tilesets) self.entitiesTiles = {} self.entitiesSpawns = {} self.entitiesCameraBorders = {} + self.entitiesLiquidPolygons = {} + self.entitiesLiquidSurfaces = {} self.layerBackground = nil self.layerGround = nil self.layerForeground = nil @@ -117,6 +124,10 @@ function Tilemap:new(mapPath, tilesets) table.insert(self.entitiesSpawns, entity) elseif name == "camera_border" then table.insert(self.entitiesCameraBorders, entity) + elseif name == "liquid" then + table.insert(self.entitiesLiquidPolygons, Liquid:new(entity)) + elseif name == "liquid_surface" then + table.insert(self.entitiesLiquidSurfaces, LiquidSurface:new(entity)) end end end @@ -126,6 +137,14 @@ function Tilemap:new(mapPath, tilesets) return self end +function Tilemap:getEntitiesLiquidPolygons() + return self.entitiesLiquidPolygons +end + +function Tilemap:getEntitiesLiquidSurfaces() + return self.entitiesLiquidSurfaces +end + function Tilemap:getEntitiesTiles() return self.entitiesTiles end diff --git a/world.lua b/world.lua index 16324bb..5eaf1ce 100644 --- a/world.lua +++ b/world.lua @@ -18,8 +18,12 @@ function World:new() self.player = nil self.enemies = {} self.entities = {} + self.liquidPolygons = {} + self.liquidSurfaces = {} + self.liquidSurfaceFixtures = {} self.camera = nil self.groundContacts = {} + self.waterContacts = {} self.contactCounts = {} self.contactEntity = {} self.contactKind = {} @@ -94,6 +98,9 @@ function World:load(mapPath, tilesets) 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", "") @@ -111,6 +118,17 @@ function World:load(mapPath, tilesets) 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 + end function World:_addContact(entity, kind) @@ -166,6 +184,28 @@ function World:_onBeginContact(udA, udB, nx, ny, contact) 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 ok, x1, y1, x2, y2 = pcall(function() return contact:getPositions() end) + if ok and x1 then + centerX = (x1 + (x2 or x1)) * 0.5 + elseif entity and entity.body then + centerX = entity.body:getX() + else + centerX = waterData.water.x or 0 + end + waterData.water:splash(centerX, 2) + 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 end function World:_onEndContact(udA, udB, contact) @@ -176,6 +216,11 @@ function World:_onEndContact(udA, udB, contact) 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 @@ -223,7 +268,6 @@ local function drawTileLayer(layer, mapTileW, mapTileH, tileGidInfo, viewMinX, v minRow, maxRow = 0, h - 1 end - -- Collect tiles by image for SpriteBatch (reduces 770 draw calls to 1-2 batch draws) local batches = {} local fallbacks = {} local data = layer.data @@ -284,6 +328,27 @@ function World:draw() drawTileLayer(self.tilemap:getBackgroundLayer(), mapTileW, mapTileH, tileGidInfo, viewMinX, viewMinY, viewMaxX, viewMaxY) drawTileLayer(self.tilemap:getGroundLayer(), 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 + love.graphics.polygon("fill", tri) + end + end +end + +love.graphics.setColor(1,1,1,1) + +for _, surface in ipairs(self.liquidSurfaces) do + if surface.draw then + surface:draw() + end +end + +for _, p in ipairs(self.liquidPolygons) do + love.graphics.circle("fill", p.x, p.y, 3) +end for _, e in ipairs(self.entities) do if e.draw then e:draw() else World.drawEntityDefault(e) end end @@ -328,9 +393,29 @@ function World:update(dt) 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 + 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 + if self.camera then self.camera:setTarget(self.player) self.camera:update(dt) -- cgit v1.2.3