initial commit
This commit is contained in:
commit
47525f364d
28 changed files with 3181 additions and 0 deletions
645
src/sti.lua
Normal file
645
src/sti.lua
Normal file
|
@ -0,0 +1,645 @@
|
|||
--[[
|
||||
------------------------------------------------------------------------------
|
||||
Simple Tiled Implementation is licensed under the MIT Open Source License.
|
||||
(http://www.opensource.org/licenses/mit-license.html)
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
Copyright (c) 2014 Landon Manning - LManning17@gmail.com - LandonManning.com
|
||||
|
||||
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.
|
||||
]]--
|
||||
|
||||
-- Simple Tiled Implementation v0.6.16
|
||||
|
||||
local bit = require "bit"
|
||||
local STI = {}
|
||||
local Map = {}
|
||||
|
||||
function STI.new(map)
|
||||
map = map .. ".lua"
|
||||
|
||||
-- Get path to map
|
||||
local path = map:reverse():find("[/\\]") or ""
|
||||
if path ~= "" then
|
||||
path = map:sub(1, 1 + (#map - path))
|
||||
end
|
||||
|
||||
-- Load map
|
||||
map = assert(love.filesystem.load(map), "File not found: " .. map)
|
||||
setfenv(map, {})
|
||||
map = setmetatable(map(), {__index = Map})
|
||||
|
||||
map.tiles = {}
|
||||
map.drawRange = {
|
||||
sx = 1,
|
||||
sy = 1,
|
||||
ex = map.width,
|
||||
ey = map.height,
|
||||
}
|
||||
|
||||
-- Set tiles, images
|
||||
local gid = 1
|
||||
for i, tileset in ipairs(map.tilesets) do
|
||||
local image = STI.formatPath(path..tileset.image)
|
||||
tileset.image = love.graphics.newImage(image)
|
||||
tileset.image:setFilter("nearest", "nearest")
|
||||
gid = map:setTiles(i, tileset, gid)
|
||||
end
|
||||
|
||||
-- Set layers
|
||||
for i, layer in ipairs(map.layers) do
|
||||
map:setLayer(layer, path)
|
||||
end
|
||||
|
||||
return map
|
||||
end
|
||||
|
||||
function STI.formatPath(path)
|
||||
local str = string.split(path, "/")
|
||||
|
||||
for i, segment in pairs(str) do
|
||||
if segment == ".." then
|
||||
str[i] = nil
|
||||
str[i-1] = nil
|
||||
end
|
||||
end
|
||||
|
||||
path = ""
|
||||
for _, segment in pairs(str) do
|
||||
path = path .. segment .. "/"
|
||||
end
|
||||
|
||||
return string.sub(path, 1, path:len()-1)
|
||||
end
|
||||
|
||||
function Map:setTiles(index, tileset, gid)
|
||||
local function getTiles(i, t, m, s)
|
||||
i = i - m
|
||||
local n = 0
|
||||
|
||||
while i >= t do
|
||||
i = i - t
|
||||
if n ~= 0 then i = i - s end
|
||||
if i >= 0 then n = n + 1 end
|
||||
end
|
||||
|
||||
return n
|
||||
end
|
||||
|
||||
local quad = love.graphics.newQuad
|
||||
local mw = self.tilewidth
|
||||
local iw = tileset.imagewidth
|
||||
local ih = tileset.imageheight
|
||||
local tw = tileset.tilewidth
|
||||
local th = tileset.tileheight
|
||||
local s = tileset.spacing
|
||||
local m = tileset.margin
|
||||
local w = getTiles(iw, tw, m, s)
|
||||
local h = getTiles(ih, th, m, s)
|
||||
|
||||
for y = 1, h do
|
||||
for x = 1, w do
|
||||
local qx = (x - 1) * tw + m + (x - 1) * s
|
||||
local qy = (y - 1) * th + m + (y - 1) * s
|
||||
local properties
|
||||
|
||||
for _, tile in pairs(tileset.tiles) do
|
||||
if tile.id == gid - tileset.firstgid + 1 then
|
||||
properties = tile.properties
|
||||
end
|
||||
end
|
||||
|
||||
local tile = {
|
||||
gid = gid,
|
||||
tileset = index,
|
||||
quad = quad(qx, qy, tw, th, iw, ih),
|
||||
properties = properties,
|
||||
sx = 1,
|
||||
sy = 1,
|
||||
r = 0,
|
||||
offset = {
|
||||
x = -mw,
|
||||
y = -th,
|
||||
},
|
||||
}
|
||||
|
||||
if self.orientation == "isometric" then
|
||||
tile.offset.x = -mw / 2
|
||||
end
|
||||
|
||||
--[[ THIS IS A TEMPORARY FIX FOR 0.9.1 ]]--
|
||||
if tileset.tileoffset then
|
||||
tile.offset.x = tile.offset.x + tileset.tileoffset.x
|
||||
tile.offset.y = tile.offset.y + tileset.tileoffset.y
|
||||
end
|
||||
|
||||
self.tiles[gid] = tile
|
||||
gid = gid + 1
|
||||
end
|
||||
end
|
||||
|
||||
return gid
|
||||
end
|
||||
|
||||
function Map:setLayer(layer, path)
|
||||
layer.x = layer.x or 0
|
||||
layer.y = layer.y or 0
|
||||
layer.update = function(dt) return end
|
||||
|
||||
if layer.type == "tilelayer" then
|
||||
self:setTileData(layer)
|
||||
self:setSpriteBatches(layer)
|
||||
layer.draw = function() self:drawTileLayer(layer) end
|
||||
elseif layer.type == "objectgroup" then
|
||||
layer.draw = function() self:drawObjectLayer(layer) end
|
||||
elseif layer.type == "imagelayer" then
|
||||
layer.draw = function() self:drawImageLayer(layer) end
|
||||
|
||||
local image = STI.formatPath(path..layer.image)
|
||||
if layer.image ~= "" then
|
||||
layer.image = love.graphics.newImage(image)
|
||||
end
|
||||
end
|
||||
|
||||
self.layers[layer.name] = layer
|
||||
end
|
||||
|
||||
function Map:setTileData(layer)
|
||||
local i = 1
|
||||
local map = {}
|
||||
|
||||
for y = 1, layer.height do
|
||||
map[y] = {}
|
||||
for x = 1, layer.width do
|
||||
local gid = layer.data[i]
|
||||
|
||||
if gid > 0 then
|
||||
local tile = self.tiles[gid]
|
||||
|
||||
if tile then
|
||||
map[y][x] = tile
|
||||
else
|
||||
local flipX = bit.status(gid, 31)
|
||||
local flipY = bit.status(gid, 30)
|
||||
local flipD = bit.status(gid, 29)
|
||||
local realgid = bit.band(gid, bit.bnot(bit.bor(2^31, 2^30, 2^29)))
|
||||
local tile = self.tiles[realgid]
|
||||
local data = {
|
||||
gid = tile.gid,
|
||||
tileset = tile.tileset,
|
||||
offset = tile.offset,
|
||||
quad = tile.quad,
|
||||
properties = tile.properties,
|
||||
sx = tile.sx,
|
||||
sy = tile.sy,
|
||||
r = tile.r,
|
||||
}
|
||||
|
||||
if flipX then
|
||||
if 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
|
||||
map[y][x] = self.tiles[gid]
|
||||
end
|
||||
end
|
||||
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
layer.data = map
|
||||
end
|
||||
|
||||
function Map:setSpriteBatches(layer)
|
||||
local newBatch = love.graphics.newSpriteBatch
|
||||
local w = love.graphics.getWidth()
|
||||
local h = love.graphics.getHeight()
|
||||
local tw = self.tilewidth
|
||||
local th = self.tileheight
|
||||
local bw = math.ceil(w / tw)
|
||||
local bh = math.ceil(h / th)
|
||||
|
||||
-- Minimum of 400 tiles per batch
|
||||
if bw < 20 then bw = 20 end
|
||||
if bh < 20 then bh = 20 end
|
||||
|
||||
local size = bw * bh
|
||||
local batches = {
|
||||
width = bw,
|
||||
height = bh,
|
||||
data = {},
|
||||
}
|
||||
|
||||
for y = 1, layer.height do
|
||||
local by = math.ceil(y / bh)
|
||||
|
||||
for x = 1, layer.width do
|
||||
local tile = layer.data[y][x]
|
||||
local bx = math.ceil(x / bw)
|
||||
|
||||
if tile then
|
||||
local ts = tile.tileset
|
||||
local image = self.tilesets[tile.tileset].image
|
||||
|
||||
batches.data[ts] = batches.data[ts] or {}
|
||||
batches.data[ts][by] = batches.data[ts][by] or {}
|
||||
batches.data[ts][by][bx] = batches.data[ts][by][bx] or newBatch(image, size)
|
||||
|
||||
local batch = batches.data[ts][by][bx]
|
||||
local tx, ty
|
||||
|
||||
if self.orientation == "orthogonal" then
|
||||
tx = x * tw + tile.offset.x
|
||||
ty = y * th + tile.offset.y
|
||||
|
||||
-- Compensation for scale/rotation shift
|
||||
if tile.sx < 0 then tx = tx + tw end
|
||||
if tile.sy < 0 then ty = ty + th end
|
||||
if tile.r > 0 then tx = tx + tw end
|
||||
if tile.r < 0 then ty = ty + th end
|
||||
elseif self.orientation == "isometric" then
|
||||
tx = (x - y) * (tw / 2) + tile.offset.x
|
||||
ty = (x + y) * (th / 2) + tile.offset.y
|
||||
elseif self.orientation == "staggered" then
|
||||
if y % 2 == 0 then
|
||||
tx = x * tw + tw / 2 + tile.offset.x
|
||||
else
|
||||
tx = x * tw + tile.offset.x
|
||||
end
|
||||
|
||||
ty = y * th / 2 + tile.offset.y
|
||||
end
|
||||
|
||||
batch:add(tile.quad, tx, ty, tile.r, tile.sx, tile.sy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
layer.batches = batches
|
||||
end
|
||||
|
||||
function Map:setDrawRange(tx, ty, w, h)
|
||||
tx = -tx
|
||||
ty = -ty
|
||||
local tw = self.tilewidth
|
||||
local th = self.tileheight
|
||||
local sx, sy, ex, ey
|
||||
|
||||
if self.orientation == "orthogonal" then
|
||||
sx = math.ceil(tx / tw)
|
||||
sy = math.ceil(ty / th)
|
||||
ex = math.ceil(sx + w / tw)
|
||||
ey = math.ceil(sy + h / th)
|
||||
elseif self.orientation == "isometric" then
|
||||
sx = math.ceil(((ty / (th / 2)) + (tx / (tw / 2))) / 2)
|
||||
sy = math.ceil(((ty / (th / 2)) - (tx / (tw / 2))) / 2 - h / th)
|
||||
ex = math.ceil(sx + (h / th) + (w / tw))
|
||||
ey = math.ceil(sy + (h / th) * 2 + (w / tw))
|
||||
elseif self.orientation == "staggered" then
|
||||
sx = math.ceil(tx / tw - 1)
|
||||
sy = math.ceil(ty / th)
|
||||
ex = math.ceil(sx + w / tw + 1)
|
||||
ey = math.ceil(sy + h / th * 2)
|
||||
end
|
||||
|
||||
self.drawRange = {
|
||||
sx = sx,
|
||||
sy = sy,
|
||||
ex = ex,
|
||||
ey = ey,
|
||||
}
|
||||
end
|
||||
|
||||
function Map:getCollisionMap(index)
|
||||
local layer = assert(self.layers[index], "Layer not found: " .. index)
|
||||
|
||||
assert(layer.type == "tilelayer", "Invalid layer type: " .. layer.type .. ". Layer must be of type: tilelayer")
|
||||
|
||||
local w = self.width
|
||||
local h = self.height
|
||||
local map = {
|
||||
type = layer.type,
|
||||
orientation = layer.orientation,
|
||||
collision = true,
|
||||
opacity = 0.5,
|
||||
data = {},
|
||||
}
|
||||
|
||||
for y=1, h do
|
||||
map.data[y] = {}
|
||||
for x=1, w do
|
||||
if layer.data[y][x] == nil then
|
||||
map.data[y][x] = 0
|
||||
else
|
||||
map.data[y][x] = 1
|
||||
|
||||
ctile = collider:addRectangle((x - 1) * self.tilewidth, (y - 1) * self.tileheight, self.tilewidth, self.tileheight)
|
||||
collider:setPassive(ctile)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return map
|
||||
end
|
||||
|
||||
function Map:addCustomLayer(name, index)
|
||||
local layer = {
|
||||
type = "customlayer",
|
||||
name = name,
|
||||
visible = true,
|
||||
opacity = 1,
|
||||
properties = {},
|
||||
}
|
||||
|
||||
function layer:draw() return end
|
||||
function layer:update(dt) return end
|
||||
|
||||
table.insert(self.layers, index, layer)
|
||||
self.layers[name] = self.layers[index]
|
||||
end
|
||||
|
||||
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.objects = nil
|
||||
layer.image = nil
|
||||
|
||||
function layer:draw() return end
|
||||
function layer:update(dt) return end
|
||||
end
|
||||
|
||||
function Map:removeLayer(index)
|
||||
local layer = assert(self.layers[index], "Layer not found: " .. index)
|
||||
|
||||
if type(index) == "string" then
|
||||
for i, layer in ipairs(self.layers) do
|
||||
if layer.name == index then
|
||||
table.remove(self.layers, i)
|
||||
table.remove(self.layers, index)
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
local name = self.layers[index].name
|
||||
table.remove(self.layers, index)
|
||||
table.remove(self.layers, name)
|
||||
end
|
||||
end
|
||||
|
||||
function Map:update(dt)
|
||||
for _, layer in ipairs(self.layers) do
|
||||
layer:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
function Map:draw()
|
||||
for _, layer in ipairs(self.layers) do
|
||||
if layer.visible and layer.opacity > 0 then
|
||||
self:drawLayer(layer)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Map:drawLayer(layer)
|
||||
love.graphics.setColor(255, 255, 255, 255 * layer.opacity)
|
||||
layer:draw()
|
||||
love.graphics.setColor(255, 255, 255, 255)
|
||||
end
|
||||
|
||||
function Map:drawTileLayer(layer)
|
||||
assert(layer.type == "tilelayer", "Invalid layer type: " .. layer.type .. ". Layer must be of type: tilelayer")
|
||||
|
||||
local bw = layer.batches.width
|
||||
local bh = layer.batches.height
|
||||
local sx = math.ceil((self.drawRange.sx - layer.x / self.tilewidth - 1) / bw)
|
||||
local sy = math.ceil((self.drawRange.sy - layer.y / self.tileheight - 1) / bh)
|
||||
local ex = math.ceil((self.drawRange.ex - layer.x / self.tilewidth + 1) / bw)
|
||||
local ey = math.ceil((self.drawRange.ey - layer.y / self.tileheight + 1) / bh)
|
||||
local mx = math.ceil(self.width / bw)
|
||||
local my = math.ceil(self.height / bh)
|
||||
|
||||
for by=sy, ey do
|
||||
for bx=sx, ex do
|
||||
if bx >= 1 and bx <= mx and by >= 1 and by <= my then
|
||||
for _, batches in pairs(layer.batches.data) do
|
||||
local batch = batches[by] and batches[by][bx]
|
||||
|
||||
if batch then
|
||||
love.graphics.draw(batch, math.floor(layer.x), math.floor(layer.y))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Map:drawObjectLayer(layer)
|
||||
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.2 }
|
||||
local shadow = { 0, 0, 0, 255 * layer.opacity }
|
||||
|
||||
local function drawEllipse(mode, x, y, rx, ry)
|
||||
local segments = 100
|
||||
local vertices = {}
|
||||
|
||||
table.insert(vertices, x + rx / 2)
|
||||
table.insert(vertices, y + ry / 2)
|
||||
|
||||
for i=0, segments do
|
||||
local angle = (i / segments) * math.pi * 2
|
||||
local px = x + rx / 2 + math.cos(angle) * rx / 2
|
||||
local py = y + ry / 2 + math.sin(angle) * ry / 2
|
||||
|
||||
table.insert(vertices, px)
|
||||
table.insert(vertices, py)
|
||||
end
|
||||
|
||||
love.graphics.polygon(mode, vertices)
|
||||
end
|
||||
|
||||
for _, object in ipairs(layer.objects) do
|
||||
local x = layer.x + object.x
|
||||
local y = layer.y + object.y
|
||||
|
||||
if object.shape == "rectangle" then
|
||||
love.graphics.setColor(fill)
|
||||
love.graphics.rectangle("fill", x, y, object.width, object.height)
|
||||
|
||||
love.graphics.setColor(shadow)
|
||||
love.graphics.rectangle("line", x+1, y+1, object.width, object.height)
|
||||
|
||||
love.graphics.setColor(line)
|
||||
love.graphics.rectangle("line", x, y, object.width, object.height)
|
||||
elseif object.shape == "ellipse" then
|
||||
love.graphics.setColor(fill)
|
||||
drawEllipse("fill", x, y, object.width, object.height)
|
||||
|
||||
love.graphics.setColor(shadow)
|
||||
drawEllipse("line", x+1, y+1, object.width, object.height)
|
||||
|
||||
love.graphics.setColor(line)
|
||||
drawEllipse("line", x, y, object.width, object.height)
|
||||
elseif object.shape == "polygon" then
|
||||
local points = {{},{}}
|
||||
|
||||
for _, point in ipairs(object.polygon) do
|
||||
table.insert(points[1], x + point.x)
|
||||
table.insert(points[1], y + point.y)
|
||||
table.insert(points[2], x + point.x+1)
|
||||
table.insert(points[2], y + point.y+1)
|
||||
end
|
||||
|
||||
love.graphics.setColor(fill)
|
||||
if not love.math.isConvex(points[1]) then
|
||||
local triangles = love.math.triangulate(points[1])
|
||||
for _, triangle in ipairs(triangles) do
|
||||
love.graphics.polygon("fill", triangle)
|
||||
end
|
||||
else
|
||||
love.graphics.polygon("fill", points[1])
|
||||
end
|
||||
|
||||
love.graphics.setColor(shadow)
|
||||
love.graphics.polygon("line", points[2])
|
||||
|
||||
love.graphics.setColor(line)
|
||||
love.graphics.polygon("line", points[1])
|
||||
elseif object.shape == "polyline" then
|
||||
local points = {{},{}}
|
||||
|
||||
for _, point in ipairs(object.polyline) do
|
||||
table.insert(points[1], x + point.x)
|
||||
table.insert(points[1], y + point.y)
|
||||
table.insert(points[2], x + point.x+1)
|
||||
table.insert(points[2], y + point.y+1)
|
||||
end
|
||||
|
||||
love.graphics.setColor(shadow)
|
||||
love.graphics.line(points[2])
|
||||
|
||||
love.graphics.setColor(line)
|
||||
love.graphics.line(points[1])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Map:drawImageLayer(layer)
|
||||
assert(layer.type == "imagelayer", "Invalid layer type: " .. layer.type .. ". Layer must be of type: imagelayer")
|
||||
|
||||
if layer.image ~= "" then
|
||||
love.graphics.draw(layer.image, layer.x, layer.y)
|
||||
end
|
||||
end
|
||||
|
||||
function Map:drawCollisionMap(layer)
|
||||
assert(layer.type == "tilelayer", "Invalid layer type: " .. layer.type .. ". Layer must be of type: tilelayer")
|
||||
assert(layer.collision, "This is not a collision layer")
|
||||
|
||||
local tw = self.tilewidth
|
||||
local th = self.tileheight
|
||||
|
||||
love.graphics.setColor(255, 255, 255, 255 * layer.opacity)
|
||||
|
||||
for y=1, self.height do
|
||||
for x=1, self.width do
|
||||
local tx, ty
|
||||
|
||||
if self.orientation == "orthogonal" then
|
||||
tx = (x - 1) * tw
|
||||
ty = (y - 1) * th
|
||||
elseif self.orientation == "isometric" then
|
||||
tx = (x - y) * (tw / 2) - self.tilewidth / 2
|
||||
ty = (x + y) * (th / 2) - self.tileheight
|
||||
elseif self.orientation == "staggered" then
|
||||
if y % 2 == 0 then
|
||||
tx = x * tw + tw / 2 - self.tilewidth
|
||||
else
|
||||
tx = x * tw - self.tilewidth
|
||||
end
|
||||
|
||||
ty = y * th / 2 - self.tileheight
|
||||
end
|
||||
|
||||
|
||||
if layer.data[y][x] == 1 then
|
||||
love.graphics.rectangle("fill", tx, ty, tw, th)
|
||||
else
|
||||
love.graphics.rectangle("line", tx, ty, tw, th)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.setColor(255, 255, 255, 255)
|
||||
end
|
||||
|
||||
-- http://wiki.interfaceware.com/534.html
|
||||
function string.split(s, d)
|
||||
local t = {}
|
||||
local i = 0
|
||||
local f
|
||||
local match = '(.-)' .. d .. '()'
|
||||
|
||||
if string.find(s, d) == nil then
|
||||
return {s}
|
||||
end
|
||||
|
||||
for sub, j in string.gmatch(s, match) do
|
||||
i = i + 1
|
||||
t[i] = sub
|
||||
f = j
|
||||
end
|
||||
|
||||
if i ~= 0 then
|
||||
t[i+1] = string.sub(s, f)
|
||||
end
|
||||
|
||||
return t
|
||||
end
|
||||
|
||||
function bit.status(num, digit)
|
||||
return bit.band(num, bit.lshift(1, digit)) ~= 0
|
||||
end
|
||||
|
||||
return STI
|
Loading…
Add table
Add a link
Reference in a new issue