From 11acc030e5a87b31a4a449d821275b037305ea50 Mon Sep 17 00:00:00 2001 From: Gonzalo Delgado Date: Fri, 18 Aug 2023 07:34:37 -0300 Subject: [PATCH] Add working, minimally playable prototype. --- conf.lua | 5 + inspect.lua | 337 ++++++++ main.lua | 230 ++++++ map.lua | 248 ++++++ map.tmx | 38 + sti/atlas.lua | 159 ++++ sti/graphics.lua | 132 ++++ sti/init.lua | 1688 +++++++++++++++++++++++++++++++++++++++++ sti/plugins/box2d.lua | 323 ++++++++ sti/plugins/bump.lua | 193 +++++ sti/utils.lua | 217 ++++++ 11 files changed, 3570 insertions(+) create mode 100644 conf.lua create mode 100644 inspect.lua create mode 100644 main.lua create mode 100644 map.lua create mode 100644 map.tmx create mode 100644 sti/atlas.lua create mode 100644 sti/graphics.lua create mode 100644 sti/init.lua create mode 100644 sti/plugins/box2d.lua create mode 100644 sti/plugins/bump.lua create mode 100644 sti/utils.lua diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..4f0ba3a --- /dev/null +++ b/conf.lua @@ -0,0 +1,5 @@ +function love.conf(t) + t.version = "11.3" + t.window.fullscreen = true + t.title = "Tortugger" +end diff --git a/inspect.lua b/inspect.lua new file mode 100644 index 0000000..f8d69dc --- /dev/null +++ b/inspect.lua @@ -0,0 +1,337 @@ +local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = pcall(require, 'compat53.module'); if p then _tl_compat = m end end; local math = _tl_compat and _tl_compat.math or math; local string = _tl_compat and _tl_compat.string or string; local table = _tl_compat and _tl_compat.table or table +local inspect = {Options = {}, } + + + + + + + + + + + + + + + + + +inspect._VERSION = 'inspect.lua 3.1.0' +inspect._URL = 'http://github.com/kikito/inspect.lua' +inspect._DESCRIPTION = 'human-readable representations of tables' +inspect._LICENSE = [[ + MIT LICENSE + + Copyright (c) 2022 Enrique García Cota + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +]] +inspect.KEY = setmetatable({}, { __tostring = function() return 'inspect.KEY' end }) +inspect.METATABLE = setmetatable({}, { __tostring = function() return 'inspect.METATABLE' end }) + +local tostring = tostring +local rep = string.rep +local match = string.match +local char = string.char +local gsub = string.gsub +local fmt = string.format + +local function rawpairs(t) + return next, t, nil +end + + + +local function smartQuote(str) + if match(str, '"') and not match(str, "'") then + return "'" .. str .. "'" + end + return '"' .. gsub(str, '"', '\\"') .. '"' +end + + +local shortControlCharEscapes = { + ["\a"] = "\\a", ["\b"] = "\\b", ["\f"] = "\\f", ["\n"] = "\\n", + ["\r"] = "\\r", ["\t"] = "\\t", ["\v"] = "\\v", ["\127"] = "\\127", +} +local longControlCharEscapes = { ["\127"] = "\127" } +for i = 0, 31 do + local ch = char(i) + if not shortControlCharEscapes[ch] then + shortControlCharEscapes[ch] = "\\" .. i + longControlCharEscapes[ch] = fmt("\\%03d", i) + end +end + +local function escape(str) + return (gsub(gsub(gsub(str, "\\", "\\\\"), + "(%c)%f[0-9]", longControlCharEscapes), + "%c", shortControlCharEscapes)) +end + +local function isIdentifier(str) + return type(str) == "string" and not not str:match("^[_%a][_%a%d]*$") +end + +local flr = math.floor +local function isSequenceKey(k, sequenceLength) + return type(k) == "number" and + flr(k) == k and + 1 <= (k) and + k <= sequenceLength +end + +local defaultTypeOrders = { + ['number'] = 1, ['boolean'] = 2, ['string'] = 3, ['table'] = 4, + ['function'] = 5, ['userdata'] = 6, ['thread'] = 7, +} + +local function sortKeys(a, b) + local ta, tb = type(a), type(b) + + + if ta == tb and (ta == 'string' or ta == 'number') then + return (a) < (b) + end + + local dta = defaultTypeOrders[ta] or 100 + local dtb = defaultTypeOrders[tb] or 100 + + + return dta == dtb and ta < tb or dta < dtb +end + +local function getKeys(t) + + local seqLen = 1 + while rawget(t, seqLen) ~= nil do + seqLen = seqLen + 1 + end + seqLen = seqLen - 1 + + local keys, keysLen = {}, 0 + for k in rawpairs(t) do + if not isSequenceKey(k, seqLen) then + keysLen = keysLen + 1 + keys[keysLen] = k + end + end + table.sort(keys, sortKeys) + return keys, keysLen, seqLen +end + +local function countCycles(x, cycles) + if type(x) == "table" then + if cycles[x] then + cycles[x] = cycles[x] + 1 + else + cycles[x] = 1 + for k, v in rawpairs(x) do + countCycles(k, cycles) + countCycles(v, cycles) + end + countCycles(getmetatable(x), cycles) + end + end +end + +local function makePath(path, a, b) + local newPath = {} + local len = #path + for i = 1, len do newPath[i] = path[i] end + + newPath[len + 1] = a + newPath[len + 2] = b + + return newPath +end + + +local function processRecursive(process, + item, + path, + visited) + if item == nil then return nil end + if visited[item] then return visited[item] end + + local processed = process(item, path) + if type(processed) == "table" then + local processedCopy = {} + visited[item] = processedCopy + local processedKey + + for k, v in rawpairs(processed) do + processedKey = processRecursive(process, k, makePath(path, k, inspect.KEY), visited) + if processedKey ~= nil then + processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited) + end + end + + local mt = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited) + if type(mt) ~= 'table' then mt = nil end + setmetatable(processedCopy, mt) + processed = processedCopy + end + return processed +end + +local function puts(buf, str) + buf.n = buf.n + 1 + buf[buf.n] = str +end + + + +local Inspector = {} + + + + + + + + + + +local Inspector_mt = { __index = Inspector } + +local function tabify(inspector) + puts(inspector.buf, inspector.newline .. rep(inspector.indent, inspector.level)) +end + +function Inspector:getId(v) + local id = self.ids[v] + local ids = self.ids + if not id then + local tv = type(v) + id = (ids[tv] or 0) + 1 + ids[v], ids[tv] = id, id + end + return tostring(id) +end + +function Inspector:putValue(v) + local buf = self.buf + local tv = type(v) + if tv == 'string' then + puts(buf, smartQuote(escape(v))) + elseif tv == 'number' or tv == 'boolean' or tv == 'nil' or + tv == 'cdata' or tv == 'ctype' then + puts(buf, tostring(v)) + elseif tv == 'table' and not self.ids[v] then + local t = v + + if t == inspect.KEY or t == inspect.METATABLE then + puts(buf, tostring(t)) + elseif self.level >= self.depth then + puts(buf, '{...}') + else + if self.cycles[t] > 1 then puts(buf, fmt('<%d>', self:getId(t))) end + + local keys, keysLen, seqLen = getKeys(t) + + puts(buf, '{') + self.level = self.level + 1 + + for i = 1, seqLen + keysLen do + if i > 1 then puts(buf, ',') end + if i <= seqLen then + puts(buf, ' ') + self:putValue(t[i]) + else + local k = keys[i - seqLen] + tabify(self) + if isIdentifier(k) then + puts(buf, k) + else + puts(buf, "[") + self:putValue(k) + puts(buf, "]") + end + puts(buf, ' = ') + self:putValue(t[k]) + end + end + + local mt = getmetatable(t) + if type(mt) == 'table' then + if seqLen + keysLen > 0 then puts(buf, ',') end + tabify(self) + puts(buf, ' = ') + self:putValue(mt) + end + + self.level = self.level - 1 + + if keysLen > 0 or type(mt) == 'table' then + tabify(self) + elseif seqLen > 0 then + puts(buf, ' ') + end + + puts(buf, '}') + end + + else + puts(buf, fmt('<%s %d>', tv, self:getId(v))) + end +end + + + + +function inspect.inspect(root, options) + options = options or {} + + local depth = options.depth or (math.huge) + local newline = options.newline or '\n' + local indent = options.indent or ' ' + local process = options.process + + if process then + root = processRecursive(process, root, {}, {}) + end + + local cycles = {} + countCycles(root, cycles) + + local inspector = setmetatable({ + buf = { n = 0 }, + ids = {}, + cycles = cycles, + depth = depth, + level = 0, + newline = newline, + indent = indent, + }, Inspector_mt) + + inspector:putValue(root) + + return table.concat(inspector.buf) +end + +setmetatable(inspect, { + __call = function(_, root, options) + return inspect.inspect(root, options) + end, +}) + +return inspect diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..01724bf --- /dev/null +++ b/main.lua @@ -0,0 +1,230 @@ +local sti = require("sti") +local inspect = require("inspect") + +love.graphics.setDefaultFilter("nearest", "nearest") + +local game = { + SCREEN_WIDTH=320, + SCREEN_HEIGHT=200, + TILE_SIZE=16, + -- NOTE: playfield dimensions in tiles + PLAYFIELD_OFFSET_TOP=1.5, + PLAYFIELD_WIDTH=20, + PLAYFIELD_HEIGHT=10, + VEHICLE_SPEED=10, + FLOATDEV_SPEED=8, + playFieldTiles={}, + map=sti("map.lua"), + entities={ + vehicles={}, + floatdevs={}, + }, + hazards={}, + drawGrid=true, +} + +function t2p(tileDim) + return tileDim*game.TILE_SIZE +end + +function tilePos2Pixels(tilePos) + return t2p(tilePos.x - 1), t2p(tilePos.y - 1) +end + +function screenPixelToTilePos(pixelPos) + return { + x=math.floor(pixelPos.x/game.TILE_SIZE + 1), + y=math.floor((pixelPos.y + t2p(game.PLAYFIELD_OFFSET_TOP))/game.TILE_SIZE) + } +end + + +local player = { + pos={x=t2p(0), y=t2p(0)}, + turtlePos={x=1, y=2}, + turtleHit=false, + lives=3, + turtles=3, + dead=false, + deadTimer=2, +} + +function resetPlayer() + player.pos = {x=t2p(0), y=t2p(0)} + player.dead = false +end + +function love.load() + local desktop_width, desktop_height = love.window.getDesktopDimensions() + game.scaleW = desktop_width / game.SCREEN_WIDTH + game.scaleH = desktop_height / game.SCREEN_HEIGHT + for i, layer in ipairs(game.map.layers) do + if layer.objects then + for j, mapObj in ipairs(layer.objects) do + mapObj.visible = false + local typekey = mapObj.type.."s" + if game.entities[typekey] == nil then + game.entities[typekey] = {} + end + table.insert( + game.entities[typekey], + { + x=mapObj.x, + y=mapObj.y, + w=mapObj.width, + h=mapObj.height, + type=mapObj.type, + } + ) + end + end + end +end + + +function love.keyreleased(key) + if player.dead then + return + end + local newPos = screenPixelToTilePos(player.pos) + local oldPos = screenPixelToTilePos(player.pos) + if key == "up" or key == "w" then + newPos.y = newPos.y - 1 + end + if key == "down" or key == "s" then + newPos.y = newPos.y + 1 + end + if key == "right" or key == "d" then + newPos.x = newPos.x + 1 + end + if key == "left" or key == "a" then + newPos.x = newPos.x - 1 + end + player.turtlePos = oldPos + if newPos.x < 1 then + newPos.x = 1 + end + if newPos.x > game.PLAYFIELD_WIDTH then + newPos.x = game.PLAYFIELD_WIDTH + end + if newPos.y < 1 then + newPos.y = 1 + end + if newPos.y > game.PLAYFIELD_HEIGHT then + newPos.y = game.PLAYFIELD_HEIGHT + end + player.pos.x = t2p(newPos.x - 1) + player.pos.y = t2p(newPos.y - 1) +end + +function tilePos2Rect(tilePos) + return { + x=t2p(tilePos.x - 1), + y=t2p(tilePos.y - 1), + w=game.TILE_SIZE, + h=game.TILE_SIZE, + } +end + +function rectanglesIntersect(rect1, rect2) + return (rect1.x < rect2.x + rect2.w and + rect1.x + rect1.w > rect2.x and + rect1.y < rect2.y + rect2.h and + rect1.y + rect1.h > rect2.y) +end + +function love.update(dt) + player.turtleHit = false + if player.dead then + player.deadTimer = player.deadTimer - dt + if player.deadTimer <= 0 then + player.deadTimer = 2 + resetPlayer() + end + else + local playerRect = tilePos2Rect(screenPixelToTilePos(player.pos)) + local turtleRect = tilePos2Rect(player.turtlePos) + if player.floating then + player.pos.y = player.pos.y + game.FLOATDEV_SPEED*dt + end + for i, vehicle in ipairs(game.entities.vehicles) do + if rectanglesIntersect(playerRect, vehicle) then + player.dead = true + player.lives = player.lives - 1 + end + if rectanglesIntersect(turtleRect, vehicle) then + player.turtleHit = true + end + + vehicle.y = vehicle.y + game.VEHICLE_SPEED*dt + if vehicle.y > t2p(game.PLAYFIELD_HEIGHT) then + vehicle.y = -vehicle.h + end + end + player.floating = false + for i, floatdev in ipairs(game.entities.floatdevs) do + if not player.floating and rectanglesIntersect(playerRect, floatdev) then + player.floating = true + end + floatdev.y = floatdev.y + game.FLOATDEV_SPEED*dt + if floatdev.y > t2p(game.PLAYFIELD_HEIGHT) then + floatdev.y = -floatdev.h + end + end + if not player.floating then + for i, water in ipairs(game.entities.waters) do + if not player.dead and rectanglesIntersect(playerRect, water) then + player.dead = true + player.lives = player.lives - 1 + end + end + end + end +end + +function love.draw() + love.graphics.setColor(1, 1, 1) + game.map:draw(0, t2p(game.PLAYFIELD_OFFSET_TOP), game.scaleW, game.scaleH) + love.graphics.push() + -- + love.graphics.scale(game.scaleW, game.scaleH) + love.graphics.translate(0, t2p(game.PLAYFIELD_OFFSET_TOP)) + if game.drawGrid then + love.graphics.setColor(0.4, 0.3, 0.1) + for i=0, game.PLAYFIELD_WIDTH do + love.graphics.line(t2p(i), 0, t2p(i), t2p(game.PLAYFIELD_HEIGHT)) + end + for i=0, game.PLAYFIELD_HEIGHT do + love.graphics.line(0, t2p(i), t2p(game.PLAYFIELD_WIDTH), t2p(i)) + end + end + love.graphics.setColor(0.9, 0.2, 0) + for i, vehicle in ipairs(game.entities.vehicles) do + love.graphics.rectangle("fill", vehicle.x, vehicle.y, vehicle.w, vehicle.h) + end + love.graphics.setColor(0.8, 0.7, 0) + for i, floatdev in ipairs(game.entities.floatdevs) do + love.graphics.rectangle("fill", floatdev.x, floatdev.y, floatdev.w, floatdev.h) + end + if player.dead then + love.graphics.setColor(0.8, 0.1, 0) + else + love.graphics.setColor(0.9, 0.6, 0.1) + end + -- TODO: change this to use player's stored pixel pos + local x, y = player.pos.x, player.pos.y + love.graphics.circle("fill", x + game.TILE_SIZE/2, y + game.TILE_SIZE/2, game.TILE_SIZE/2) + love.graphics.setColor(0.2, 0.1, 0.1) + love.graphics.circle("line", x + game.TILE_SIZE/2, y + game.TILE_SIZE/2, game.TILE_SIZE/2) + x, y = tilePos2Pixels(player.turtlePos) + love.graphics.setColor(0.2, 0.1, 0.1) + love.graphics.circle("line", x + game.TILE_SIZE/2, y + game.TILE_SIZE/2, game.TILE_SIZE/2) + if player.turtleHit then + love.graphics.setColor(0.8, 0.1, 0) + else + love.graphics.setColor(0, 0.7, 0.2) + end + love.graphics.circle("fill", x + game.TILE_SIZE/2, y + game.TILE_SIZE/2, game.TILE_SIZE/2) + -- + love.graphics.pop() +end diff --git a/map.lua b/map.lua new file mode 100644 index 0000000..96f6139 --- /dev/null +++ b/map.lua @@ -0,0 +1,248 @@ +return { + version = "1.5", + luaversion = "5.1", + tiledversion = "1.8.6", + orientation = "orthogonal", + renderorder = "right-down", + width = 20, + height = 10, + tilewidth = 16, + tileheight = 16, + nextlayerid = 4, + nextobjectid = 12, + properties = {}, + tilesets = { + { + name = "tiles", + firstgid = 1, + tilewidth = 16, + tileheight = 16, + spacing = 0, + margin = 0, + columns = 10, + image = "tiles.png", + imagewidth = 160, + imageheight = 16, + transparentcolor = "#ff00ff", + objectalignment = "unspecified", + tileoffset = { + x = 0, + y = 0 + }, + grid = { + orientation = "orthogonal", + width = 16, + height = 16 + }, + properties = {}, + wangsets = {}, + tilecount = 10, + tiles = {} + } + }, + layers = { + { + type = "tilelayer", + x = 0, + y = 0, + width = 20, + height = 10, + id = 1, + name = "Capa de patrones 1", + visible = true, + opacity = 1, + offsetx = 0, + offsety = 0, + parallaxx = 1, + parallaxy = 1, + properties = {}, + encoding = "lua", + data = { + 1, 2, 3, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 3, 7, 3, 4, 6, + 1, 2, 7, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 7, 7, 7, 4, 6, + 1, 2, 3, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 3, 7, 3, 4, 6, + 1, 2, 7, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 7, 7, 7, 4, 6, + 1, 2, 3, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 3, 7, 3, 4, 6, + 1, 2, 7, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 7, 7, 7, 4, 6, + 1, 2, 3, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 3, 7, 3, 4, 6, + 1, 2, 7, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 7, 7, 7, 4, 6, + 1, 2, 3, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 3, 7, 3, 4, 6, + 1, 2, 7, 4, 1, 5, 5, 5, 1, 5, 5, 5, 5, 1, 2, 7, 7, 7, 4, 6 + } + }, + { + type = "objectgroup", + draworder = "topdown", + id = 3, + name = "hazards", + visible = true, + opacity = 1, + offsetx = 0, + offsety = 0, + parallaxx = 1, + parallaxy = 1, + properties = {}, + objects = { + { + id = 4, + name = "", + type = "water", + shape = "rectangle", + x = 80, + y = -0.25, + width = 48, + height = 160, + rotation = 0, + visible = false, + properties = {} + }, + { + id = 5, + name = "", + type = "water", + shape = "rectangle", + x = 144, + y = 0, + width = 64, + height = 160, + rotation = 0, + visible = false, + properties = {} + } + } + }, + { + type = "objectgroup", + draworder = "topdown", + id = 2, + name = "entities", + visible = true, + opacity = 1, + offsetx = 0, + offsety = 0, + parallaxx = 1, + parallaxy = 1, + tintcolor = { 170, 0, 0 }, + properties = {}, + objects = { + { + id = 1, + name = "", + type = "vehicle", + shape = "rectangle", + x = 18, + y = 0, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 8, + name = "", + type = "floatdev", + shape = "rectangle", + x = 82, + y = 0, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 11, + name = "", + type = "floatdev", + shape = "rectangle", + x = 98, + y = 32, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 2, + name = "", + type = "vehicle", + shape = "rectangle", + x = 18, + y = 64, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 7, + name = "", + type = "floatdev", + shape = "rectangle", + x = 82, + y = 64, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 10, + name = "", + type = "floatdev", + shape = "rectangle", + x = 98, + y = 98, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 3, + name = "", + type = "vehicle", + shape = "rectangle", + x = 18, + y = 128, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 6, + name = "", + type = "floatdev", + shape = "rectangle", + x = 82, + y = 128, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + }, + { + id = 9, + name = "", + type = "floatdev", + shape = "rectangle", + x = 98, + y = 160, + width = 12, + height = 16, + rotation = 0, + visible = true, + properties = {} + } + } + } + } +} diff --git a/map.tmx b/map.tmx new file mode 100644 index 0000000..c5496ab --- /dev/null +++ b/map.tmx @@ -0,0 +1,38 @@ + + + + + + + + + + +1,2,3,4,1,5,5,5,1,5,5,5,5,1,2,3,7,3,4,6, +1,2,7,4,1,5,5,5,1,5,5,5,5,1,2,7,7,7,4,6, +1,2,3,4,1,5,5,5,1,5,5,5,5,1,2,3,7,3,4,6, +1,2,7,4,1,5,5,5,1,5,5,5,5,1,2,7,7,7,4,6, +1,2,3,4,1,5,5,5,1,5,5,5,5,1,2,3,7,3,4,6, +1,2,7,4,1,5,5,5,1,5,5,5,5,1,2,7,7,7,4,6, +1,2,3,4,1,5,5,5,1,5,5,5,5,1,2,3,7,3,4,6, +1,2,7,4,1,5,5,5,1,5,5,5,5,1,2,7,7,7,4,6, +1,2,3,4,1,5,5,5,1,5,5,5,5,1,2,3,7,3,4,6, +1,2,7,4,1,5,5,5,1,5,5,5,5,1,2,7,7,7,4,6 + + + + + + + + + + + + + + + + + + diff --git a/sti/atlas.lua b/sti/atlas.lua new file mode 100644 index 0000000..302b332 --- /dev/null +++ b/sti/atlas.lua @@ -0,0 +1,159 @@ +---- Texture atlas complement for the Simple Tiled Implementation +-- @copyright 2022 +-- @author Eduardo Hernández coz.eduardo.hernandez@gmail.com +-- @license MIT/X11 + +local module = {} + +--- Create a texture atlas +-- @param files Array with filenames +-- @param sort If "size" will sort by size, or if "id" will sort by id +-- @param ids Array with ids of each file +-- @param pow2 If true, will force a power of 2 size +function module.Atlas( files, sort, ids, pow2 ) + + local function Node(x, y, w, h) + return {x = x, y = y, w = w, h = h} + end + + local function nextpow2( n ) + local res = 1 + while res <= n do + res = res * 2 + end + return res + end + + local function loadImgs() + local images = {} + for i = 1, #files do + images[i] = {} + --images[i].name = files[i] + if ids then images[i].id = ids[i] end + images[i].img = love.graphics.newImage( files[i] ) + images[i].w = images[i].img:getWidth() + images[i].h = images[i].img:getHeight() + images[i].area = images[i].w * images[i].h + end + if sort == "size" or sort == "id" then + table.sort( images, function( a, b ) return ( a.area > b.area ) end ) + end + return images + end + + --TODO: understand this func + local function add(root, id, w, h) + if root.left or root.right then + if root.left then + local node = add(root.left, id, w, h) + if node then return node end + end + if root.right then + local node = add(root.right, id, w, h) + if node then return node end + end + return nil + end + + if w > root.w or h > root.h then return nil end + + local _w, _h = root.w - w, root.h - h + + if _w <= _h then + root.left = Node(root.x + w, root.y, _w, h) + root.right = Node(root.x, root.y + h, root.w, _h) + else + root.left = Node(root.x, root.y + h, w, _h) + root.right = Node(root.x + w, root.y, _w, root.h) + end + + root.w = w + root.h = h + root.id = id + + return root + end + + local function unmap(root) + if not root then return {} end + + local tree = {} + if root.id then + tree[root.id] = {} + tree[root.id].x, tree[root.id].y = root.x, root.y + end + + local left = unmap(root.left) + local right = unmap(root.right) + + for k, v in pairs(left) do + tree[k] = {} + tree[k].x, tree[k].y = v.x, v.y + end + for k, v in pairs(right) do + tree[k] = {} + tree[k].x, tree[k].y = v.x, v.y + end + + return tree + end + + local function bake() + local images = loadImgs() + + local root = {} + local w, h = images[1].w, images[1].h + + if pow2 then + if w % 1 == 0 then w = nextpow2(w) end + if h % 1 == 0 then h = nextpow2(h) end + end + + repeat + local node + + root = Node(0, 0, w, h) + + for i = 1, #images do + node = add(root, i, images[i].w, images[i].h) + if not node then break end + end + + if not node then + if h <= w then + if pow2 then h = h * 2 else h = h + 1 end + else + if pow2 then w = w * 2 else w = w + 1 end + end + else + break + end + until false + + local limits = love.graphics.getSystemLimits() + if w > limits.texturesize or h > limits.texturesize then + return "Resulting texture is too large for this system" + end + + local coords = unmap(root) + local map = love.graphics.newCanvas(w, h) + love.graphics.setCanvas( map ) +-- love.graphics.clear() + + for i = 1, #images do + love.graphics.draw(images[i].img, coords[i].x, coords[i].y) + if ids then coords[i].id = images[i].id end + end + love.graphics.setCanvas() + + if sort == "ids" then + table.sort( coords, function( a, b ) return ( a.id < b.id ) end ) + end + + return { image = map, coords = coords } + end + + return bake() +end + +return module diff --git a/sti/graphics.lua b/sti/graphics.lua new file mode 100644 index 0000000..6acf8d6 --- /dev/null +++ b/sti/graphics.lua @@ -0,0 +1,132 @@ +local lg = _G.love.graphics +local graphics = { isCreated = lg and true or false } + +function graphics.newSpriteBatch(...) + if graphics.isCreated then + return lg.newSpriteBatch(...) + end +end + +function graphics.newCanvas(...) + if graphics.isCreated then + return lg.newCanvas(...) + end +end + +function graphics.newImage(...) + if graphics.isCreated then + return lg.newImage(...) + end +end + +function graphics.newQuad(...) + if graphics.isCreated then + return lg.newQuad(...) + end +end + +function graphics.getCanvas(...) + if graphics.isCreated then + return lg.getCanvas(...) + end +end + +function graphics.setCanvas(...) + if graphics.isCreated then + return lg.setCanvas(...) + end +end + +function graphics.clear(...) + if graphics.isCreated then + return lg.clear(...) + end +end + +function graphics.push(...) + if graphics.isCreated then + return lg.push(...) + end +end + +function graphics.origin(...) + if graphics.isCreated then + return lg.origin(...) + end +end + +function graphics.scale(...) + if graphics.isCreated then + return lg.scale(...) + end +end + +function graphics.translate(...) + if graphics.isCreated then + return lg.translate(...) + end +end + +function graphics.pop(...) + if graphics.isCreated then + return lg.pop(...) + end +end + +function graphics.draw(...) + if graphics.isCreated then + return lg.draw(...) + end +end + +function graphics.rectangle(...) + if graphics.isCreated then + return lg.rectangle(...) + end +end + +function graphics.getColor(...) + if graphics.isCreated then + return lg.getColor(...) + end +end + +function graphics.setColor(...) + if graphics.isCreated then + return lg.setColor(...) + end +end + +function graphics.line(...) + if graphics.isCreated then + return lg.line(...) + end +end + +function graphics.polygon(...) + if graphics.isCreated then + return lg.polygon(...) + end +end + +function graphics.points(...) + if graphics.isCreated then + return lg.points(...) + end +end + +function graphics.getWidth() + if graphics.isCreated then + return lg.getWidth() + end + return 0 +end + +function graphics.getHeight() + if graphics.isCreated then + return lg.getHeight() + end + return 0 +end + +return graphics diff --git a/sti/init.lua b/sti/init.lua new file mode 100644 index 0000000..4ea4e42 --- /dev/null +++ b/sti/init.lua @@ -0,0 +1,1688 @@ +--- Simple and fast Tiled map loader and renderer. +-- @module sti +-- @author Landon Manning +-- @copyright 2019 +-- @license MIT/X11 + +local STI = { + _LICENSE = "MIT/X11", + _URL = "https://github.com/karai17/Simple-Tiled-Implementation", + _VERSION = "1.2.3.0", + _DESCRIPTION = "Simple Tiled Implementation is a Tiled Map Editor library designed for the *awesome* LÖVE framework.", + cache = {} +} +STI.__index = STI + +local love = _G.love +local cwd = (...):gsub('%.init$', '') .. "." +local utils = require(cwd .. "utils") +local ceil = math.ceil +local floor = math.floor +local lg = require(cwd .. "graphics") +local atlas = require(cwd .. "atlas") +local Map = {} +Map.__index = Map + +local function new(map, plugins, ox, oy) + local dir = "" + + if type(map) == "table" then + map = setmetatable(map, Map) + else + -- Check for valid map type + local ext = map:sub(-4, -1) + assert(ext == ".lua", string.format( + "Invalid file type: %s. File must be of type: lua.", + ext + )) + + -- Get directory of map + dir = map:reverse():find("[/\\]") or "" + if dir ~= "" then + dir = map:sub(1, 1 + (#map - dir)) + end + + -- Load map + map = setmetatable(assert(love.filesystem.load(map))(), Map) + end + + map:init(dir, plugins, ox, oy) + + return map +end + +--- Instance a new map. +-- @param map Path to the map file or the map table itself +-- @param plugins A list of plugins to load +-- @param ox Offset of map on the X axis (in pixels) +-- @param oy Offset of map on the Y axis (in pixels) +-- @return table The loaded Map +function STI.__call(_, map, plugins, ox, oy) + return new(map, plugins, ox, oy) +end + +--- Flush image cache. +function STI:flush() + self.cache = {} +end + +--- Map object + +--- Instance a new map +-- @param path Path to the map file +-- @param plugins A list of plugins to load +-- @param ox Offset of map on the X axis (in pixels) +-- @param oy Offset of map on the Y axis (in pixels) +function Map:init(path, plugins, ox, oy) + if type(plugins) == "table" then + self:loadPlugins(plugins) + end + + self:resize() + self.objects = {} + self.tiles = {} + self.tileInstances = {} + self.offsetx = ox or 0 + self.offsety = oy or 0 + + self.freeBatchSprites = {} + setmetatable(self.freeBatchSprites, { __mode = 'k' }) + + -- Set tiles, images + local gid = 1 + for i, tileset in ipairs(self.tilesets) do + assert(not tileset.filename, "STI does not support external Tilesets.\nYou need to embed all Tilesets.") + + if tileset.image then + -- Cache images + if lg.isCreated then + local formatted_path = utils.format_path(path .. tileset.image) + + if not STI.cache[formatted_path] then + utils.fix_transparent_color(tileset, formatted_path) + utils.cache_image(STI, formatted_path, tileset.image) + else + tileset.image = STI.cache[formatted_path] + end + end + + gid = self:setTiles(i, tileset, gid) + elseif tileset.tilecount > 0 then + -- Build atlas for image collection + local files, ids = {}, {} + for j = 1, #tileset.tiles do + files[ j ] = utils.format_path(path .. tileset.tiles[j].image) + ids[ j ] = tileset.tiles[j].id + end + + local map = atlas.Atlas( files, "ids", ids ) + + if lg.isCreated then + local formatted_path = utils.format_path(path .. tileset.name) + + if not STI.cache[formatted_path] then + -- No need to fix transparency color for collections + utils.cache_image(STI, formatted_path, map.image) + tileset.image = map.image + else + tileset.image = STI.cache[formatted_path] + end + end + + gid = self:setAtlasTiles(i, tileset, map.coords, gid) + end + end + + local layers = {} + for _, layer in ipairs(self.layers) do + self:groupAppendToList(layers, layer) + end + self.layers = layers + + -- Set layers + for _, layer in ipairs(self.layers) do + self:setLayer(layer, path) + end +end + +--- Layers from the group are added to the list +-- @param layers List of layers +-- @param layer Layer data +function Map:groupAppendToList(layers, layer) + if layer.type == "group" then + for _, groupLayer in pairs(layer.layers) do + groupLayer.name = layer.name .. "." .. groupLayer.name + groupLayer.visible = layer.visible + groupLayer.opacity = layer.opacity * groupLayer.opacity + groupLayer.offsetx = layer.offsetx + groupLayer.offsetx + groupLayer.offsety = layer.offsety + groupLayer.offsety + + for key, property in pairs(layer.properties) do + if groupLayer.properties[key] == nil then + groupLayer.properties[key] = property + end + end + + self:groupAppendToList(layers, groupLayer) + end + else + table.insert(layers, layer) + end +end + +--- Load plugins +-- @param plugins A list of plugins to load +function Map:loadPlugins(plugins) + for _, plugin in ipairs(plugins) do + local pluginModulePath = cwd .. 'plugins.' .. plugin + local ok, pluginModule = pcall(require, pluginModulePath) + if ok then + for k, func in pairs(pluginModule) do + if not self[k] then + self[k] = func + end + end + end + end +end + +--- Create Tiles based on a single tileset image +-- @param index Index of the Tileset +-- @param tileset Tileset data +-- @param gid First Global ID in Tileset +-- @return number Next Tileset's first Global ID +function Map:setTiles(index, tileset, gid) + local quad = lg.newQuad + local imageW = tileset.imagewidth + local imageH = tileset.imageheight + local tileW = tileset.tilewidth + local tileH = tileset.tileheight + local margin = tileset.margin + local spacing = tileset.spacing + local w = utils.get_tiles(imageW, tileW, margin, spacing) + local h = utils.get_tiles(imageH, tileH, margin, spacing) + + for y = 1, h do + for x = 1, w do + local id = gid - tileset.firstgid + local quadX = (x - 1) * tileW + margin + (x - 1) * spacing + local quadY = (y - 1) * tileH + margin + (y - 1) * spacing + local type = "" + local properties, terrain, animation, objectGroup + + for _, tile in pairs(tileset.tiles) do + if tile.id == id then + properties = tile.properties + animation = tile.animation + objectGroup = tile.objectGroup + type = tile.type + + if tile.terrain then + terrain = {} + + for i = 1, #tile.terrain do + terrain[i] = tileset.terrains[tile.terrain[i] + 1] + end + end + end + end + + local tile = { + id = id, + gid = gid, + tileset = index, + type = type, + quad = quad( + quadX, quadY, + tileW, tileH, + imageW, imageH + ), + properties = properties or {}, + terrain = terrain, + animation = animation, + objectGroup = objectGroup, + frame = 1, + time = 0, + width = tileW, + height = tileH, + sx = 1, + sy = 1, + r = 0, + offset = tileset.tileoffset, + } + + self.tiles[gid] = tile + gid = gid + 1 + end + end + + return gid +end + +--- Create Tiles based on a texture atlas +-- @param index Index of the Tileset +-- @param tileset Tileset data +-- @param coords Tile XY location in the atlas +-- @param gid First Global ID in Tileset +-- @return number Next Tileset's first Global ID +function Map:setAtlasTiles(index, tileset, coords, gid) + local quad = lg.newQuad + local imageW = tileset.image:getWidth() + local imageH = tileset.image:getHeight() + + local firstgid = tileset.firstgid + for i = 1, #tileset.tiles do + local tile = tileset.tiles[i] + if tile.terrain then + terrain = {} + + for j = 1, #tile.terrain do + terrain[j] = tileset.terrains[tile.terrain[j] + 1] + end + end + + local tile = { + id = tile.id, + gid = firstgid + tile.id, + tileset = index, + class = tile.class, + quad = quad( + coords[i].x, coords[i].y, + tile.width, tile.height, + imageW, imageH + ), + properties = tile.properties or {}, + terrain = terrain, + animation = tile.animation, + objectGroup = tile.objectGroup, + frame = 1, + time = 0, + width = tile.width, + height = tile.height, + sx = 1, + sy = 1, + r = 0, + offset = tileset.tileoffset, + } + + -- Be aware that in collections self.tiles can be a sparse array + self.tiles[tile.gid] = tile + end + + return gid + #tileset.tiles +end + +--- Create Layers +-- @param layer Layer data +-- @param path (Optional) Path to an Image Layer's image +function Map:setLayer(layer, path) + if layer.encoding then + if layer.encoding == "base64" then + assert(require "ffi", "Compressed maps require LuaJIT FFI.\nPlease Switch your interperator to LuaJIT or your Tile Layer Format to \"CSV\".") + local fd = love.data.decode("string", "base64", layer.data) + + if not layer.compression then + layer.data = utils.get_decompressed_data(fd) + else + assert(love.data.decompress, "zlib and gzip compression require LOVE 11.0+.\nPlease set your Tile Layer Format to \"Base64 (uncompressed)\" or \"CSV\".") + + if layer.compression == "zlib" then + local data = love.data.decompress("string", "zlib", fd) + layer.data = utils.get_decompressed_data(data) + end + + if layer.compression == "gzip" then + local data = love.data.decompress("string", "gzip", fd) + layer.data = utils.get_decompressed_data(data) + end + end + end + end + + layer.x = (layer.x or 0) + layer.offsetx + self.offsetx + layer.y = (layer.y or 0) + layer.offsety + self.offsety + layer.update = function() end + + if layer.type == "tilelayer" then + self:setTileData(layer) + self:setSpriteBatches(layer) + layer.draw = function() self:drawTileLayer(layer) end + elseif layer.type == "objectgroup" then + self:setObjectData(layer) + self:setObjectCoordinates(layer) + self:setObjectSpriteBatches(layer) + layer.draw = function() self:drawObjectLayer(layer) end + elseif layer.type == "imagelayer" then + layer.draw = function() self:drawImageLayer(layer) end + + if layer.image ~= "" then + local formatted_path = utils.format_path(path .. layer.image) + if not STI.cache[formatted_path] then + utils.cache_image(STI, formatted_path) + end + + layer.image = STI.cache[formatted_path] + layer.width = layer.image:getWidth() + layer.height = layer.image:getHeight() + end + end + + self.layers[layer.name] = layer +end + +--- Add Tiles to Tile Layer +-- @param layer The Tile Layer +function Map:setTileData(layer) + if layer.chunks then + for _, chunk in ipairs(layer.chunks) do + self:setTileData(chunk) + end + return + end + + local i = 1 + local map = {} + + for y = 1, layer.height do + map[y] = {} + for x = 1, layer.width do + local gid = layer.data[i] + + -- NOTE: Empty tiles have a GID of 0 + if gid > 0 then + map[y][x] = self.tiles[gid] or self:setFlippedGID(gid) + end + + i = i + 1 + end + end + + layer.data = map +end + +--- Add Objects to Layer +-- @param layer The Object Layer +function Map:setObjectData(layer) + for _, object in ipairs(layer.objects) do + object.layer = layer + self.objects[object.id] = object + end +end + +--- Correct position and orientation of Objects in an Object Layer +-- @param layer The Object Layer +function Map:setObjectCoordinates(layer) + for _, object in ipairs(layer.objects) do + local x = layer.x + object.x + local y = layer.y + object.y + local w = object.width + local h = object.height + local cos = math.cos(math.rad(object.rotation)) + local sin = math.sin(math.rad(object.rotation)) + + if object.shape == "rectangle" and not object.gid then + object.rectangle = {} + + local vertices = { + { x=x, y=y }, + { x=x + w, y=y }, + { x=x + w, y=y + h }, + { x=x, y=y + h }, + } + + for _, vertex in ipairs(vertices) do + vertex.x, vertex.y = utils.rotate_vertex(self, vertex, x, y, cos, sin) + table.insert(object.rectangle, { x = vertex.x, y = vertex.y }) + end + elseif object.shape == "ellipse" then + object.ellipse = {} + local vertices = utils.convert_ellipse_to_polygon(x, y, w, h) + + for _, vertex in ipairs(vertices) do + vertex.x, vertex.y = utils.rotate_vertex(self, vertex, x, y, cos, sin) + table.insert(object.ellipse, { x = vertex.x, y = vertex.y }) + end + elseif object.shape == "polygon" then + for _, vertex in ipairs(object.polygon) do + vertex.x = vertex.x + x + vertex.y = vertex.y + y + vertex.x, vertex.y = utils.rotate_vertex(self, vertex, x, y, cos, sin) + end + elseif object.shape == "polyline" then + for _, vertex in ipairs(object.polyline) do + vertex.x = vertex.x + x + vertex.y = vertex.y + y + vertex.x, vertex.y = utils.rotate_vertex(self, vertex, x, y, cos, sin) + end + end + end +end + +--- Convert tile location to tile instance location +-- @param layer Tile layer +-- @param tile Tile +-- @param x Tile location on X axis (in tiles) +-- @param y Tile location on Y axis (in tiles) +-- @return number Tile instance location on X axis (in pixels) +-- @return number Tile instance location on Y axis (in pixels) +function Map:getLayerTilePosition(layer, tile, x, y) + local tileW = self.tilewidth + local tileH = self.tileheight + local tileX, tileY + + if self.orientation == "orthogonal" then + tileX = (x - 1) * tileW + tile.offset.x + tileY = (y - 0) * tileH + tile.offset.y - tile.height + tileX, tileY = utils.compensate(tile, tileX, tileY, tileW, tileH) + elseif self.orientation == "isometric" then + tileX = (x - y) * (tileW / 2) + tile.offset.x + layer.width * tileW / 2 - self.tilewidth / 2 + tileY = (x + y - 2) * (tileH / 2) + tile.offset.y + else + local sideLen = self.hexsidelength or 0 + if self.staggeraxis == "y" then + if self.staggerindex == "odd" then + if y % 2 == 0 then + tileX = (x - 1) * tileW + tileW / 2 + tile.offset.x + else + tileX = (x - 1) * tileW + tile.offset.x + end + else + if y % 2 == 0 then + tileX = (x - 1) * tileW + tile.offset.x + else + tileX = (x - 1) * tileW + tileW / 2 + tile.offset.x + end + end + + local rowH = tileH - (tileH - sideLen) / 2 + tileY = (y - 1) * rowH + tile.offset.y + else + if self.staggerindex == "odd" then + if x % 2 == 0 then + tileY = (y - 1) * tileH + tileH / 2 + tile.offset.y + else + tileY = (y - 1) * tileH + tile.offset.y + end + else + if x % 2 == 0 then + tileY = (y - 1) * tileH + tile.offset.y + else + tileY = (y - 1) * tileH + tileH / 2 + tile.offset.y + end + end + + local colW = tileW - (tileW - sideLen) / 2 + tileX = (x - 1) * colW + tile.offset.x + end + end + + return tileX, tileY +end + +--- Place new tile instance +-- @param layer Tile layer +-- @param chunk Layer chunk +-- @param tile Tile +-- @param number Tile location on X axis (in tiles) +-- @param number Tile location on Y axis (in tiles) +function Map:addNewLayerTile(layer, chunk, tile, x, y) + local tileset = tile.tileset + local image = self.tilesets[tile.tileset].image + local batches + local size + + if chunk then + batches = chunk.batches + size = chunk.width * chunk.height + else + batches = layer.batches + size = layer.width * layer.height + end + + batches[tileset] = batches[tileset] or lg.newSpriteBatch(image, size) + + local batch = batches[tileset] + local tileX, tileY = self:getLayerTilePosition(layer, tile, x, y) + + local instance = { + layer = layer, + chunk = chunk, + gid = tile.gid, + x = tileX, + y = tileY, + r = tile.r, + oy = 0 + } + + -- NOTE: STI can run headless so it is not guaranteed that a batch exists. + if batch then + instance.batch = batch + instance.id = batch:add(tile.quad, tileX, tileY, tile.r, tile.sx, tile.sy) + end + + self.tileInstances[tile.gid] = self.tileInstances[tile.gid] or {} + table.insert(self.tileInstances[tile.gid], instance) +end + +function Map:set_batches(layer, chunk) + if chunk then + chunk.batches = {} + else + layer.batches = {} + end + + if self.orientation == "orthogonal" or self.orientation == "isometric" then + local offsetX = chunk and chunk.x or 0 + local offsetY = chunk and chunk.y or 0 + + local startX = 1 + local startY = 1 + local endX = chunk and chunk.width or layer.width + local endY = chunk and chunk.height or layer.height + local incrementX = 1 + local incrementY = 1 + + -- Determine order to add tiles to sprite batch + -- Defaults to right-down + if self.renderorder == "right-up" then + startY, endY, incrementY = endY, startY, -1 + elseif self.renderorder == "left-down" then + startX, endX, incrementX = endX, startX, -1 + elseif self.renderorder == "left-up" then + startX, endX, incrementX = endX, startX, -1 + startY, endY, incrementY = endY, startY, -1 + end + + for y = startY, endY, incrementY do + for x = startX, endX, incrementX do + -- NOTE: Cannot short circuit this since it is valid for tile to be assigned nil + local tile + if chunk then + tile = chunk.data[y][x] + else + tile = layer.data[y][x] + end + + if tile then + self:addNewLayerTile(layer, chunk, tile, x + offsetX, y + offsetY) + end + end + end + else + if self.staggeraxis == "y" then + for y = 1, (chunk and chunk.height or layer.height) do + for x = 1, (chunk and chunk.width or layer.width) do + -- NOTE: Cannot short circuit this since it is valid for tile to be assigned nil + local tile + if chunk then + tile = chunk.data[y][x] + else + tile = layer.data[y][x] + end + + if tile then + self:addNewLayerTile(layer, chunk, tile, x, y) + end + end + end + else + local i = 0 + local _x + + if self.staggerindex == "odd" then + _x = 1 + else + _x = 2 + end + + while i < (chunk and chunk.width * chunk.height or layer.width * layer.height) do + for _y = 1, (chunk and chunk.height or layer.height) + 0.5, 0.5 do + local y = floor(_y) + + for x = _x, (chunk and chunk.width or layer.width), 2 do + i = i + 1 + + -- NOTE: Cannot short circuit this since it is valid for tile to be assigned nil + local tile + if chunk then + tile = chunk.data[y][x] + else + tile = layer.data[y][x] + end + + if tile then + self:addNewLayerTile(layer, chunk, tile, x, y) + end + end + + if _x == 1 then + _x = 2 + else + _x = 1 + end + end + end + end + end +end + +--- Batch Tiles in Tile Layer for improved draw speed +-- @param layer The Tile Layer +function Map:setSpriteBatches(layer) + if layer.chunks then + for _, chunk in ipairs(layer.chunks) do + self:set_batches(layer, chunk) + end + return + end + + self:set_batches(layer) +end + +--- Batch Tiles in Object Layer for improved draw speed +-- @param layer The Object Layer +function Map:setObjectSpriteBatches(layer) + local newBatch = lg.newSpriteBatch + local batches = {} + + if layer.draworder == "topdown" then + table.sort(layer.objects, function(a, b) + return a.y + a.height < b.y + b.height + end) + end + + for _, object in ipairs(layer.objects) do + if object.gid then + local tile = self.tiles[object.gid] or self:setFlippedGID(object.gid) + local tileset = tile.tileset + local image = self.tilesets[tileset].image + + batches[tileset] = batches[tileset] or newBatch(image) + + local sx = object.width / tile.width + local sy = object.height / tile.height + + -- Tiled rotates around bottom left corner, where love2D rotates around top left corner + local ox = 0 + local oy = tile.height + + local batch = batches[tileset] + local tileX = object.x + tile.offset.x + local tileY = object.y + tile.offset.y + local tileR = math.rad(object.rotation) + + -- Compensation for scale/rotation shift + if tile.sx == -1 then + tileX = tileX + object.width + + if tileR ~= 0 then + tileX = tileX - object.width + ox = ox + tile.width + end + end + + if tile.sy == -1 then + tileY = tileY - object.height + + if tileR ~= 0 then + tileY = tileY + object.width + oy = oy - tile.width + end + end + + local instance = { + id = batch:add(tile.quad, tileX, tileY, tileR, tile.sx * sx, tile.sy * sy, ox, oy), + batch = batch, + layer = layer, + gid = tile.gid, + x = tileX, + y = tileY - oy, + r = tileR, + oy = oy + } + + self.tileInstances[tile.gid] = self.tileInstances[tile.gid] or {} + table.insert(self.tileInstances[tile.gid], instance) + end + end + + layer.batches = batches +end + +--- Create a Custom Layer to place userdata in (such as player sprites) +-- @param name Name of Custom Layer +-- @param index Draw order within Layer stack +-- @return table Custom Layer +function Map:addCustomLayer(name, index) + index = index or #self.layers + 1 + local layer = { + type = "customlayer", + name = name, + visible = true, + opacity = 1, + properties = {}, + } + + function layer.draw() end + function layer.update() end + + table.insert(self.layers, index, layer) + self.layers[name] = self.layers[index] + + return layer +end + +--- Convert another Layer into a Custom Layer +-- @param index Index or name of Layer to convert +-- @return table Custom Layer +function Map:convertToCustomLayer(index) + local layer = assert(self.layers[index], "Layer not found: " .. index) + + layer.type = "customlayer" + layer.x = nil + layer.y = nil + layer.width = nil + layer.height = nil + layer.encoding = nil + layer.data = nil + layer.chunks = nil + layer.objects = nil + layer.image = nil + + function layer.draw() end + function layer.update() end + + return layer +end + +--- Remove a Layer from the Layer stack +-- @param index Index or name of Layer to remove +function Map:removeLayer(index) + local layer = assert(self.layers[index], "Layer not found: " .. index) + + if type(index) == "string" then + for i, l in ipairs(self.layers) do + if l.name == index then + table.remove(self.layers, i) + self.layers[index] = nil + break + end + end + else + local name = self.layers[index].name + table.remove(self.layers, index) + self.layers[name] = nil + end + + -- Remove layer batches + if layer.batches then + for _, batch in pairs(layer.batches) do + self.freeBatchSprites[batch] = nil + end + end + + -- Remove chunk batches + if layer.chunks then + for _, chunk in ipairs(layer.chunks) do + for _, batch in pairs(chunk.batches) do + self.freeBatchSprites[batch] = nil + end + end + end + + -- Remove tile instances + if layer.type == "tilelayer" then + for _, tiles in pairs(self.tileInstances) do + for i = #tiles, 1, -1 do + local tile = tiles[i] + if tile.layer == layer then + table.remove(tiles, i) + end + end + end + end + + -- Remove objects + if layer.objects then + for i, object in pairs(self.objects) do + if object.layer == layer then + self.objects[i] = nil + end + end + end +end + +--- Animate Tiles and update every Layer +-- @param dt Delta Time +function Map:update(dt) + for _, tile in pairs(self.tiles) do + local update = false + + if tile.animation then + tile.time = tile.time + dt * 1000 + + while tile.time > tonumber(tile.animation[tile.frame].duration) do + update = true + tile.time = tile.time - tonumber(tile.animation[tile.frame].duration) + tile.frame = tile.frame + 1 + + if tile.frame > #tile.animation then tile.frame = 1 end + end + + if update and self.tileInstances[tile.gid] then + for _, j in pairs(self.tileInstances[tile.gid]) do + local t = self.tiles[tonumber(tile.animation[tile.frame].tileid) + self.tilesets[tile.tileset].firstgid] + j.batch:set(j.id, t.quad, j.x, j.y, j.r, tile.sx, tile.sy, 0, j.oy) + end + end + end + end + + for _, layer in ipairs(self.layers) do + layer:update(dt) + end +end + +--- Draw every Layer +-- @param tx Translate on X +-- @param ty Translate on Y +-- @param sx Scale on X +-- @param sy Scale on Y +function Map:draw(tx, ty, sx, sy) + local current_canvas = lg.getCanvas() + lg.setCanvas(self.canvas) + lg.clear() + + -- Scale map to 1.0 to draw onto canvas, this fixes tearing issues + -- Map is translated to correct position so the right section is drawn + lg.push() + lg.origin() + lg.translate(math.floor(tx or 0), math.floor(ty or 0)) + + for _, layer in ipairs(self.layers) do + if layer.visible and layer.opacity > 0 then + self:drawLayer(layer) + end + end + + lg.pop() + + -- Draw canvas at 0,0; this fixes scissoring issues + -- Map is scaled to correct scale so the right section is shown + lg.push() + lg.origin() + lg.scale(sx or 1, sy or sx or 1) + + lg.setCanvas(current_canvas) + lg.draw(self.canvas) + + lg.pop() +end + +--- Draw an individual Layer +-- @param layer The Layer to draw +function Map.drawLayer(_, layer) + local r,g,b,a = lg.getColor() + lg.setColor(r, g, b, a * layer.opacity) + layer:draw() + lg.setColor(r,g,b,a) +end + +--- Default draw function for Tile Layers +-- @param layer The Tile Layer to draw +function Map:drawTileLayer(layer) + if type(layer) == "string" or type(layer) == "number" then + layer = self.layers[layer] + end + + assert(layer.type == "tilelayer", "Invalid layer type: " .. layer.type .. ". Layer must be of type: tilelayer") + + -- NOTE: This does not take into account any sort of draw range clipping and will always draw every chunk + if layer.chunks then + for _, chunk in ipairs(layer.chunks) do + for _, batch in pairs(chunk.batches) do + lg.draw(batch, 0, 0) + end + end + + return + end + + for _, batch in pairs(layer.batches) do + lg.draw(batch, floor(layer.x), floor(layer.y)) + end +end + +--- Default draw function for Object Layers +-- @param layer The Object Layer to draw +function Map:drawObjectLayer(layer) + if type(layer) == "string" or type(layer) == "number" then + layer = self.layers[layer] + end + + assert(layer.type == "objectgroup", "Invalid layer type: " .. layer.type .. ". Layer must be of type: objectgroup") + + local line = { 160, 160, 160, 255 * layer.opacity } + local fill = { 160, 160, 160, 255 * layer.opacity * 0.5 } + local r,g,b,a = lg.getColor() + local reset = { r, g, b, a * layer.opacity } + + local function sortVertices(obj) + local vertex = {} + + for _, v in ipairs(obj) do + table.insert(vertex, v.x) + table.insert(vertex, v.y) + end + + return vertex + end + + local function drawShape(obj, shape) + local vertex = sortVertices(obj) + + if shape == "polyline" then + lg.setColor(line) + lg.line(vertex) + return + elseif shape == "polygon" then + lg.setColor(fill) + if not love.math.isConvex(vertex) then + local triangles = love.math.triangulate(vertex) + for _, triangle in ipairs(triangles) do + lg.polygon("fill", triangle) + end + else + lg.polygon("fill", vertex) + end + else + lg.setColor(fill) + lg.polygon("fill", vertex) + end + + lg.setColor(line) + lg.polygon("line", vertex) + end + + for _, object in ipairs(layer.objects) do + if object.visible then + if object.shape == "rectangle" and not object.gid then + drawShape(object.rectangle, "rectangle") + elseif object.shape == "ellipse" then + drawShape(object.ellipse, "ellipse") + elseif object.shape == "polygon" then + drawShape(object.polygon, "polygon") + elseif object.shape == "polyline" then + drawShape(object.polyline, "polyline") + elseif object.shape == "point" then + lg.points(object.x, object.y) + end + end + end + + lg.setColor(reset) + for _, batch in pairs(layer.batches) do + lg.draw(batch, 0, 0) + end + lg.setColor(r,g,b,a) +end + +--- Default draw function for Image Layers +-- @param layer The Image Layer to draw +function Map:drawImageLayer(layer) + if type(layer) == "string" or type(layer) == "number" then + layer = self.layers[layer] + end + + assert(layer.type == "imagelayer", "Invalid layer type: " .. layer.type .. ". Layer must be of type: imagelayer") + + if layer.image ~= "" then + lg.draw(layer.image, layer.x, layer.y) + end +end + +--- Resize the drawable area of the Map +-- @param w The new width of the drawable area (in pixels) +-- @param h The new Height of the drawable area (in pixels) +function Map:resize(w, h) + if lg.isCreated then + w = w or lg.getWidth() + h = h or lg.getHeight() + + self.canvas = lg.newCanvas(w, h) + self.canvas:setFilter("nearest", "nearest") + end +end + +--- Create flipped or rotated Tiles based on bitop flags +-- @param gid The flagged Global ID +-- @return table Flipped Tile +function Map:setFlippedGID(gid) + local bit31 = 2147483648 + local bit30 = 1073741824 + local bit29 = 536870912 + local flipX = false + local flipY = false + local flipD = false + local realgid = gid + + if realgid >= bit31 then + realgid = realgid - bit31 + flipX = not flipX + end + + if realgid >= bit30 then + realgid = realgid - bit30 + flipY = not flipY + end + + if realgid >= bit29 then + realgid = realgid - bit29 + flipD = not flipD + end + + local tile = self.tiles[realgid] + local data = { + id = tile.id, + gid = gid, + tileset = tile.tileset, + frame = tile.frame, + time = tile.time, + width = tile.width, + height = tile.height, + offset = tile.offset, + quad = tile.quad, + properties = tile.properties, + terrain = tile.terrain, + animation = tile.animation, + sx = tile.sx, + sy = tile.sy, + r = tile.r, + } + + if flipX then + if flipY and flipD then + data.r = math.rad(-90) + data.sy = -1 + elseif flipY then + data.sx = -1 + data.sy = -1 + elseif flipD then + data.r = math.rad(90) + else + data.sx = -1 + end + elseif flipY then + if flipD then + data.r = math.rad(-90) + else + data.sy = -1 + end + elseif flipD then + data.r = math.rad(90) + data.sy = -1 + end + + self.tiles[gid] = data + + return self.tiles[gid] +end + +--- Get custom properties from Layer +-- @param layer The Layer +-- @return table List of properties +function Map:getLayerProperties(layer) + local l = self.layers[layer] + + if not l then + return {} + end + + return l.properties +end + +--- Get custom properties from Tile +-- @param layer The Layer that the Tile belongs to +-- @param x The X axis location of the Tile (in tiles) +-- @param y The Y axis location of the Tile (in tiles) +-- @return table List of properties +function Map:getTileProperties(layer, x, y) + local tile = self.layers[layer].data[y][x] + + if not tile then + return {} + end + + return tile.properties +end + +--- Get custom properties from Object +-- @param layer The Layer that the Object belongs to +-- @param object The index or name of the Object +-- @return table List of properties +function Map:getObjectProperties(layer, object) + local o = self.layers[layer].objects + + if type(object) == "number" then + o = o[object] + else + for _, v in ipairs(o) do + if v.name == object then + o = v + break + end + end + end + + if not o then + return {} + end + + return o.properties +end + +--- Change a tile in a layer to another tile +-- @param layer The Layer that the Tile belongs to +-- @param x The X axis location of the Tile (in tiles) +-- @param y The Y axis location of the Tile (in tiles) +-- @param gid The gid of the new tile +function Map:setLayerTile(layer, x, y, gid) + layer = self.layers[layer] + + layer.data[y] = layer.data[y] or {} + local tile = layer.data[y][x] + local instance + if tile then + local tileX, tileY = self:getLayerTilePosition(layer, tile, x, y) + for _, inst in pairs(self.tileInstances[tile.gid]) do + if inst.x == tileX and inst.y == tileY then + instance = inst + break + end + end + end + + if tile == self.tiles[gid] then + return + end + + tile = self.tiles[gid] + + if instance then + self:swapTile(instance, tile) + else + self:addNewLayerTile(layer, tile, x, y) + end + layer.data[y][x] = tile +end + +--- Swap a tile in a spritebatch +-- @param instance The current Instance object we want to replace +-- @param tile The Tile object we want to use +-- @return none +function Map:swapTile(instance, tile) + -- Update sprite batch + if instance.batch then + if tile then + instance.batch:set( + instance.id, + tile.quad, + instance.x, + instance.y, + tile.r, + tile.sx, + tile.sy + ) + else + instance.batch:set( + instance.id, + instance.x, + instance.y, + 0, + 0) + + self.freeBatchSprites[instance.batch] = self.freeBatchSprites[instance.batch] or {} + table.insert(self.freeBatchSprites[instance.batch], instance) + end + end + + -- Remove old tile instance + for i, ins in ipairs(self.tileInstances[instance.gid]) do + if ins.batch == instance.batch and ins.id == instance.id then + table.remove(self.tileInstances[instance.gid], i) + break + end + end + + -- Add new tile instance + if tile then + self.tileInstances[tile.gid] = self.tileInstances[tile.gid] or {} + + local freeBatchSprites = self.freeBatchSprites[instance.batch] + local newInstance + if freeBatchSprites and #freeBatchSprites > 0 then + newInstance = freeBatchSprites[#freeBatchSprites] + freeBatchSprites[#freeBatchSprites] = nil + else + newInstance = {} + end + + newInstance.layer = instance.layer + newInstance.batch = instance.batch + newInstance.id = instance.id + newInstance.gid = tile.gid or 0 + newInstance.x = instance.x + newInstance.y = instance.y + newInstance.r = tile.r or 0 + newInstance.oy = tile.r ~= 0 and tile.height or 0 + table.insert(self.tileInstances[tile.gid], newInstance) + end +end + +--- Convert tile location to pixel location +-- @param x The X axis location of the point (in tiles) +-- @param y The Y axis location of the point (in tiles) +-- @return number The X axis location of the point (in pixels) +-- @return number The Y axis location of the point (in pixels) +function Map:convertTileToPixel(x,y) + if self.orientation == "orthogonal" then + local tileW = self.tilewidth + local tileH = self.tileheight + return + x * tileW, + y * tileH + elseif self.orientation == "isometric" then + local mapH = self.height + local tileW = self.tilewidth + local tileH = self.tileheight + local offsetX = mapH * tileW / 2 + return + (x - y) * tileW / 2 + offsetX, + (x + y) * tileH / 2 + elseif self.orientation == "staggered" or + self.orientation == "hexagonal" then + local tileW = self.tilewidth + local tileH = self.tileheight + local sideLen = self.hexsidelength or 0 + + if self.staggeraxis == "x" then + return + x * tileW, + ceil(y) * (tileH + sideLen) + (ceil(y) % 2 == 0 and tileH or 0) + else + return + ceil(x) * (tileW + sideLen) + (ceil(x) % 2 == 0 and tileW or 0), + y * tileH + end + end +end + +--- Convert pixel location to tile location +-- @param x The X axis location of the point (in pixels) +-- @param y The Y axis location of the point (in pixels) +-- @return number The X axis location of the point (in tiles) +-- @return number The Y axis location of the point (in tiles) +function Map:convertPixelToTile(x, y) + if self.orientation == "orthogonal" then + local tileW = self.tilewidth + local tileH = self.tileheight + return + x / tileW, + y / tileH + elseif self.orientation == "isometric" then + local mapH = self.height + local tileW = self.tilewidth + local tileH = self.tileheight + local offsetX = mapH * tileW / 2 + return + y / tileH + (x - offsetX) / tileW, + y / tileH - (x - offsetX) / tileW + elseif self.orientation == "staggered" then + local staggerX = self.staggeraxis == "x" + local even = self.staggerindex == "even" + + local function topLeft(x, y) + if staggerX then + if ceil(x) % 2 == 1 and even then + return x - 1, y + else + return x - 1, y - 1 + end + else + if ceil(y) % 2 == 1 and even then + return x, y - 1 + else + return x - 1, y - 1 + end + end + end + + local function topRight(x, y) + if staggerX then + if ceil(x) % 2 == 1 and even then + return x + 1, y + else + return x + 1, y - 1 + end + else + if ceil(y) % 2 == 1 and even then + return x + 1, y - 1 + else + return x, y - 1 + end + end + end + + local function bottomLeft(x, y) + if staggerX then + if ceil(x) % 2 == 1 and even then + return x - 1, y + 1 + else + return x - 1, y + end + else + if ceil(y) % 2 == 1 and even then + return x, y + 1 + else + return x - 1, y + 1 + end + end + end + + local function bottomRight(x, y) + if staggerX then + if ceil(x) % 2 == 1 and even then + return x + 1, y + 1 + else + return x + 1, y + end + else + if ceil(y) % 2 == 1 and even then + return x + 1, y + 1 + else + return x, y + 1 + end + end + end + + local tileW = self.tilewidth + local tileH = self.tileheight + + if staggerX then + x = x - (even and tileW / 2 or 0) + else + y = y - (even and tileH / 2 or 0) + end + + local halfH = tileH / 2 + local ratio = tileH / tileW + local referenceX = ceil(x / tileW) + local referenceY = ceil(y / tileH) + local relativeX = x - referenceX * tileW + local relativeY = y - referenceY * tileH + + if (halfH - relativeX * ratio > relativeY) then + return topLeft(referenceX, referenceY) + elseif (-halfH + relativeX * ratio > relativeY) then + return topRight(referenceX, referenceY) + elseif (halfH + relativeX * ratio < relativeY) then + return bottomLeft(referenceX, referenceY) + elseif (halfH * 3 - relativeX * ratio < relativeY) then + return bottomRight(referenceX, referenceY) + end + + return referenceX, referenceY + elseif self.orientation == "hexagonal" then + local staggerX = self.staggeraxis == "x" + local even = self.staggerindex == "even" + local tileW = self.tilewidth + local tileH = self.tileheight + local sideLenX = 0 + local sideLenY = 0 + + local colW = tileW / 2 + local rowH = tileH / 2 + if staggerX then + sideLenX = self.hexsidelength + x = x - (even and tileW or (tileW - sideLenX) / 2) + colW = colW - (colW - sideLenX / 2) / 2 + else + sideLenY = self.hexsidelength + y = y - (even and tileH or (tileH - sideLenY) / 2) + rowH = rowH - (rowH - sideLenY / 2) / 2 + end + + local referenceX = ceil(x) / (colW * 2) + local referenceY = ceil(y) / (rowH * 2) + + -- If in staggered line, then shift reference by 0.5 of other axes + if staggerX then + if (floor(referenceX) % 2 == 0) == even then + referenceY = referenceY - 0.5 + end + else + if (floor(referenceY) % 2 == 0) == even then + referenceX = referenceX - 0.5 + end + end + + local relativeX = x - referenceX * colW * 2 + local relativeY = y - referenceY * rowH * 2 + local centers + + if staggerX then + local left = sideLenX / 2 + local centerX = left + colW + local centerY = tileH / 2 + + centers = { + { x = left, y = centerY }, + { x = centerX, y = centerY - rowH }, + { x = centerX, y = centerY + rowH }, + { x = centerX + colW, y = centerY }, + } + else + local top = sideLenY / 2 + local centerX = tileW / 2 + local centerY = top + rowH + + centers = { + { x = centerX, y = top }, + { x = centerX - colW, y = centerY }, + { x = centerX + colW, y = centerY }, + { x = centerX, y = centerY + rowH } + } + end + + local nearest = 0 + local minDist = math.huge + + local function len2(ax, ay) + return ax * ax + ay * ay + end + + for i = 1, 4 do + local dc = len2(centers[i].x - relativeX, centers[i].y - relativeY) + + if dc < minDist then + minDist = dc + nearest = i + end + end + + local offsetsStaggerX = { + { x = 1, y = 1 }, + { x = 2, y = 0 }, + { x = 2, y = 1 }, + { x = 3, y = 1 }, + } + + local offsetsStaggerY = { + { x = 1, y = 1 }, + { x = 0, y = 2 }, + { x = 1, y = 2 }, + { x = 1, y = 3 }, + } + + local offsets = staggerX and offsetsStaggerX or offsetsStaggerY + + return + referenceX + offsets[nearest].x, + referenceY + offsets[nearest].y + end +end + +--- A list of individual layers indexed both by draw order and name +-- @table Map.layers +-- @see TileLayer +-- @see ObjectLayer +-- @see ImageLayer +-- @see CustomLayer + +--- A list of individual tiles indexed by Global ID +-- @table Map.tiles +-- @see Tile +-- @see Map.tileInstances + +--- A list of tile instances indexed by Global ID +-- @table Map.tileInstances +-- @see TileInstance +-- @see Tile +-- @see Map.tiles + +--- A list of no-longer-used batch sprites, indexed by batch +--@table Map.freeBatchSprites + +--- A list of individual objects indexed by Global ID +-- @table Map.objects +-- @see Object + +--- @table TileLayer +-- @field name The name of the layer +-- @field x Position on the X axis (in pixels) +-- @field y Position on the Y axis (in pixels) +-- @field width Width of layer (in tiles) +-- @field height Height of layer (in tiles) +-- @field visible Toggle if layer is visible or hidden +-- @field opacity Opacity of layer +-- @field properties Custom properties +-- @field data A tileWo dimensional table filled with individual tiles indexed by [y][x] (in tiles) +-- @field update Update function +-- @field draw Draw function +-- @see Map.layers +-- @see Tile + +--- @table ObjectLayer +-- @field name The name of the layer +-- @field x Position on the X axis (in pixels) +-- @field y Position on the Y axis (in pixels) +-- @field visible Toggle if layer is visible or hidden +-- @field opacity Opacity of layer +-- @field properties Custom properties +-- @field objects List of objects indexed by draw order +-- @field update Update function +-- @field draw Draw function +-- @see Map.layers +-- @see Object + +--- @table ImageLayer +-- @field name The name of the layer +-- @field x Position on the X axis (in pixels) +-- @field y Position on the Y axis (in pixels) +-- @field visible Toggle if layer is visible or hidden +-- @field opacity Opacity of layer +-- @field properties Custom properties +-- @field image Image to be drawn +-- @field update Update function +-- @field draw Draw function +-- @see Map.layers + +--- Custom Layers are used to place userdata such as sprites within the draw order of the map. +-- @table CustomLayer +-- @field name The name of the layer +-- @field x Position on the X axis (in pixels) +-- @field y Position on the Y axis (in pixels) +-- @field visible Toggle if layer is visible or hidden +-- @field opacity Opacity of layer +-- @field properties Custom properties +-- @field update Update function +-- @field draw Draw function +-- @see Map.layers +-- @usage +-- -- Create a Custom Layer +-- local spriteLayer = map:addCustomLayer("Sprite Layer", 3) +-- +-- -- Add data to Custom Layer +-- spriteLayer.sprites = { +-- player = { +-- image = lg.newImage("assets/sprites/player.png"), +-- x = 64, +-- y = 64, +-- r = 0, +-- } +-- } +-- +-- -- Update callback for Custom Layer +-- function spriteLayer:update(dt) +-- for _, sprite in pairs(self.sprites) do +-- sprite.r = sprite.r + math.rad(90 * dt) +-- end +-- end +-- +-- -- Draw callback for Custom Layer +-- function spriteLayer:draw() +-- for _, sprite in pairs(self.sprites) do +-- local x = math.floor(sprite.x) +-- local y = math.floor(sprite.y) +-- local r = sprite.r +-- lg.draw(sprite.image, x, y, r) +-- end +-- end + +--- @table Tile +-- @field id Local ID within Tileset +-- @field gid Global ID +-- @field tileset Tileset ID +-- @field quad Quad object +-- @field properties Custom properties +-- @field terrain Terrain data +-- @field animation Animation data +-- @field frame Current animation frame +-- @field time Time spent on current animation frame +-- @field width Width of tile +-- @field height Height of tile +-- @field sx Scale value on the X axis +-- @field sy Scale value on the Y axis +-- @field r Rotation of tile (in radians) +-- @field offset Offset drawing position +-- @field offset.x Offset value on the X axis +-- @field offset.y Offset value on the Y axis +-- @see Map.tiles + +--- @table TileInstance +-- @field batch Spritebatch the Tile Instance belongs to +-- @field id ID within the spritebatch +-- @field gid Global ID +-- @field x Position on the X axis (in pixels) +-- @field y Position on the Y axis (in pixels) +-- @see Map.tileInstances +-- @see Tile + +--- @table Object +-- @field id Global ID +-- @field name Name of object (non-unique) +-- @field shape Shape of object +-- @field x Position of object on X axis (in pixels) +-- @field y Position of object on Y axis (in pixels) +-- @field width Width of object (in pixels) +-- @field height Heigh tof object (in pixels) +-- @field rotation Rotation of object (in radians) +-- @field visible Toggle if object is visible or hidden +-- @field properties Custom properties +-- @field ellipse List of verticies of specific shape +-- @field rectangle List of verticies of specific shape +-- @field polygon List of verticies of specific shape +-- @field polyline List of verticies of specific shape +-- @see Map.objects + +return setmetatable({}, STI) diff --git a/sti/plugins/box2d.lua b/sti/plugins/box2d.lua new file mode 100644 index 0000000..c6d4148 --- /dev/null +++ b/sti/plugins/box2d.lua @@ -0,0 +1,323 @@ +--- Box2D plugin for STI +-- @module box2d +-- @author Landon Manning +-- @copyright 2019 +-- @license MIT/X11 + +local love = _G.love +local utils = require((...):gsub('plugins.box2d', 'utils')) +local lg = require((...):gsub('plugins.box2d', 'graphics')) + +return { + box2d_LICENSE = "MIT/X11", + box2d_URL = "https://github.com/karai17/Simple-Tiled-Implementation", + box2d_VERSION = "2.3.2.7", + box2d_DESCRIPTION = "Box2D hooks for STI.", + + --- Initialize Box2D physics world. + -- @param world The Box2D world to add objects to. + box2d_init = function(map, world) + assert(love.physics, "To use the Box2D plugin, please enable the love.physics module.") + + local body = love.physics.newBody(world, map.offsetx, map.offsety) + local collision = { + body = body, + } + + local function addObjectToWorld(objshape, vertices, userdata, object) + local shape + + if objshape == "polyline" then + if #vertices == 4 then + shape = love.physics.newEdgeShape(unpack(vertices)) + else + shape = love.physics.newChainShape(false, unpack(vertices)) + end + else + shape = love.physics.newPolygonShape(unpack(vertices)) + end + + local currentBody = body + --dynamic are objects/players etc. + if userdata.properties.dynamic == true then + currentBody = love.physics.newBody(world, map.offsetx, map.offsety, 'dynamic') + -- static means it shouldn't move. Things like walls/ground. + elseif userdata.properties.static == true then + currentBody = love.physics.newBody(world, map.offsetx, map.offsety, 'static') + -- kinematic means that the object is static in the game world but effects other bodies + elseif userdata.properties.kinematic == true then + currentBody = love.physics.newBody(world, map.offsetx, map.offsety, 'kinematic') + end + + local fixture = love.physics.newFixture(currentBody, shape) + fixture:setUserData(userdata) + + -- Set some custom properties from userdata (or use default set by box2d) + fixture:setFriction(userdata.properties.friction or 0.2) + fixture:setRestitution(userdata.properties.restitution or 0.0) + fixture:setSensor(userdata.properties.sensor or false) + fixture:setFilterData( + userdata.properties.categories or 1, + userdata.properties.mask or 65535, + userdata.properties.group or 0 + ) + + local obj = { + object = object, + body = currentBody, + shape = shape, + fixture = fixture, + } + + table.insert(collision, obj) + end + + local function getPolygonVertices(object) + local vertices = {} + for _, vertex in ipairs(object.polygon) do + table.insert(vertices, vertex.x) + table.insert(vertices, vertex.y) + end + + return vertices + end + + local function calculateObjectPosition(object, tile) + local o = { + shape = object.shape, + x = (object.dx or object.x) + map.offsetx, + y = (object.dy or object.y) + map.offsety, + w = object.width, + h = object.height, + polygon = object.polygon or object.polyline or object.ellipse or object.rectangle + } + + local userdata = { + object = o, + properties = object.properties + } + + o.r = object.rotation or 0 + if o.shape == "rectangle" then + local cos = math.cos(math.rad(o.r)) + local sin = math.sin(math.rad(o.r)) + local oy = 0 + + if object.gid then + local tileset = map.tilesets[map.tiles[object.gid].tileset] + local lid = object.gid - tileset.firstgid + local t = {} + + -- This fixes a height issue + o.y = o.y + map.tiles[object.gid].offset.y + oy = o.h + + for _, tt in ipairs(tileset.tiles) do + if tt.id == lid then + t = tt + break + end + end + + if t.objectGroup then + for _, obj in ipairs(t.objectGroup.objects) do + -- Every object in the tile + calculateObjectPosition(obj, object) + end + + return + else + o.w = map.tiles[object.gid].width + o.h = map.tiles[object.gid].height + end + end + + o.polygon = { + { x=o.x+0, y=o.y+0 }, + { x=o.x+o.w, y=o.y+0 }, + { x=o.x+o.w, y=o.y+o.h }, + { x=o.x+0, y=o.y+o.h } + } + + for _, vertex in ipairs(o.polygon) do + vertex.x, vertex.y = utils.rotate_vertex(map, vertex, o.x, o.y, cos, sin, oy) + end + + local vertices = getPolygonVertices(o) + addObjectToWorld(o.shape, vertices, userdata, tile or object) + elseif o.shape == "ellipse" then + if not o.polygon then + o.polygon = utils.convert_ellipse_to_polygon(o.x, o.y, o.w, o.h) + end + local vertices = getPolygonVertices(o) + local triangles = love.math.triangulate(vertices) + + for _, triangle in ipairs(triangles) do + addObjectToWorld(o.shape, triangle, userdata, tile or object) + end + elseif o.shape == "polygon" then + -- Recalculate collision polygons inside tiles + if tile then + local cos = math.cos(math.rad(o.r)) + local sin = math.sin(math.rad(o.r)) + for _, vertex in ipairs(o.polygon) do + vertex.x = vertex.x + o.x + vertex.y = vertex.y + o.y + vertex.x, vertex.y = utils.rotate_vertex(map, vertex, o.x, o.y, cos, sin) + end + end + + local vertices = getPolygonVertices(o) + local triangles = love.math.triangulate(vertices) + + for _, triangle in ipairs(triangles) do + addObjectToWorld(o.shape, triangle, userdata, tile or object) + end + elseif o.shape == "polyline" then + local vertices = getPolygonVertices(o) + addObjectToWorld(o.shape, vertices, userdata, tile or object) + end + end + + for _, tile in pairs(map.tiles) do + if map.tileInstances[tile.gid] then + for _, instance in ipairs(map.tileInstances[tile.gid]) do + -- Every object in every instance of a tile + if tile.objectGroup then + for _, object in ipairs(tile.objectGroup.objects) do + if object.properties.collidable == true then + object = utils.deepCopy(object) + object.dx = instance.x + object.x + object.dy = instance.y + object.y + calculateObjectPosition(object, instance) + end + end + end + + -- Every instance of a tile + if tile.properties.collidable == true then + local object = { + shape = "rectangle", + x = instance.x, + y = instance.y, + width = map.tilewidth, + height = map.tileheight, + properties = tile.properties + } + + calculateObjectPosition(object, instance) + end + end + end + end + + for _, layer in ipairs(map.layers) do + -- Entire layer + if layer.properties.collidable == true then + if layer.type == "tilelayer" then + for gid, tiles in pairs(map.tileInstances) do + local tile = map.tiles[gid] + local tileset = map.tilesets[tile.tileset] + + for _, instance in ipairs(tiles) do + if instance.layer == layer then + local object = { + shape = "rectangle", + x = instance.x, + y = instance.y, + width = tileset.tilewidth, + height = tileset.tileheight, + properties = tile.properties + } + + calculateObjectPosition(object, instance) + end + end + end + elseif layer.type == "objectgroup" then + for _, object in ipairs(layer.objects) do + calculateObjectPosition(object) + end + elseif layer.type == "imagelayer" then + local object = { + shape = "rectangle", + x = layer.x or 0, + y = layer.y or 0, + width = layer.width, + height = layer.height, + properties = layer.properties + } + + calculateObjectPosition(object) + end + end + + -- Individual objects + if layer.type == "objectgroup" then + for _, object in ipairs(layer.objects) do + if object.properties.collidable == true then + calculateObjectPosition(object) + end + end + end + end + + map.box2d_collision = collision + end, + + --- Remove Box2D fixtures and shapes from world. + -- @param index The index or name of the layer being removed + box2d_removeLayer = function(map, index) + local layer = assert(map.layers[index], "Layer not found: " .. index) + local collision = map.box2d_collision + + -- Remove collision objects + for i = #collision, 1, -1 do + local obj = collision[i] + + if obj.object.layer == layer then + obj.fixture:destroy() + table.remove(collision, i) + end + end + end, + + --- Draw Box2D physics world. + -- @param tx Translate on X + -- @param ty Translate on Y + -- @param sx Scale on X + -- @param sy Scale on Y + box2d_draw = function(map, tx, ty, sx, sy) + local collision = map.box2d_collision + + lg.push() + lg.scale(sx or 1, sy or sx or 1) + lg.translate(math.floor(tx or 0), math.floor(ty or 0)) + + for _, obj in ipairs(collision) do + local points = {obj.body:getWorldPoints(obj.shape:getPoints())} + local shape_type = obj.shape:getType() + + if shape_type == "edge" or shape_type == "chain" then + love.graphics.line(points) + elseif shape_type == "polygon" then + love.graphics.polygon("line", points) + else + error("sti box2d plugin does not support "..shape_type.." shapes") + end + end + + lg.pop() + end +} + +--- Custom Properties in Tiled are used to tell this plugin what to do. +-- @table Properties +-- @field collidable set to true, can be used on any Layer, Tile, or Object +-- @field sensor set to true, can be used on any Tile or Object that is also collidable +-- @field dynamic set to true, can be used on any Tile or Object +-- @field friction can be used to define the friction of any Object +-- @field restitution can be used to define the restitution of any Object +-- @field categories can be used to set the filter Category of any Object +-- @field mask can be used to set the filter Mask of any Object +-- @field group can be used to set the filter Group of any Object diff --git a/sti/plugins/bump.lua b/sti/plugins/bump.lua new file mode 100644 index 0000000..1d4b828 --- /dev/null +++ b/sti/plugins/bump.lua @@ -0,0 +1,193 @@ +--- Bump.lua plugin for STI +-- @module bump.lua +-- @author David Serrano (BobbyJones|FrenchFryLord) +-- @copyright 2019 +-- @license MIT/X11 + +local lg = require((...):gsub('plugins.bump', 'graphics')) + +return { + bump_LICENSE = "MIT/X11", + bump_URL = "https://github.com/karai17/Simple-Tiled-Implementation", + bump_VERSION = "3.1.7.1", + bump_DESCRIPTION = "Bump hooks for STI.", + + --- Adds each collidable tile to the Bump world. + -- @param world The Bump world to add objects to. + -- @return collidables table containing the handles to the objects in the Bump world. + bump_init = function(map, world) + local collidables = {} + + for _, tileset in ipairs(map.tilesets) do + for _, tile in ipairs(tileset.tiles) do + local gid = tileset.firstgid + tile.id + + if map.tileInstances[gid] then + for _, instance in ipairs(map.tileInstances[gid]) do + -- Every object in every instance of a tile + if tile.objectGroup then + for _, object in ipairs(tile.objectGroup.objects) do + if object.properties.collidable == true then + local t = { + name = object.name, + type = object.type, + x = instance.x + map.offsetx + object.x, + y = instance.y + map.offsety + object.y, + width = object.width, + height = object.height, + layer = instance.layer, + properties = object.properties + + } + + world:add(t, t.x, t.y, t.width, t.height) + table.insert(collidables, t) + end + end + end + + -- Every instance of a tile + if tile.properties and tile.properties.collidable == true then + local t = { + x = instance.x + map.offsetx, + y = instance.y + map.offsety, + width = map.tilewidth, + height = map.tileheight, + layer = instance.layer, + type = tile.type, + properties = tile.properties + } + + world:add(t, t.x, t.y, t.width, t.height) + table.insert(collidables, t) + end + end + end + end + end + + for _, layer in ipairs(map.layers) do + -- Entire layer + if layer.properties.collidable == true then + if layer.type == "tilelayer" then + for y, tiles in ipairs(layer.data) do + for x, tile in pairs(tiles) do + + if tile.objectGroup then + for _, object in ipairs(tile.objectGroup.objects) do + if object.properties.collidable == true then + local t = { + name = object.name, + type = object.type, + x = ((x-1) * map.tilewidth + tile.offset.x + map.offsetx) + object.x, + y = ((y-1) * map.tileheight + tile.offset.y + map.offsety) + object.y, + width = object.width, + height = object.height, + layer = layer, + properties = object.properties + } + + world:add(t, t.x, t.y, t.width, t.height) + table.insert(collidables, t) + end + end + end + + + local t = { + x = (x-1) * map.tilewidth + tile.offset.x + map.offsetx, + y = (y-1) * map.tileheight + tile.offset.y + map.offsety, + width = tile.width, + height = tile.height, + layer = layer, + type = tile.type, + properties = tile.properties + } + + world:add(t, t.x, t.y, t.width, t.height) + table.insert(collidables, t) + end + end + elseif layer.type == "imagelayer" then + world:add(layer, layer.x, layer.y, layer.width, layer.height) + table.insert(collidables, layer) + end + end + + -- individual collidable objects in a layer that is not "collidable" + -- or whole collidable objects layer + if layer.type == "objectgroup" then + for _, obj in ipairs(layer.objects) do + if layer.properties.collidable == true or obj.properties.collidable == true then + if obj.shape == "rectangle" then + local t = { + name = obj.name, + type = obj.type, + x = obj.x + map.offsetx, + y = obj.y + map.offsety, + width = obj.width, + height = obj.height, + layer = layer, + properties = obj.properties + } + + if obj.gid then + t.y = t.y - obj.height + end + + world:add(t, t.x, t.y, t.width, t.height) + table.insert(collidables, t) + end -- TODO implement other object shapes? + end + end + end + end + + map.bump_world = world + map.bump_collidables = collidables + end, + + --- Remove layer + -- @param index to layer to be removed + bump_removeLayer = function(map, index) + local layer = assert(map.layers[index], "Layer not found: " .. index) + local collidables = map.bump_collidables + + -- Remove collision objects + for i = #collidables, 1, -1 do + local obj = collidables[i] + + if obj.layer == layer + and ( + layer.properties.collidable == true + or obj.properties.collidable == true + ) then + map.bump_world:remove(obj) + table.remove(collidables, i) + end + end + end, + + --- Draw bump collisions world. + -- @param world bump world holding the tiles geometry + -- @param tx Translate on X + -- @param ty Translate on Y + -- @param sx Scale on X + -- @param sy Scale on Y + bump_draw = function(map, tx, ty, sx, sy) + lg.push() + lg.scale(sx or 1, sy or sx or 1) + lg.translate(math.floor(tx or 0), math.floor(ty or 0)) + + local items = map.bump_world:getItems() + for _, item in ipairs(items) do + lg.rectangle("line", map.bump_world:getRect(item)) + end + + lg.pop() + end +} + +--- Custom Properties in Tiled are used to tell this plugin what to do. +-- @table Properties +-- @field collidable set to true, can be used on any Layer, Tile, or Object diff --git a/sti/utils.lua b/sti/utils.lua new file mode 100644 index 0000000..95e857a --- /dev/null +++ b/sti/utils.lua @@ -0,0 +1,217 @@ +-- Some utility functions that shouldn't be exposed. +local utils = {} + +-- https://github.com/stevedonovan/Penlight/blob/master/lua/pl/path.lua#L286 +function utils.format_path(path) + local np_gen1,np_gen2 = '[^SEP]+SEP%.%.SEP?','SEP+%.?SEP' + local np_pat1, np_pat2 = np_gen1:gsub('SEP','/'), np_gen2:gsub('SEP','/') + local k + + repeat -- /./ -> / + path,k = path:gsub(np_pat2,'/',1) + until k == 0 + + repeat -- A/../ -> (empty) + path,k = path:gsub(np_pat1,'',1) + until k == 0 + + if path == '' then path = '.' end + + return path +end + +-- Compensation for scale/rotation shift +function utils.compensate(tile, tileX, tileY, tileW, tileH) + local compx = 0 + local compy = 0 + + if tile.sx < 0 then compx = tileW end + if tile.sy < 0 then compy = tileH end + + if tile.r > 0 then + tileX = tileX + tileH - compy + tileY = tileY + tileH + compx - tileW + elseif tile.r < 0 then + tileX = tileX + compy + tileY = tileY - compx + tileH + else + tileX = tileX + compx + tileY = tileY + compy + end + + return tileX, tileY +end + +-- Cache images in main STI module +function utils.cache_image(sti, path, image) + image = image or love.graphics.newImage(path) + image:setFilter("nearest", "nearest") + sti.cache[path] = image +end + +-- We just don't know. +function utils.get_tiles(imageW, tileW, margin, spacing) + imageW = imageW - margin + local n = 0 + + while imageW >= tileW do + imageW = imageW - tileW + if n ~= 0 then imageW = imageW - spacing end + if imageW >= 0 then n = n + 1 end + end + + return n +end + +-- Decompress tile layer data +function utils.get_decompressed_data(data) + local ffi = require "ffi" + local d = {} + local decoded = ffi.cast("uint32_t*", data) + + for i = 0, data:len() / ffi.sizeof("uint32_t") do + table.insert(d, tonumber(decoded[i])) + end + + return d +end + +-- Convert a Tiled ellipse object to a LOVE polygon +function utils.convert_ellipse_to_polygon(x, y, w, h, max_segments) + local ceil = math.ceil + local cos = math.cos + local sin = math.sin + + local function calc_segments(segments) + local function vdist(a, b) + local c = { + x = a.x - b.x, + y = a.y - b.y, + } + + return c.x * c.x + c.y * c.y + end + + segments = segments or 64 + local vertices = {} + + local v = { 1, 2, ceil(segments/4-1), ceil(segments/4) } + + local m + if love and love.physics then + m = love.physics.getMeter() + else + m = 32 + end + + for _, i in ipairs(v) do + local angle = (i / segments) * math.pi * 2 + local px = x + w / 2 + cos(angle) * w / 2 + local py = y + h / 2 + sin(angle) * h / 2 + + table.insert(vertices, { x = px / m, y = py / m }) + end + + local dist1 = vdist(vertices[1], vertices[2]) + local dist2 = vdist(vertices[3], vertices[4]) + + -- Box2D threshold + if dist1 < 0.0025 or dist2 < 0.0025 then + return calc_segments(segments-2) + end + + return segments + end + + local segments = calc_segments(max_segments) + local vertices = {} + + table.insert(vertices, { x = x + w / 2, y = y + h / 2 }) + + for i = 0, segments do + local angle = (i / segments) * math.pi * 2 + local px = x + w / 2 + cos(angle) * w / 2 + local py = y + h / 2 + sin(angle) * h / 2 + + table.insert(vertices, { x = px, y = py }) + end + + return vertices +end + +function utils.rotate_vertex(map, vertex, x, y, cos, sin, oy) + if map.orientation == "isometric" then + x, y = utils.convert_isometric_to_screen(map, x, y) + vertex.x, vertex.y = utils.convert_isometric_to_screen(map, vertex.x, vertex.y) + end + + vertex.x = vertex.x - x + vertex.y = vertex.y - y + + return + x + cos * vertex.x - sin * vertex.y, + y + sin * vertex.x + cos * vertex.y - (oy or 0) +end + +--- Project isometric position to cartesian position +function utils.convert_isometric_to_screen(map, x, y) + local mapW = map.width + local tileW = map.tilewidth + local tileH = map.tileheight + local tileX = x / tileH + local tileY = y / tileH + local offsetX = mapW * tileW / 2 + + return + (tileX - tileY) * tileW / 2 + offsetX, + (tileX + tileY) * tileH / 2 +end + +function utils.hex_to_color(hex) + if hex:sub(1, 1) == "#" then + hex = hex:sub(2) + end + + return { + r = tonumber(hex:sub(1, 2), 16) / 255, + g = tonumber(hex:sub(3, 4), 16) / 255, + b = tonumber(hex:sub(5, 6), 16) / 255 + } +end + +function utils.pixel_function(_, _, r, g, b, a) + local mask = utils._TC + + if r == mask.r and + g == mask.g and + b == mask.b then + return r, g, b, 0 + end + + return r, g, b, a +end + +function utils.fix_transparent_color(tileset, path) + local image_data = love.image.newImageData(path) + tileset.image = love.graphics.newImage(image_data) + + if tileset.transparentcolor then + utils._TC = utils.hex_to_color(tileset.transparentcolor) + + image_data:mapPixel(utils.pixel_function) + tileset.image = love.graphics.newImage(image_data) + end +end + +function utils.deepCopy(t) + local copy = {} + for k,v in pairs(t) do + if type(v) == "table" then + v = utils.deepCopy(v) + end + copy[k] = v + end + return copy +end + +return utils