summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--entity.lua78
-rw-r--r--fonts.lua1
-rw-r--r--main.lua91
-rw-r--r--tilemap.lua192
-rw-r--r--world.lua167
5 files changed, 513 insertions, 16 deletions
diff --git a/entity.lua b/entity.lua
new file mode 100644
index 0000000..3619993
--- /dev/null
+++ b/entity.lua
@@ -0,0 +1,78 @@
+local Entity = {}
+Entity.__index = Entity
+
+function Entity:new(x, y, width, height, physicsWidth, physicsHeight)
+ local self = setmetatable({}, Entity)
+ self.x = x
+ self.y = y
+ self.width = width
+ self.height = height
+ self.physicsWidth = physicsWidth or width
+ self.physicsHeight = physicsHeight or height
+ return self
+end
+
+function Entity:update(dt)
+end
+
+function Entity:draw()
+end
+
+function Entity:enablePhysics(world, bodyType)
+ bodyType = bodyType or "dynamic"
+ if not world then error("love.physics.World is required") end
+ local cx = self.x + self.physicsWidth / 2
+ local cy = self.y + self.height - self.physicsHeight / 2
+ self.body = love.physics.newBody(world, cx, cy, bodyType)
+ local shape = love.physics.newRectangleShape(self.physicsWidth, self.physicsHeight)
+ self.fixture = love.physics.newFixture(self.body, shape, 1)
+ if self.fixture.setFriction then self.fixture:setFriction(0.3) end
+ if self.fixture.setRestitution then self.fixture:setRestitution(0) end
+ if self.fixture.setUserData then self.fixture:setUserData(self) end
+ self.contact = { floor = 0, wall = 0, ceiling = 0 }
+end
+
+function Entity:syncFromPhysicsBody()
+ if not self.body then return end
+ self.x = self.body:getX() - self.physicsWidth / 2
+ self.y = self.body:getY() - self.height + self.physicsHeight / 2
+end
+
+function Entity:getPhysicsBody()
+ return self.body
+end
+
+function Entity:getPhysicsFixture()
+ return self.fixture
+end
+
+function Entity:setPropertiesFromOptions(options)
+ options = options or {}
+ self.properties = {}
+ if options.properties then
+ for k, v in pairs(options.properties) do
+ self.properties[k] = v
+ end
+ end
+
+ for k, v in pairs(options) do
+ if self[k] == nil then
+ self[k] = v
+ end
+ end
+
+ return self
+end
+
+function Entity:has(key)
+ return self.properties and self.properties[key] ~= nil
+end
+
+function Entity:get(key, default)
+ if self.properties and self.properties[key] ~= nil then
+ return self.properties[key]
+ end
+ return default
+end
+
+return Entity \ No newline at end of file
diff --git a/fonts.lua b/fonts.lua
index 905b2a3..5fa8667 100644
--- a/fonts.lua
+++ b/fonts.lua
@@ -1,5 +1,4 @@
---@diagnostic disable: undefined-global
--- Font loader for pixel-perfect 64x64 virtual screen
local M = {}
local FONT_PATH = "assets/font/font.otf"
diff --git a/main.lua b/main.lua
index 66ef333..e44d670 100644
--- a/main.lua
+++ b/main.lua
@@ -11,13 +11,19 @@ local dpiScale = 1
local canvas = nil
local smoothCameraShader = nil
---TODO: separate to components
local cameraModule = require("camera")
local camera = nil
--- end
-
local fonts = require("fonts")
+local currentState = "game"
+local currentLevel = "assets/maps/tilemap1.lua"
+local world = nil
+
+local states = {
+ game = {},
+ menu = {},
+ settings = {}
+}
local function recalcScale(w, h)
dpiScale = (love.window.getDPIScale and love.window.getDPIScale()) or 1
@@ -27,8 +33,49 @@ local function recalcScale(w, h)
offsetY = math.floor((h - VIRTUAL_HEIGHT * finalScale) / 2)
end
+function states.game.load()
+ local World = require("world")
+ world = World:new()
+ world:load(currentLevel)
+ local target = world:getPlayer() or { x = 0, y = 0, width = 16, height = 16 }
+ camera = cameraModule:new(target, VIRTUAL_WIDTH/3, VIRTUAL_HEIGHT/3, true, WORLD_TO_CANVAS)
+ world:setCamera(camera)
+end
+
+function states.game.update(dt)
+ if camera then camera:update(dt) end
+ if world then world:update(dt) end
+end
+
+function states.game.draw()
+ if camera then camera:set() end
+ if world then world:draw() end
+ if camera then camera:unset() end
+end
+
+function states.menu.load()
+ camera = nil
+ world = nil
+end
+
+function states.menu.update(dt) end
+
+function states.menu.draw()
+ love.graphics.print("Menu (state machine ready)", 10, 10)
+end
+
+function states.settings.load()
+ camera = nil
+ world = nil
+end
+
+function states.settings.update(dt) end
+
+function states.settings.draw()
+ love.graphics.print("Settings", 10, 10)
+end
+
function love.load()
- camera = cameraModule:new({x = 0, y = 0, width = VIRTUAL_WIDTH, height = VIRTUAL_HEIGHT}, VIRTUAL_WIDTH, VIRTUAL_HEIGHT, true, WORLD_TO_CANVAS)
love.graphics.setDefaultFilter("nearest", "nearest")
love.window.setTitle("Openformer")
fonts.load()
@@ -46,6 +93,8 @@ function love.load()
local w, h = love.graphics.getWidth(), love.graphics.getHeight()
recalcScale(w, h)
+ local state = states[currentState]
+ if state and state.load then state.load() end
end
function love.resize(w, h)
@@ -55,10 +104,9 @@ function love.resize(w, h)
canvas:setFilter("nearest", "nearest")
end
-
function love.update(dt)
- if not camera then return end
- camera:update(dt)
+ local state = states[currentState]
+ if state and state.update then state.update(dt) end
end
function love.keypressed(key, scancode, isrepeat)
@@ -71,20 +119,33 @@ function love.draw()
love.graphics.setCanvas(canvas)
love.graphics.clear()
love.graphics.push()
- if camera then
- camera:set()
- love.graphics.print("FPS: " .. love.timer.getFPS(), 10, 10)
+ local state = states[currentState]
+ if state and state.draw then state.draw() end
love.graphics.pop()
-
- camera:unset()
- end
-
love.graphics.setCanvas()
love.graphics.clear()
local drawX = offsetX - (CANVAS_PADDING * finalScale) / 2
local drawY = offsetY - (CANVAS_PADDING * finalScale) / 2
+
+ local subDx, subDy = 0, 0
+ if world and world.camera and world.camera.getSubPixelOffset then
+ subDx, subDy = world.camera:getSubPixelOffset()
+ end
+
+ local uvOffsetX = subDx * WORLD_TO_CANVAS / CANVAS_WIDTH
+ local uvOffsetY = subDy * WORLD_TO_CANVAS / CANVAS_HEIGHT
+
+ if smoothCameraShader and (subDx ~= 0 or subDy ~= 0) then
+ smoothCameraShader:send("offset", { uvOffsetX, uvOffsetY })
+ love.graphics.setShader(smoothCameraShader)
+ end
+
love.graphics.draw(canvas, math.floor(drawX), math.floor(drawY), 0, finalScale, finalScale)
+
+ if smoothCameraShader then
+ love.graphics.setShader()
+ end
end
-return nil \ No newline at end of file
+return nil
diff --git a/tilemap.lua b/tilemap.lua
new file mode 100644
index 0000000..64ca520
--- /dev/null
+++ b/tilemap.lua
@@ -0,0 +1,192 @@
+local Entity = require("entity")
+
+local Tilemap = {}
+Tilemap.__index = Tilemap
+
+local function loadMapData(mapPath)
+ if type(mapPath) ~= "string" then
+ return mapPath
+ end
+ if love and love.filesystem and love.filesystem.load then
+ local chunk, err = love.filesystem.load(mapPath)
+ if not chunk then
+ error("Tilemap: failed to load '" .. tostring(mapPath) .. "': " .. tostring(err))
+ end
+ return chunk()
+ end
+ local mod = mapPath:gsub("%.lua$", ""):gsub("/", ".")
+ local ok, data = pcall(require, mod)
+ if not ok then
+ error("Tilemap: failed to load '" .. tostring(mapPath) .. "': " .. tostring(data))
+ end
+ return data
+end
+
+local function objectToEntity(obj)
+ local x = obj.x or 0
+ local y = obj.y or 0
+ local w = obj.width or 0
+ local h = obj.height or 0
+ local entity = Entity:new(x, y, w, h)
+ entity:setPropertiesFromOptions({
+ properties = obj.properties or {},
+ name = obj.name,
+ type = obj.type,
+ id = obj.id,
+ rotation = obj.rotation,
+ visible = obj.visible
+ })
+ return entity
+end
+
+function Tilemap:new(map)
+ local self = setmetatable({}, Tilemap)
+
+ self.entitiesTiles = {}
+ self.entitiesSpawns = {}
+ self.entitiesCameraBorders = {}
+ self.layerBackground = nil
+ self.layerGround = nil
+ self.layerForeground = nil
+ self.tileWidth = 16
+ self.tileHeight = 16
+ self.mapWidth = 0
+ self.mapHeight = 0
+ self.tilesetImage = nil
+ self.tileQuads = {}
+
+ local mapData = loadMapData(map)
+ self.mapData = mapData
+
+ self.tileWidth = mapData and (mapData.tilewidth or 16) or 16
+ self.tileHeight = mapData and (mapData.tileheight or 16) or 16
+ self.mapWidth = (mapData and mapData.width or 0) * self.tileWidth
+ self.mapHeight = (mapData and mapData.height or 0) * self.tileHeight
+
+ if type(map) == "string" then
+ local basePath = map:gsub("%.lua$", ""):gsub("%.tmx$", "")
+ local imagePath = basePath .. ".png"
+ local ok, img = pcall(love.graphics.newImage, imagePath)
+ if ok and img then
+ self.tilesetImage = img
+ local firstGid = (mapData.tilesets and mapData.tilesets[1] and mapData.tilesets[1].firstgid) or 1
+ local imgW, imgH = img:getDimensions()
+ local tw, th = self.tileWidth, self.tileHeight
+ local cols = math.floor(imgW / tw)
+ local rows = math.floor(imgH / th)
+ for index = 0, (cols * rows) - 1 do
+ local qx = (index % cols) * tw
+ local qy = math.floor(index / cols) * th
+ self.tileQuads[firstGid + index] = love.graphics.newQuad(qx, qy, tw, th, imgW, imgH)
+ end
+ end
+ end
+
+ if mapData and mapData.layers then
+ for _, layer in ipairs(mapData.layers) do
+ if layer.type == "tilelayer" and layer.name then
+ local n = layer.name:lower()
+ if n == "background" then self.layerBackground = layer
+ elseif n == "ground" then self.layerGround = layer
+ elseif n == "foreground" then self.layerForeground = layer
+ end
+ elseif layer.type == "objectgroup" and layer.objects then
+ local name = (layer.name or ""):gsub("%s+", "_"):lower()
+ for _, obj in ipairs(layer.objects) do
+ local entity = objectToEntity(obj)
+ if name == "tiles" or name == "tile" then
+ table.insert(self.entitiesTiles, entity)
+ elseif name == "spawn" then
+ table.insert(self.entitiesSpawns, entity)
+ elseif name == "camera_border" then
+ table.insert(self.entitiesCameraBorders, entity)
+ end
+ end
+ end
+ end
+ end
+
+ return self
+end
+
+function Tilemap:getEntitiesTiles()
+ return self.entitiesTiles
+end
+
+function Tilemap:getEntitiesSpawns()
+ return self.entitiesSpawns
+end
+
+function Tilemap:getEntitiesCameraBorders()
+ return self.entitiesCameraBorders
+end
+
+-- Returns minX, maxX, minY, maxY from camera_border entities for use with Camera:setLimits.
+-- Returns nil, nil, nil, nil if there are no camera border entities.
+function Tilemap:getCameraLimits()
+ local borders = self.entitiesCameraBorders
+ if not borders or #borders == 0 then
+ return nil, nil, nil, nil
+ end
+ local minX, maxX = math.huge, -math.huge
+ local minY, maxY = math.huge, -math.huge
+ for _, e in ipairs(borders) do
+ local x, y = e.x or 0, e.y or 0
+ local w, h = e.width or 0, e.height or 0
+ minX = math.min(minX, x)
+ maxX = math.max(maxX, x + w)
+ minY = math.min(minY, y)
+ maxY = math.max(maxY, y + h)
+ end
+ return minX, maxX, minY, maxY
+end
+
+function Tilemap:getMapData()
+ return self.mapData
+end
+
+function Tilemap:getBackgroundLayer()
+ return self.layerBackground
+end
+
+function Tilemap:getGroundLayer()
+ return self.layerGround
+end
+
+function Tilemap:getForegroundLayer()
+ return self.layerForeground
+end
+
+function Tilemap:getLayers()
+ return {
+ background = self.layerBackground,
+ ground = self.layerGround,
+ foreground = self.layerForeground
+ }
+end
+
+function Tilemap:getTileWidth()
+ return self.tileWidth
+end
+
+function Tilemap:getTileHeight()
+ return self.tileHeight
+end
+
+function Tilemap:getMapWidth()
+ return self.mapWidth
+end
+
+function Tilemap:getMapHeight()
+ return self.mapHeight
+end
+
+function Tilemap:getTilesetImage()
+ return self.tilesetImage
+end
+
+function Tilemap:getTileQuads()
+ return self.tileQuads
+end
+
+return Tilemap
diff --git a/world.lua b/world.lua
new file mode 100644
index 0000000..2098040
--- /dev/null
+++ b/world.lua
@@ -0,0 +1,167 @@
+local Tilemap = require("tilemap")
+local Entity = require("entity")
+local Camera = require("camera")
+
+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.camera = nil
+ return self
+end
+
+function World:load(levelPath)
+ if self.physicsWorld then
+ self.groundEntities = {}
+ end
+ self.physicsWorld = love.physics.newWorld(0, GRAVITY)
+ self.physicsWorld:setCallbacks(nil, nil, nil, nil)
+
+ self.tilemap = Tilemap:new(levelPath)
+ self.mapData = self.tilemap:getMapData()
+ if not self.mapData then
+ error("World:load - no map data from " .. tostring(levelPath))
+ end
+
+ local tileWidth = self.tilemap:getTileWidth()
+ local tileHeight = self.tilemap:getTileHeight()
+
+ local groundLayer = self.tilemap:getGroundLayer()
+ if groundLayer and groundLayer.data then
+ local w = self.mapData.width 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)
+ end
+ end
+ end
+
+ self.player = nil
+ self.enemies = {}
+ self.entities = {}
+ 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 = Entity:new(spawn.x, spawn.y, spawn.width, spawn.height)
+ self.player:enablePhysics(self.physicsWorld, "dynamic")
+ self.player.isPlayer = true
+ table.insert(self.entities, self.player)
+ 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
+
+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
+
+local function drawTileLayer(layer, tileWidth, tileHeight, tilesetImage, tileQuads)
+ if not layer or not layer.visible or not layer.data then return end
+ local w = layer.width or 0
+ local data = layer.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
+ if tilesetImage and tileQuads and tileQuads[gid] then
+ love.graphics.draw(tilesetImage, tileQuads[gid], x, y)
+ else
+ local r = ((gid * 17) % 256) / 255
+ local g = ((gid * 31 + 50) % 256) / 255
+ local b = ((gid * 47 + 100) % 256) / 255
+ love.graphics.setColor(r, g, b, 1)
+ love.graphics.rectangle("fill", x, y, tileWidth, tileHeight)
+ love.graphics.setColor(1, 1, 1, 1)
+ end
+ end
+ end
+end
+
+function World:draw()
+ local tw = self.tilemap:getTileWidth()
+ local th = self.tilemap:getTileHeight()
+ local tilesetImage = self.tilemap:getTilesetImage()
+ local tileQuads = self.tilemap:getTileQuads()
+
+ drawTileLayer(self.tilemap:getBackgroundLayer(), tw, th, tilesetImage, tileQuads)
+
+ drawTileLayer(self.tilemap:getGroundLayer(), tw, th, tilesetImage, tileQuads)
+
+ for _, e in ipairs(self.entities) do
+ if e.draw then e:draw() else World.drawEntityDefault(e) end
+ end
+
+ drawTileLayer(self.tilemap:getForegroundLayer(), tw, th, tilesetImage, tileQuads)
+end
+
+-- TODO remove. draw method handled by each entity
+function World.drawEntityDefault(entity)
+ love.graphics.setColor(0.2, 0.6, 1, 1)
+ if entity.isPlayer then
+ love.graphics.setColor(1, 0.3, 0.2, 1)
+ end
+ love.graphics.rectangle("fill", entity.x, entity.y, entity.width, entity.height)
+ 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.camera then
+ self.camera:setTarget(self.player)
+ self.camera:update(dt)
+ end
+
+ for _, e in ipairs(self.entities) do
+ if e.body then
+ e:syncFromPhysicsBody()
+ end
+ if e.update then e:update(dt) end
+ end
+end
+
+return World