diff options
| -rw-r--r-- | entity.lua | 78 | ||||
| -rw-r--r-- | fonts.lua | 1 | ||||
| -rw-r--r-- | main.lua | 91 | ||||
| -rw-r--r-- | tilemap.lua | 192 | ||||
| -rw-r--r-- | world.lua | 167 |
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 @@ -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" @@ -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 |
