diff options
| -rw-r--r-- | animation.lua | 68 | ||||
| -rw-r--r-- | main.lua | 6 | ||||
| -rw-r--r-- | player.lua | 181 | ||||
| -rw-r--r-- | world.lua | 159 |
4 files changed, 399 insertions, 15 deletions
diff --git a/animation.lua b/animation.lua new file mode 100644 index 0000000..7d77d40 --- /dev/null +++ b/animation.lua @@ -0,0 +1,68 @@ +local Animation = {} +Animation.__index = Animation + +function Animation.new(imageOrPath, width, height, duration) + local self = setmetatable({}, Animation) + + if type(imageOrPath) == "string" then + self.spriteSheet = love.graphics.newImage(imageOrPath) + else + self.spriteSheet = imageOrPath + end + + self.frameWidth = width + self.frameHeight = height + self.quads = {} + + local imgW, imgH = self.spriteSheet:getDimensions() + for y = 0, imgH - height, height do + for x = 0, imgW - width, width do + table.insert(self.quads, love.graphics.newQuad(x, y, width, height, imgW, imgH)) + end + end + + self.duration = duration or 1 + self.currentTime = 0 + + return self +end + +function Animation:update(dt) + self.currentTime = self.currentTime + dt + if self.looping == false then + if self.currentTime > self.duration then + self.currentTime = self.duration + end + else + if self.currentTime >= self.duration then + self.currentTime = self.currentTime % self.duration + end + end +end + +function Animation:reset() + self.currentTime = 0 +end + +-- Returns true only for non-looping animations that have played through once. +function Animation:isFinished() + return self.looping == false and self.currentTime >= self.duration +end + +function Animation:draw(x, y, flipHorizontal) + if #self.quads == 0 then return end + + local t = self.currentTime / self.duration + local index = math.floor(t * #self.quads) + 1 + if index > #self.quads then index = #self.quads end + + local quad = self.quads[index] + local sx = (flipHorizontal and -1) or 1 + local sy = 1 + local ox = flipHorizontal and self.frameWidth or 0 + local oy = 0 + + love.graphics.draw(self.spriteSheet, quad, x, y, 0, sx, sy, ox, oy) +end + +return Animation @@ -1,5 +1,7 @@ local VIRTUAL_WIDTH, VIRTUAL_HEIGHT = 16*10*3, 9*10*3 local CANVAS_PADDING = 6 + +DEBUG = true local CANVAS_WIDTH = VIRTUAL_WIDTH + CANVAS_PADDING local CANVAS_HEIGHT = VIRTUAL_HEIGHT + CANVAS_PADDING local WORLD_TO_CANVAS = 3 @@ -114,6 +116,10 @@ function love.keypressed(key, scancode, isrepeat) if key == "f11" then love.window.setFullscreen(not love.window.getFullscreen(), "desktop") end + if (key == "space" or key == "up" or key == "w") and not isrepeat then + local player = world and world:getPlayer() + if player then player:jump() end + end end function love.draw() diff --git a/player.lua b/player.lua new file mode 100644 index 0000000..4e39f21 --- /dev/null +++ b/player.lua @@ -0,0 +1,181 @@ +local Entity = require("entity") +local Animation = require("animation") + +local Player = {} +Player.__index = Player +setmetatable(Player, { __index = Entity }) + +local MOVE_SPEED = 70 +local JUMP_FORCE = -200 +local GROUND_LAYER = "ground" + +function Player.new(world, spawnX, spawnY) + local w, h = 16, 16 + local pw, ph = 8, 8 -- physics body size + local self = setmetatable(Entity:new(spawnX or 0, spawnY or 0, w, h, pw, ph), Player) + + self.directionState = { 'side', 'up', 'down', 'side_up', 'side_down' } + self.directionIndex = 1 + self.direction = self.directionState[self.directionIndex] + + self.availableJumps = 0 + self:enablePhysics(world, "dynamic") + + self.animations = { + idle = Animation.new("assets/player/idle.png", 16, 16, 0.6), + running = Animation.new("assets/player/running.png", 16, 16, 1), + going_up = Animation.new("assets/player/going_up.png", 16, 16, 0.8), + going_down = Animation.new("assets/player/going_down.png", 16, 16, 0.5), + stop_running = Animation.new("assets/player/stop_running.png", 16, 16, 0.3), + ground_hit = Animation.new("assets/player/ground_hit.png", 16, 16, 0.3) + } + if love and love.filesystem and love.filesystem.getDirectoryItems then + local ok, items = pcall(love.filesystem.getDirectoryItems, "assets/player") + if ok and items then + for _, filename in ipairs(items) do + if filename:match("%.png$") then + local name = filename:gsub("%.png$", "") + if not self.animations[name] then + self.animations[name] = Animation.new("assets/player/" .. filename, 16, 16, 0.3) + end + end + end + end + end + + self.animations.ground_hit.looping = false + self.animations.stop_running.looping = false + + self.currentAnim = self.animations.idle + + self.lastFacing = 1 + + self.state = "idle" + self.wasOnFloor = false + + self.vx = 0 + self.vy = 0 + + self.grounded = false + return self +end + +function Player:trackDirectionByKeyPressed() + local left = love.keyboard.isDown("left", "a") + local right = love.keyboard.isDown("right", "d") + local up = love.keyboard.isDown("up", "w") + local down = love.keyboard.isDown("down", "s") + + if up and left then + self.directionIndex = 4 + elseif up and right then + self.directionIndex = 4 + elseif down and left then + self.directionIndex = 5 + elseif down and right then + self.directionIndex = 5 + + elseif up then + self.directionIndex = 2 + elseif down then + self.directionIndex = 3 + elseif left then + self.directionIndex = 1 + elseif right then + self.directionIndex = 1 + end + + self.direction = self.directionState[self.directionIndex] +end + +function Player:update(dt) + self:syncFromPhysicsBody() + self:trackDirectionByKeyPressed() + + local vx, vy = self.body:getLinearVelocity() + self.vx = vx + self.vy = vy + + local c = self.contact or { floor = 0, wall = 0, ceiling = 0 } + local onFloor = c.floor == 1 + local prevOnFloor = self.wasOnFloor + local prevState = self.state + + local move = 0 + 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 + self.availableJumps = 1 + if move ~= 0 then + self.lastFacing = move + self.body:setLinearVelocity(move * MOVE_SPEED, vy) + else + self.body:setLinearVelocity(0, vy) + end + else + if move ~= 0 then self.lastFacing = move end + self.body:setLinearVelocity(move * MOVE_SPEED * 0.7, vy) + end + + local desiredState + if 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 not onFloor or self.currentAnim:isFinished() then + self.state = desiredState + end + else + local wasAirborne = prevState == "jumping" or prevState == "falling" + if onFloor and not prevOnFloor and wasAirborne then + self.state = "ground_hit" + self.animations.ground_hit:reset() + elseif onFloor and prevState == "running" and move == 0 then + self.state = "stop_running" + self.animations.stop_running:reset() + else + self.state = desiredState + end + end + + self.wasOnFloor = onFloor + + local animMap = { + idle = self.animations.idle, + running = self.animations.running, + jumping = self.animations.going_up, + falling = self.animations.going_down, + stop_running = self.animations.stop_running, + ground_hit = self.animations.ground_hit, + } + self.currentAnim = animMap[self.state] or self.animations.idle + self.currentAnim:update(dt) +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 + if canJump then + self.body:setLinearVelocity(self.body:getLinearVelocity(), JUMP_FORCE) + self.state = "jumping" + self.animations.going_up:reset() + self.availableJumps = self.availableJumps - 1 + return true + end + return false +end + +function Player:draw() + local flip = (self.lastFacing < 0) + self.currentAnim:draw(self.x, self.y, flip) +end + +function Player:getBody() + return self.body +end + +return Player @@ -1,5 +1,6 @@ local Tilemap = require("tilemap") local Entity = require("entity") +local Player = require("player") local Camera = require("camera") local World = {} @@ -18,6 +19,10 @@ function World:new() self.enemies = {} self.entities = {} self.camera = nil + self.groundContacts = {} + self.contactCounts = {} + self.contactEntity = {} + self.contactKind = {} return self end @@ -26,7 +31,17 @@ function World:load(mapPath, tilesetPath) self.groundEntities = {} end self.physicsWorld = love.physics.newWorld(0, GRAVITY) - self.physicsWorld:setCallbacks(nil, nil, nil, nil) + 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, tilesetPath) self.mapData = self.tilemap:getMapData() @@ -39,18 +54,38 @@ function World:load(mapPath, tilesetPath) local groundLayer = self.tilemap:getGroundLayer() if groundLayer and groundLayer.data then - local w = self.mapData.width or 0 + local mapW = self.mapData.width or 0 + local mapH = self.mapData.height or 0 local data = groundLayer.data - for i, gid in ipairs(data) do - if gid and gid ~= 0 then - local idx = i - 1 - local tx = idx % w - local ty = math.floor(idx / w) - local x = tx * tileWidth - local y = ty * tileHeight - local tileEntity = Entity:new(x, y, tileWidth, tileHeight) - tileEntity:enablePhysics(self.physicsWorld, "static") - table.insert(self.groundEntities, tileEntity) + + 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 @@ -62,8 +97,7 @@ function World:load(mapPath, tilesetPath) for _, spawn in ipairs(spawns) do local entityType = spawn:get("entity", "") if entityType == "Player" and not self.player then - self.player = Entity:new(spawn.x, spawn.y, spawn.width, spawn.height) - self.player:enablePhysics(self.physicsWorld, "dynamic") + self.player = Player.new(self.physicsWorld, spawn.x, spawn.y) self.player.isPlayer = true table.insert(self.entities, self.player) else @@ -78,6 +112,82 @@ function World:load(mapPath, tilesetPath) 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:_onBeginContact(udA, udB, nx, ny, contact) + 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 +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 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()) @@ -134,10 +244,29 @@ function World:draw() if e.draw then e:draw() else World.drawEntityDefault(e) end end + if DEBUG then + World.drawPhysicsBodyOutlines(self.entities) + World.drawPhysicsBodyOutlines(self.groundEntities) + end + drawTileLayer(self.tilemap:getForegroundLayer(), tw, th, tilesetImage, tileQuads) end --- TODO remove. draw method handled by each entity +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 = cx - e.physicsWidth / 2 + local y = 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(entity) love.graphics.setColor(0.2, 0.6, 1, 1) if entity.isPlayer then |
