1
0
Fork 0
mirror of https://github.com/Reuh/ubiquitousse.git synced 2025-10-27 17:19:31 +00:00
ubiquitousse/ldtk/ldtk.can

721 lines
22 KiB
Text

--- [LDtk](https://ldtk.io/) level importer for Lua and drawing using LÖVE.
-- Support most LDtk features, and allow easy usage in LÖVE projects.
--
-- Every unit is in pixel in the API unless written otherwise.
-- Colors are reprsented as a table `{r,g,b}` where `r`,`b`,`g` in [0-1].
--
-- This modules returns a single function, @{LDtk}(path).
--
-- No mandatory dependency.
-- Optionally requires LÖVE `love.graphics` (drawing Image, SpriteBatch, Quad) for drawing only.
--
-- @module ldtk
-- @require love
-- @usage
-- local ldtk = require("ubiquitousse.ldtk")
--
-- -- load ldtk project file
-- local project = ldtk("example.ldtk")
--
-- -- can define callbacks when loading: for example to setup entities defined in LDtk
-- local callbacks = {
-- onAddEntity = function(entity)
-- -- handle entity...
-- end
-- }
--
-- -- load every level, with callbacks
-- for _, lvl in ipairs(project.levels) do lvl:load(callbacks) end
--
-- function love.draw()
-- -- draw every level
-- for _, lvl in ipairs(project.levels) do lvl:draw() end
-- end
-- TODO: give associated tile & color with enum values, also give enum info
--- LÖVE wrappers/placeholder
let lg = (love or {}).graphics
let newQuad
if lg then
newQuad = lg.newQuad
else
newQuad = (x, y, w , h, image)
return { x, y, w, h }
end
end
--- json helpers
let json_decode = require((...):gsub("ldtk$", "json")).decode
let readJson = (file)
let f = assert(io.open(file, "r"))
local t = json_decode(f:read("*a"))
f:close()
return t
end
--- color helpers
let parseColor = (str)
local r, g, b = str:match("^#(..)(..)(..)")
r, g, b = tonumber(r, 16), tonumber(g, 16), tonumber(b, 16)
return { r/255, g/255, b/255 }
end
let white = {1,1,1}
--- returns a lua table from some fieldInstances
let toLua = (type, val)
if val == nil then return val end
if type:match("^Array%<") then
local itype = type:match("^Array%<(.*)%>$")
for i, v in ipairs(val) do
val[i] = toLua(itype, v)
end
elseif type == "Color" then
return parseColor(val)
elseif type == "Point" then
return { x = val.cx, y = val.cy }
end
return val
end
let getFields = (f)
local t = {}
for _, v in ipairs(f) do
t[v.__identifier] = toLua(v.__type, v.__value)
end
return t
end
let tileset_mt
--- cached values
let make_cache = (new_fn)
return setmetatable({}, {
__mode = "v",
__call = (cache, id)
if not cache[id] then
cache[id] = new_fn(id)
end
return cache[id]
end
})
end
let cache = {
tileset = make_cache((tilesetDef)
return tileset_mt._init(tilesetDef)
end),
image = make_cache((path)
if lg then
return lg.newImage(path)
else
return path
end
end),
}
--- Tileset object.
-- Stores the image associated with the tileset; can be shared among several layers and levels.
-- @type Tileset
tileset_mt = {
_newQuad = :(x, y, width, height)
return newQuad(x, y, width, height, @image)
end,
_getTileQuad = :(tileid, x, y, size)
if not @_tileQuads[tileid] then
@_tileQuads[tileid] = @_newQuad(x, y, size, size)
end
return @_tileQuads[tileid]
end,
_init = (tilesetDef)
local t = {
--- The tileset LÖVE image object.
-- If LÖVE is not available, this is the path to the image (string).
image = cache.image(tilesetDef.path),
_tileQuads = {}
}
return setmetatable(t, tileset_mt)
end
}
tileset_mt.__index = tileset_mt
--- Layer object.
--
-- Part of a @{Level}.
--
-- @type Layer
let layer_mt = {
--- Draw the current layer.
--
-- Assumes we are currently in level coordinates (i.e. level top-left is at 0,0).
-- You can specify an offset if your level top-left coordinate is not at 0,0 (or to produce other effects).
-- @number[opt=0] x offset X position to draw the layer at
-- @number[opt=0] y offset Y position to draw the layer at
-- @require love
draw = :(x=0, y=0)
if @visible then
lg.push()
lg.translate(x + @x, y + @y)
if @spritebatch then
lg.setColor(1, 1, 1, @opacity)
lg.draw(@spritebatch)
elseif @intTiles then
for _, t in ipairs(@intTiles) do
lg.setColor(t.color)
lg.rectangle("fill", t.x, t.y, t.layer.gridSize, t.layer.gridSize)
end
elseif @entities then
for _, e in ipairs(@entities) do
if e.draw then e:draw() end
end
end
lg.pop()
end
end,
_unloadCallbacks = :(callbacks)
local onRemoveTile = callbacks.onRemoveTile
if @tiles and onRemoveTile then
for _, t in ipairs(@tiles) do
onRemoveTile(t)
end
end
local onRemoveIntTile = callbacks.onRemoveIntTile
if @intTiles and onRemoveIntTile then
for _, t in ipairs(@intTiles) do
onRemoveIntTile(t)
end
end
local onRemoveEntity = callbacks.onRemoveEntity
if @entities and onRemoveEntity then
for _, e in ipairs(@entities) do
onRemoveEntity(e)
end
end
end,
_init = (layer, level, order, callbacks)
let gridSize = layer.__gridSize
let t = {
--- `Level` this layer belongs to.
-- @ftype Level
level = level,
--- The layer name.
-- @ftype string
identifier = layer.__identifier,
--- Type of layer: IntGrid, Entities, Tiles or AutoLayer.
-- @ftype string
type = layer.__type,
--- Whether the layer is visible or not.
-- @ftype boolean
visible = layer.visible,
--- The layer opacity (0-1).
-- @ftype number
opacity = layer.opacity,
--- The layer order: smaller order means it is on top.
-- @ftype number
order = order,
--- X position of the layer relative to the level.
-- @ftype number
x = layer.__pxTotalOffsetX,
--- Y position of the layer relative to the level.
-- @ftype number
y = layer.__pxTotalOffsetY,
--- Size of the grid on this layer.
-- @ftype number
gridSize = gridSize,
--- Width of the layer, in grid units.
-- @ftype number
gridWidth = layer.__cWid,
--- Height of the layer, in grid units.
-- @ftype number
gridHeight = layer.__cHei,
--- _(Entities layer only)_ List of `Entity` in the layer.
-- @ftype {Entity,...}
entities = nil,
--- _(Tiles, AutoLayer, or IntGrid with AutoLayer rules layers only)_ List of `Tile`s in the layer.
-- @ftype {Tile,...}
-- @ftype nil if not applicable
tiles = nil,
--- _(Tiles, AutoLayer, or IntGrid with AutoLayer rules layers only)_ `Tileset` object associated with the layer.
-- @ftype Tileset
-- @ftype nil if not applicable
tileset = nil,
--- _(Tiles, AutoLayer, or IntGrid with AutoLayer rules layers only)_ [LÖVE SpriteBatch](https://love2d.org/wiki/SpriteBatch) containing the layer.
-- @ftype SpriteBatch
-- @ftype nil if LÖVE not available.
-- @require love
spritebatch = nil,
--- _(IntGrid without AutoLayer rules layer only)_ list of `IntTile`s in the layer.
-- @ftype {IntTile,...}
-- @ftype nil if not applicable
intTiles = nil,
}
-- Layers with an associated tileset (otherwise ignore as there is nothing to draw) (Tiles, AutoLayer & IntGrid with AutoLayer rules)
if layer.__tilesetDefUid then
t.tiles = {}
local tilesetData = level.project._tilesetData[layer.__tilesetDefUid]
t.tileset = cache.tileset(tilesetData)
local tiles = layer.__type == "Tiles" and layer.gridTiles or layer.autoLayerTiles
local onAddTile = callbacks.onAddTile
if lg then t.spritebatch = lg.newSpriteBatch(t.tileset.image) end
for _, tl in ipairs(tiles) do
let quad = t.tileset:_getTileQuad(tl.t, tl.src[1], tl.src[2], gridSize)
let sx, sy = 1, 1
let x, y = tl.px[1], tl.px[2]
--- Tile object.
--
-- This represent the tiles from a Tiles, AutoLayer or IntGrid with AutoLayer rules layer.
--
-- Can be retrived from the `Layer.tiles` list or `onAddTile` level load callback.
--
-- @type Tile
let tile = {
--- `Layer` the tile belongs to.
-- @ftype Layer
layer = t,
--- X position of the tile relative to the layer.
-- @ftype number
x = x,
--- Y position of the tile relative to the layer.
-- @ftype number
y = y,
--- Whether the tile is flipped horizontally.
-- @ftype boolean
flipX = false,
--- Whether the tile is flipped vertically.
-- @ftype boolean
flipY = false,
--- Tags associated with the tile: can be used either as a list of tags or a map of activated tags tags[name] == true.
-- @ftype {"tag",["tag"]=true,...}
tags = tilesetData[tl.t].tags,
--- Custom data associated with the tile, if any.
-- @ftype string
data = tilesetData[tl.t].data,
--- Quad associated with the tile (relative to the layer's tileset).
-- @ftype LÖVE Quad if LÖVE is available
-- @ftype table { x, y, width, height } if LÖVE not available
quad = quad
}
if tl.f == 1 or tl.f == 3 then
sx = -1
x += gridSize
tile.flipX = true
end
if tl.f == 2 or tl.f == 3 then
sy = -1
y += gridSize
tile.flipY = true
end
if t.spritebatch then t.spritebatch:add(quad, x, y, 0, sx, sy) end
table.insert(t.tiles, tile)
if onAddTile then onAddTile(tile) end
end
-- IntGrid
elseif layer.__type == "IntGrid" then
t.intTiles = {}
local onAddIntTile = callbacks.onAddIntTile
local values = level.project._layerDef[layer.layerDefUid].intGridValues
for i, tl in ipairs(layer.intGridCsv) do
if tl > 0 then
let y = math.floor((i-1) / t.gridWidth) * gridSize
let x = ((i-1) % t.gridWidth) * gridSize
--- IntTile object.
--
-- This represent the tiles from a IntGrid without AutoLayer rules layer.
--
-- Can be retrived from the @{intTiles} list or `onAddIntTile` level load callback.
--
-- @type IntTile
let tile = {
--- `Layer` the IntTile belongs to.
-- @ftype Layer
layer = t,
--- X position of the IntTile relative to the layer.
-- @ftype number
x = x,
--- Y position of the IntTile relative to the layer.
-- @ftype number
y = y,
--- Name of the IntTile.
-- @ftype string
identifier = values[tl].identifier,
--- Integer value of the IntTile.
-- @ftype number
value = tl,
--- Color of the IntTile.
-- @ftype table {r,g,b} with r,g,b in [0-1]
color = values[tl].color
}
table.insert(t.intTiles, tile)
if onAddIntTile then onAddIntTile(tile) end
end
end
end
-- Entities layers
if layer.__type == "Entities" then
t.entities = {}
local onAddEntity = callbacks.onAddEntity
for _, e in ipairs(layer.entityInstances) do
let entityDef = level.project._entityData[e.defUid]
--- Entity object.
--
-- This represent an entity from an Entities layer.
--
-- Can be retrived from the @{entities} list or `onAddEntity` level load callback.
--
-- @type Entity
let entity = {
--- `Layer` this entity belongs to.
-- @ftype Layer
layer = t,
--- The entity name.
-- @ftype string
identifier = e.__identifier,
--- X position of the entity relative to the layer.
-- @ftype number
x = e.px[1],
--- Y position of the entity relative to the layer.
-- @ftype number
y = e.px[2],
--- The entity width.
-- @ftype number
width = e.width,
--- The entity height.
-- @ftype number
height = e.height,
--- Scale factor on x axis relative to original entity size.
-- @ftype number
sx = e.width / entityDef.width,
--- Scale factor on y axis relative to original entity size.
-- @ftype number
sy = e.height / entityDef.height,
--- The entity pivot point x position relative to the entity.
-- @ftype number
pivotX = e.__pivot[1] * e.width,
--- The entity pivot point x position relative to the entity.
-- @ftype number
pivotY = e.__pivot[2] * e.height,
--- Entity color.
-- @ftype table {r,g,b} with r,g,b in [0-1]
color = entityDef.color,
--- Tile associated with the entity, if any. Is a table { tileset = associated tileset object, quad = associated quad }.
-- `quad` is a LÖVE Quad if LÖVE is available, otherwise a table `{ x, y, width, height }`.
-- @ftype table
tile = nil,
--- Map of `CustomFields` of the entity.
-- @ftype CustomFields
fields = getFields(e.fieldInstances),
--- Called for the entity when drawing the associated entity layer (you will likely want to redefine it).
--
-- By default, this draws the tile associated with the entity if there is one, or a rectangle around the entity position otherwise,
-- assuming we are currently in layer coordinates (i.e. layer top-left is at 0,0).
-- @require love
draw = :()
if @tile then
let _, _, w, h = @tile.quad:getViewport()
lg.setColor(white)
lg.draw(@tile.tileset.image, @tile.quad, @x-@pivotX, @y-@pivotY, 0, @width / w, @height / h)
else
lg.setColor(@color)
lg.rectangle("line", @x-@pivotX, @y-@pivotY, @width, @height)
end
end
}
if e.__tile then
local tileset = cache.tileset(level.project._tilesetData[e.__tile.tilesetUid])
local srcRect = e.__tile.srcRect
local quad = tileset:_newQuad(srcRect[1], srcRect[2], srcRect[3], srcRect[4])
entity.tile = {
tileset = tileset,
quad = quad
}
end
table.insert(t.entities, entity)
if onAddEntity then onAddEntity(entity) end
end
end
return setmetatable(t, layer_mt)
end
}
layer_mt.__index = layer_mt
--- Level object.
--
-- Levels are not automatically loaded in order to not waste ressources if your project is large; so before drawing or operating on a level, you will need to call its @{Level:load} method.
--
-- Part of a @{Project}.
--
-- @type Level
let level_mt = {
--- Draw this level.
-- Will draw the eventual backgrounds and all the layers in the level.
--
-- Assumes we are currently in world coordinates (i.e. world top-left is at 0,0).
-- You can specify an offset if your world top-left coordinate is not at 0,0 (or to produce other effects).
--
-- The level must be loaded.
-- @number[opt=0] x offset X position to draw the level at
-- @number[opt=0] y offset Y position to draw the level at
-- @require love
draw = :(x=0, y=0)
assert(@loaded == true, "level not loaded")
lg.push()
lg.translate(x + @x, y + @y)
@drawBackground()
-- layers
for _, l in ipairs(@layers) do
l:draw()
end
lg.pop()
end,
--- Draw this level background.
--
-- Assumes we are currently in level coordinates (i.e. level top-left is at 0,0).
-- You can specify an offset if your level top-left coordinate is not at 0,0 (or to produce other effects).
--
-- The level must be loaded.
-- @number[opt=0] x offset X position to draw the background at
-- @number[opt=0] y offset Y position to draw the backgroud at
-- @require love
drawBackground = :(x=0, y=0)
assert(@loaded == true, "level not loaded")
-- background color
lg.setColor(@background.color)
lg.rectangle("fill", x, y, @width, @height)
-- background image
lg.setColor(white)
let bgImage = @background.image
if bgImage then
lg.draw(bgImage.image, bgImage.quad, x + bgImage.x, y + bgImage.y, 0, bgImage.sx, bgImage.sy)
end
end,
--- Load the level.
-- Will load every layer in the level and the associated images.
--
-- You can optionally specify some callbacks for the loading process:
--
-- * `onAddLayer(layer)` will be called for every new layer loaded, with the @{Layer} as sole argument
-- * `onAddTile(tile)` will be called for every new tile loaded, with the @{Tile} as sole argument
-- * `onAddIntTile(tile)` will be called for every new IntGrid tile loaded, with the @{IntTile} as sole argument
-- * `onAddEntity(entity)` will be called for every new entity loaded, with the @{Entity} as sole argument
--
-- These callbacks should allow you to capture all the important elements needed to use the level, so you can hopefully
-- integrate it into your current game engine easily.
--
-- @tparam[opt] table callbacks
load = :(callbacks={})
assert(@loaded == false, "level already loaded")
if @_json.bgRelPath then
let pos = @_json.__bgPos
let cropRect = pos.cropRect
let image = cache.image(@project._directory..@_json.bgRelPath)
@background.image = {
image = image,
quad = newQuad(cropRect[1], cropRect[2], cropRect[3], cropRect[4], image),
x = pos.topLeftPx[1],
y = pos.topLeftPx[2],
sx = pos.scale[1],
sy = pos.scale[1]
}
end
let layerInstances
if @_json.externalRelPath then
layerInstances = readJson(@project._directory..@_json.externalRelPath).layerInstances
else
layerInstances = @_json.layerInstances
end
@layers = {}
let onAddLayer = callbacks.onAddLayer
for i=#layerInstances, 1, -1 do
local layer = layer_mt._init(layerInstances[i], @, i, callbacks)
table.insert(@layers, layer)
if onAddLayer then onAddLayer(layer) end
end
@loaded = true
end,
--- Unload the level.
-- Images loaded by the level will be freed on the next garbage collection cycle.
--
-- You can optionally specify some callbacks for the unloading process:
--
-- * `onAddLayer(layer)` will be called for every new layer unloaded, with the @{Layer} as sole argument
-- * `onAddTile(tile)` will be called for every new tile unloaded, with the @{Tile} as sole argument
-- * `onAddIntTile(tile)` will be called for every new IntGrid tile unloaded, with the @{IntTile} as sole argument
-- * `onAddEntity(entity)` will be called for every new entity unloaded, with the @{Entity} as sole argument
--
-- @tparam[opt] table callbacks
unload = :(callbacks={})
assert(@loaded == true, "level not loaded")
let onRemoveLayer = callbacks.onRemoveLayer
for _, l in ipairs(@layers) do
l:_unloadCallbacks(callbacks)
if onRemoveLayer then onRemoveLayer(l) end
end
@loaded = false
@background.image = nil
@layers = nil
end,
_init = (level, project)
let t = {
--- `Project` this level belongs to.
-- @ftype Project
project = project,
--- Whether this level is currently loaded or not.
-- @ftype boolean
loaded = false,
--- The level name.
-- @ftype string
identifier = level.identifier,
--- The level x position.
-- @ftype number
x = level.worldX,
--- The level y position.
-- @ftype number
y = level.worldY,
--- The level width.
-- @ftype number
width = level.pxWid,
--- The level height.
-- @ftype number
height = level.pxHei,
--- Map of `CustomFields` of the level (table).
-- @ftype CustomFields
fields = getFields(level.fieldInstances),
--- List of `Layer`s in the level (table).
-- @ftype {Layer,...}
layers = nil,
--- Level background.
--
-- If there is a background image, `background.image` contains a table `{image=image, x=number, y=number, sx=number, sy=number}`
-- where `image` is the LÖVE image (or image filepath if LÖVE not available) `x` and `y` are the top-left position,
-- and `sx` and `sy` the horizontal and vertical scale factors.
-- @field color backrgound color {r,g,b} with r,g,b in [0-1]
-- @field image backrgound image information, if any
background = {
color = parseColor(level.__bgColor),
image = nil,
},
-- private
_json = level,
}
return setmetatable(t, level_mt)
end
}
level_mt.__index = level_mt
--- Project object.
--
-- Returned by @{LDtk}.
--
-- @type Project
let project_mt = {
_init = (project, directory)
assert(project.jsonVersion == "0.9.3", "the map was made with LDtk version %s but the importer is made for 0.9.3":format(project.jsonVersion))
let t = {
--- List of `Level`s in this project.
-- @ftype {Level,...}
levels = nil,
-- private
_directory = directory,
_layerDef = nil,
_tilesetData = nil,
_entityData = nil,
}
t.levels = [
for _, lvl in ipairs(project.levels) do
push level_mt._init(lvl, t)
end
]
t._tilesetData = [
for _, ts in ipairs(project.defs.tilesets) do
@[ts.uid] = {
path = directory..ts.relPath
}
local tilesetData = @[ts.uid]
for gridx=0, ts.__cWid-1 do
for gridy=0, ts.__cHei-1 do
tilesetData[gridx + gridy * ts.__cWid] = {
tags = {},
data = nil
}
end
end
for _, custom in ipairs(ts.customData) do
tilesetData[custom.tileId].data = custom.data
end
for _, tag in ipairs(ts.enumTags) do
local value = tag.enumValueId
for _, tileId in ipairs(tag.tileIds) do
table.insert(tilesetData[tileId].tags, value)
tilesetData[tileId].tags[value] = true
end
end
end
]
t._layerDef = [
for _, lay in ipairs(project.defs.layers) do
@[lay.uid] = {
intGridValues = nil
}
local layerDef = @[lay.uid]
if lay.__type == "IntGrid" then
layerDef.intGridValues = [
for _, v in ipairs(lay.intGridValues) do
@[v.value] = {
color = parseColor(v.color),
identifier = v.identifier
}
end
]
end
end
]
t._entityData = [
for _, ent in ipairs(project.defs.entities) do
@[ent.uid] = {
color = parseColor(ent.color),
width = ent.width,
height = ent.height
}
end
]
return setmetatable(t, project_mt)
end
}
project_mt.__index = project_mt
--- Custom fields: map of each field name to its value.
--
-- LDtk allows to defined custom fields in some places (`Entity.fields`, `Level.fields`). This library allows you to access them in a table that
-- map each field name to its value `{["fieldName"]=value,...}`.
--
-- @type CustomFields
--- Type conversion.
--
-- Here is how the values are converted to Lua values:
--
-- * Integers, Floats are converted into a Lua number.
-- * Booleans are converted into a Lua boolean.
-- * Strings, Multilines are converted in a Lua string.
-- * Enum are converted into a Lua string giving the currently selected enum value.
-- * Filepath are converted into a Lua string giving the file path.
-- * Arrays are converted into a Lua table with the elements in it as a list.
-- * Points are converted into a Lua table with the fields `x` and `y`: `{ x=number, y=number }`.
-- * Colors are converted into a Lua table with the red, green and blue components in [0-1] as a list: `{r,g,b}`.
-- @doc conversion
--- LDtk module.
-- `ubiquitousse.ldtk` returns a single function, @{LDtk}.
-- @section end
--- Load a LDtk project.
-- @string path to LDtk project file (.ldtk)
-- @treturn Project the loaded LDtk project
-- @function LDtk
return (file)
return project_mt._init(readJson(file), file:match("^(.-)[^%/%\\]+$"))
end