commit a557312c7f68cf79825ae2de2d9c2a2326f121fb Author: Alexander Griffith Date: Wed Oct 17 21:32:39 2018 -0400 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c0ff56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +/releases \ No newline at end of file diff --git a/changelog.org b/changelog.org new file mode 100644 index 0000000..1b7ceeb --- /dev/null +++ b/changelog.org @@ -0,0 +1,4 @@ +* 2018-10-16 Alexander Griffith + - Updated love version in love-release.sh to 11.1 + - Added libs fennelview, lume & stdio + diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..274ef20 --- /dev/null +++ b/conf.lua @@ -0,0 +1,10 @@ +love.conf = function(t) + t.gammacorrect = true + t.title, t.identity = "Minimal", "Minimal" + t.modules.joystick = false + t.modules.physics = false + t.window.width = 720 + t.window.height = 450 + t.window.vsync = false + t.version = "11.1" +end diff --git a/lib/bump.lua b/lib/bump.lua new file mode 100644 index 0000000..6dabca7 --- /dev/null +++ b/lib/bump.lua @@ -0,0 +1,773 @@ +local bump = { + _VERSION = 'bump v3.1.7', + _URL = 'https://github.com/kikito/bump.lua', + _DESCRIPTION = 'A collision detection library for Lua', + _LICENSE = [[ + MIT LICENSE + + Copyright (c) 2014 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. + ]] +} + +------------------------------------------ +-- Auxiliary functions +------------------------------------------ +local DELTA = 1e-10 -- floating-point margin of error + +local abs, floor, ceil, min, max = math.abs, math.floor, math.ceil, math.min, math.max + +local function sign(x) + if x > 0 then return 1 end + if x == 0 then return 0 end + return -1 +end + +local function nearest(x, a, b) + if abs(a - x) < abs(b - x) then return a else return b end +end + +local function assertType(desiredType, value, name) + if type(value) ~= desiredType then + error(name .. ' must be a ' .. desiredType .. ', but was ' .. tostring(value) .. '(a ' .. type(value) .. ')') + end +end + +local function assertIsPositiveNumber(value, name) + if type(value) ~= 'number' or value <= 0 then + error(name .. ' must be a positive integer, but was ' .. tostring(value) .. '(' .. type(value) .. ')') + end +end + +local function assertIsRect(x,y,w,h) + assertType('number', x, 'x') + assertType('number', y, 'y') + assertIsPositiveNumber(w, 'w') + assertIsPositiveNumber(h, 'h') +end + +local defaultFilter = function() + return 'slide' +end + +------------------------------------------ +-- Rectangle functions +------------------------------------------ + +local function rect_getNearestCorner(x,y,w,h, px, py) + return nearest(px, x, x+w), nearest(py, y, y+h) +end + +-- This is a generalized implementation of the liang-barsky algorithm, which also returns +-- the normals of the sides where the segment intersects. +-- Returns nil if the segment never touches the rect +-- Notice that normals are only guaranteed to be accurate when initially ti1, ti2 == -math.huge, math.huge +local function rect_getSegmentIntersectionIndices(x,y,w,h, x1,y1,x2,y2, ti1,ti2) + ti1, ti2 = ti1 or 0, ti2 or 1 + local dx, dy = x2-x1, y2-y1 + local nx, ny + local nx1, ny1, nx2, ny2 = 0,0,0,0 + local p, q, r + + for side = 1,4 do + if side == 1 then nx,ny,p,q = -1, 0, -dx, x1 - x -- left + elseif side == 2 then nx,ny,p,q = 1, 0, dx, x + w - x1 -- right + elseif side == 3 then nx,ny,p,q = 0, -1, -dy, y1 - y -- top + else nx,ny,p,q = 0, 1, dy, y + h - y1 -- bottom + end + + if p == 0 then + if q <= 0 then return nil end + else + r = q / p + if p < 0 then + if r > ti2 then return nil + elseif r > ti1 then ti1,nx1,ny1 = r,nx,ny + end + else -- p > 0 + if r < ti1 then return nil + elseif r < ti2 then ti2,nx2,ny2 = r,nx,ny + end + end + end + end + + return ti1,ti2, nx1,ny1, nx2,ny2 +end + +-- Calculates the minkowsky difference between 2 rects, which is another rect +local function rect_getDiff(x1,y1,w1,h1, x2,y2,w2,h2) + return x2 - x1 - w1, + y2 - y1 - h1, + w1 + w2, + h1 + h2 +end + +local function rect_containsPoint(x,y,w,h, px,py) + return px - x > DELTA and py - y > DELTA and + x + w - px > DELTA and y + h - py > DELTA +end + +local function rect_isIntersecting(x1,y1,w1,h1, x2,y2,w2,h2) + return x1 < x2+w2 and x2 < x1+w1 and + y1 < y2+h2 and y2 < y1+h1 +end + +local function rect_getSquareDistance(x1,y1,w1,h1, x2,y2,w2,h2) + local dx = x1 - x2 + (w1 - w2)/2 + local dy = y1 - y2 + (h1 - h2)/2 + return dx*dx + dy*dy +end + +local function rect_detectCollision(x1,y1,w1,h1, x2,y2,w2,h2, goalX, goalY) + goalX = goalX or x1 + goalY = goalY or y1 + + local dx, dy = goalX - x1, goalY - y1 + local x,y,w,h = rect_getDiff(x1,y1,w1,h1, x2,y2,w2,h2) + + local overlaps, ti, nx, ny + + if rect_containsPoint(x,y,w,h, 0,0) then -- item was intersecting other + local px, py = rect_getNearestCorner(x,y,w,h, 0, 0) + local wi, hi = min(w1, abs(px)), min(h1, abs(py)) -- area of intersection + ti = -wi * hi -- ti is the negative area of intersection + overlaps = true + else + local ti1,ti2,nx1,ny1 = rect_getSegmentIntersectionIndices(x,y,w,h, 0,0,dx,dy, -math.huge, math.huge) + + -- item tunnels into other + if ti1 + and ti1 < 1 + and (abs(ti1 - ti2) >= DELTA) -- special case for rect going through another rect's corner + and (0 < ti1 + DELTA + or 0 == ti1 and ti2 > 0) + then + ti, nx, ny = ti1, nx1, ny1 + overlaps = false + end + end + + if not ti then return end + + local tx, ty + + if overlaps then + if dx == 0 and dy == 0 then + -- intersecting and not moving - use minimum displacement vector + local px, py = rect_getNearestCorner(x,y,w,h, 0,0) + if abs(px) < abs(py) then py = 0 else px = 0 end + nx, ny = sign(px), sign(py) + tx, ty = x1 + px, y1 + py + else + -- intersecting and moving - move in the opposite direction + local ti1, _ + ti1,_,nx,ny = rect_getSegmentIntersectionIndices(x,y,w,h, 0,0,dx,dy, -math.huge, 1) + if not ti1 then return end + tx, ty = x1 + dx * ti1, y1 + dy * ti1 + end + else -- tunnel + tx, ty = x1 + dx * ti, y1 + dy * ti + end + + return { + overlaps = overlaps, + ti = ti, + move = {x = dx, y = dy}, + normal = {x = nx, y = ny}, + touch = {x = tx, y = ty}, + itemRect = {x = x1, y = y1, w = w1, h = h1}, + otherRect = {x = x2, y = y2, w = w2, h = h2} + } +end + +------------------------------------------ +-- Grid functions +------------------------------------------ + +local function grid_toWorld(cellSize, cx, cy) + return (cx - 1)*cellSize, (cy-1)*cellSize +end + +local function grid_toCell(cellSize, x, y) + return floor(x / cellSize) + 1, floor(y / cellSize) + 1 +end + +-- grid_traverse* functions are based on "A Fast Voxel Traversal Algorithm for Ray Tracing", +-- by John Amanides and Andrew Woo - http://www.cse.yorku.ca/~amana/research/grid.pdf +-- It has been modified to include both cells when the ray "touches a grid corner", +-- and with a different exit condition + +local function grid_traverse_initStep(cellSize, ct, t1, t2) + local v = t2 - t1 + if v > 0 then + return 1, cellSize / v, ((ct + v) * cellSize - t1) / v + elseif v < 0 then + return -1, -cellSize / v, ((ct + v - 1) * cellSize - t1) / v + else + return 0, math.huge, math.huge + end +end + +local function grid_traverse(cellSize, x1,y1,x2,y2, f) + local cx1,cy1 = grid_toCell(cellSize, x1,y1) + local cx2,cy2 = grid_toCell(cellSize, x2,y2) + local stepX, dx, tx = grid_traverse_initStep(cellSize, cx1, x1, x2) + local stepY, dy, ty = grid_traverse_initStep(cellSize, cy1, y1, y2) + local cx,cy = cx1,cy1 + + f(cx, cy) + + -- The default implementation had an infinite loop problem when + -- approaching the last cell in some occassions. We finish iterating + -- when we are *next* to the last cell + while abs(cx - cx2) + abs(cy - cy2) > 1 do + if tx < ty then + tx, cx = tx + dx, cx + stepX + f(cx, cy) + else + -- Addition: include both cells when going through corners + if tx == ty then f(cx + stepX, cy) end + ty, cy = ty + dy, cy + stepY + f(cx, cy) + end + end + + -- If we have not arrived to the last cell, use it + if cx ~= cx2 or cy ~= cy2 then f(cx2, cy2) end + +end + +local function grid_toCellRect(cellSize, x,y,w,h) + local cx,cy = grid_toCell(cellSize, x, y) + local cr,cb = ceil((x+w) / cellSize), ceil((y+h) / cellSize) + return cx, cy, cr - cx + 1, cb - cy + 1 +end + +------------------------------------------ +-- Responses +------------------------------------------ + +local touch = function(world, col, x,y,w,h, goalX, goalY, filter) + return col.touch.x, col.touch.y, {}, 0 +end + +local cross = function(world, col, x,y,w,h, goalX, goalY, filter) + local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter) + return goalX, goalY, cols, len +end + +local slide = function(world, col, x,y,w,h, goalX, goalY, filter) + goalX = goalX or x + goalY = goalY or y + + local tch, move = col.touch, col.move + if move.x ~= 0 or move.y ~= 0 then + if col.normal.x ~= 0 then + goalX = tch.x + else + goalY = tch.y + end + end + + col.slide = {x = goalX, y = goalY} + + x,y = tch.x, tch.y + local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter) + return goalX, goalY, cols, len +end + +local bounce = function(world, col, x,y,w,h, goalX, goalY, filter) + goalX = goalX or x + goalY = goalY or y + + local tch, move = col.touch, col.move + local tx, ty = tch.x, tch.y + + local bx, by = tx, ty + + if move.x ~= 0 or move.y ~= 0 then + local bnx, bny = goalX - tx, goalY - ty + if col.normal.x == 0 then bny = -bny else bnx = -bnx end + bx, by = tx + bnx, ty + bny + end + + col.bounce = {x = bx, y = by} + x,y = tch.x, tch.y + goalX, goalY = bx, by + + local cols, len = world:project(col.item, x,y,w,h, goalX, goalY, filter) + return goalX, goalY, cols, len +end + +------------------------------------------ +-- World +------------------------------------------ + +local World = {} +local World_mt = {__index = World} + +-- Private functions and methods + +local function sortByWeight(a,b) return a.weight < b.weight end + +local function sortByTiAndDistance(a,b) + if a.ti == b.ti then + local ir, ar, br = a.itemRect, a.otherRect, b.otherRect + local ad = rect_getSquareDistance(ir.x,ir.y,ir.w,ir.h, ar.x,ar.y,ar.w,ar.h) + local bd = rect_getSquareDistance(ir.x,ir.y,ir.w,ir.h, br.x,br.y,br.w,br.h) + return ad < bd + end + return a.ti < b.ti +end + +local function addItemToCell(self, item, cx, cy) + self.rows[cy] = self.rows[cy] or setmetatable({}, {__mode = 'v'}) + local row = self.rows[cy] + row[cx] = row[cx] or {itemCount = 0, x = cx, y = cy, items = setmetatable({}, {__mode = 'k'})} + local cell = row[cx] + self.nonEmptyCells[cell] = true + if not cell.items[item] then + cell.items[item] = true + cell.itemCount = cell.itemCount + 1 + end +end + +local function removeItemFromCell(self, item, cx, cy) + local row = self.rows[cy] + if not row or not row[cx] or not row[cx].items[item] then return false end + + local cell = row[cx] + cell.items[item] = nil + cell.itemCount = cell.itemCount - 1 + if cell.itemCount == 0 then + self.nonEmptyCells[cell] = nil + end + return true +end + +local function getDictItemsInCellRect(self, cl,ct,cw,ch) + local items_dict = {} + for cy=ct,ct+ch-1 do + local row = self.rows[cy] + if row then + for cx=cl,cl+cw-1 do + local cell = row[cx] + if cell and cell.itemCount > 0 then -- no cell.itemCount > 1 because tunneling + for item,_ in pairs(cell.items) do + items_dict[item] = true + end + end + end + end + end + + return items_dict +end + +local function getCellsTouchedBySegment(self, x1,y1,x2,y2) + + local cells, cellsLen, visited = {}, 0, {} + + grid_traverse(self.cellSize, x1,y1,x2,y2, function(cx, cy) + local row = self.rows[cy] + if not row then return end + local cell = row[cx] + if not cell or visited[cell] then return end + + visited[cell] = true + cellsLen = cellsLen + 1 + cells[cellsLen] = cell + end) + + return cells, cellsLen +end + +local function getInfoAboutItemsTouchedBySegment(self, x1,y1, x2,y2, filter) + local cells, len = getCellsTouchedBySegment(self, x1,y1,x2,y2) + local cell, rect, l,t,w,h, ti1,ti2, tii0,tii1 + local visited, itemInfo, itemInfoLen = {},{},0 + for i=1,len do + cell = cells[i] + for item in pairs(cell.items) do + if not visited[item] then + visited[item] = true + if (not filter or filter(item)) then + rect = self.rects[item] + l,t,w,h = rect.x,rect.y,rect.w,rect.h + + ti1,ti2 = rect_getSegmentIntersectionIndices(l,t,w,h, x1,y1, x2,y2, 0, 1) + if ti1 and ((0 < ti1 and ti1 < 1) or (0 < ti2 and ti2 < 1)) then + -- the sorting is according to the t of an infinite line, not the segment + tii0,tii1 = rect_getSegmentIntersectionIndices(l,t,w,h, x1,y1, x2,y2, -math.huge, math.huge) + itemInfoLen = itemInfoLen + 1 + itemInfo[itemInfoLen] = {item = item, ti1 = ti1, ti2 = ti2, weight = min(tii0,tii1)} + end + end + end + end + end + table.sort(itemInfo, sortByWeight) + return itemInfo, itemInfoLen +end + +local function getResponseByName(self, name) + local response = self.responses[name] + if not response then + error(('Unknown collision type: %s (%s)'):format(name, type(name))) + end + return response +end + + +-- Misc Public Methods + +function World:addResponse(name, response) + self.responses[name] = response +end + +function World:project(item, x,y,w,h, goalX, goalY, filter) + assertIsRect(x,y,w,h) + + goalX = goalX or x + goalY = goalY or y + filter = filter or defaultFilter + + local collisions, len = {}, 0 + + local visited = {} + if item ~= nil then visited[item] = true end + + -- This could probably be done with less cells using a polygon raster over the cells instead of a + -- bounding rect of the whole movement. Conditional to building a queryPolygon method + local tl, tt = min(goalX, x), min(goalY, y) + local tr, tb = max(goalX + w, x+w), max(goalY + h, y+h) + local tw, th = tr-tl, tb-tt + + local cl,ct,cw,ch = grid_toCellRect(self.cellSize, tl,tt,tw,th) + + local dictItemsInCellRect = getDictItemsInCellRect(self, cl,ct,cw,ch) + + for other,_ in pairs(dictItemsInCellRect) do + if not visited[other] then + visited[other] = true + + local responseName = filter(item, other) + if responseName then + local ox,oy,ow,oh = self:getRect(other) + local col = rect_detectCollision(x,y,w,h, ox,oy,ow,oh, goalX, goalY) + + if col then + col.other = other + col.item = item + col.type = responseName + + len = len + 1 + collisions[len] = col + end + end + end + end + + table.sort(collisions, sortByTiAndDistance) + + return collisions, len +end + +function World:countCells() + local count = 0 + for _,row in pairs(self.rows) do + for _,_ in pairs(row) do + count = count + 1 + end + end + return count +end + +function World:hasItem(item) + return not not self.rects[item] +end + +function World:getItems() + local items, len = {}, 0 + for item,_ in pairs(self.rects) do + len = len + 1 + items[len] = item + end + return items, len +end + +function World:countItems() + local len = 0 + for _ in pairs(self.rects) do len = len + 1 end + return len +end + +function World:getRect(item) + local rect = self.rects[item] + if not rect then + error('Item ' .. tostring(item) .. ' must be added to the world before getting its rect. Use world:add(item, x,y,w,h) to add it first.') + end + return rect.x, rect.y, rect.w, rect.h +end + +function World:toWorld(cx, cy) + return grid_toWorld(self.cellSize, cx, cy) +end + +function World:toCell(x,y) + return grid_toCell(self.cellSize, x, y) +end + + +--- Query methods + +function World:queryRect(x,y,w,h, filter) + + assertIsRect(x,y,w,h) + + local cl,ct,cw,ch = grid_toCellRect(self.cellSize, x,y,w,h) + local dictItemsInCellRect = getDictItemsInCellRect(self, cl,ct,cw,ch) + + local items, len = {}, 0 + + local rect + for item,_ in pairs(dictItemsInCellRect) do + rect = self.rects[item] + if (not filter or filter(item)) + and rect_isIntersecting(x,y,w,h, rect.x, rect.y, rect.w, rect.h) + then + len = len + 1 + items[len] = item + end + end + + return items, len +end + +function World:queryPoint(x,y, filter) + local cx,cy = self:toCell(x,y) + local dictItemsInCellRect = getDictItemsInCellRect(self, cx,cy,1,1) + + local items, len = {}, 0 + + local rect + for item,_ in pairs(dictItemsInCellRect) do + rect = self.rects[item] + if (not filter or filter(item)) + and rect_containsPoint(rect.x, rect.y, rect.w, rect.h, x, y) + then + len = len + 1 + items[len] = item + end + end + + return items, len +end + +function World:querySegment(x1, y1, x2, y2, filter) + local itemInfo, len = getInfoAboutItemsTouchedBySegment(self, x1, y1, x2, y2, filter) + local items = {} + for i=1, len do + items[i] = itemInfo[i].item + end + return items, len +end + +function World:querySegmentWithCoords(x1, y1, x2, y2, filter) + local itemInfo, len = getInfoAboutItemsTouchedBySegment(self, x1, y1, x2, y2, filter) + local dx, dy = x2-x1, y2-y1 + local info, ti1, ti2 + for i=1, len do + info = itemInfo[i] + ti1 = info.ti1 + ti2 = info.ti2 + + info.weight = nil + info.x1 = x1 + dx * ti1 + info.y1 = y1 + dy * ti1 + info.x2 = x1 + dx * ti2 + info.y2 = y1 + dy * ti2 + end + return itemInfo, len +end + + +--- Main methods + +function World:add(item, x,y,w,h) + local rect = self.rects[item] + if rect then + error('Item ' .. tostring(item) .. ' added to the world twice.') + end + assertIsRect(x,y,w,h) + + self.rects[item] = {x=x,y=y,w=w,h=h} + + local cl,ct,cw,ch = grid_toCellRect(self.cellSize, x,y,w,h) + for cy = ct, ct+ch-1 do + for cx = cl, cl+cw-1 do + addItemToCell(self, item, cx, cy) + end + end + + return item +end + +function World:remove(item) + local x,y,w,h = self:getRect(item) + + self.rects[item] = nil + local cl,ct,cw,ch = grid_toCellRect(self.cellSize, x,y,w,h) + for cy = ct, ct+ch-1 do + for cx = cl, cl+cw-1 do + removeItemFromCell(self, item, cx, cy) + end + end +end + +function World:update(item, x2,y2,w2,h2) + local x1,y1,w1,h1 = self:getRect(item) + w2,h2 = w2 or w1, h2 or h1 + assertIsRect(x2,y2,w2,h2) + + if x1 ~= x2 or y1 ~= y2 or w1 ~= w2 or h1 ~= h2 then + + local cellSize = self.cellSize + local cl1,ct1,cw1,ch1 = grid_toCellRect(cellSize, x1,y1,w1,h1) + local cl2,ct2,cw2,ch2 = grid_toCellRect(cellSize, x2,y2,w2,h2) + + if cl1 ~= cl2 or ct1 ~= ct2 or cw1 ~= cw2 or ch1 ~= ch2 then + + local cr1, cb1 = cl1+cw1-1, ct1+ch1-1 + local cr2, cb2 = cl2+cw2-1, ct2+ch2-1 + local cyOut + + for cy = ct1, cb1 do + cyOut = cy < ct2 or cy > cb2 + for cx = cl1, cr1 do + if cyOut or cx < cl2 or cx > cr2 then + removeItemFromCell(self, item, cx, cy) + end + end + end + + for cy = ct2, cb2 do + cyOut = cy < ct1 or cy > cb1 + for cx = cl2, cr2 do + if cyOut or cx < cl1 or cx > cr1 then + addItemToCell(self, item, cx, cy) + end + end + end + + end + + local rect = self.rects[item] + rect.x, rect.y, rect.w, rect.h = x2,y2,w2,h2 + + end +end + +function World:move(item, goalX, goalY, filter) + local actualX, actualY, cols, len = self:check(item, goalX, goalY, filter) + + self:update(item, actualX, actualY) + + return actualX, actualY, cols, len +end + +function World:check(item, goalX, goalY, filter) + filter = filter or defaultFilter + + local visited = {[item] = true} + local visitedFilter = function(itm, other) + if visited[other] then return false end + return filter(itm, other) + end + + local cols, len = {}, 0 + + local x,y,w,h = self:getRect(item) + + local projected_cols, projected_len = self:project(item, x,y,w,h, goalX,goalY, visitedFilter) + + while projected_len > 0 do + local col = projected_cols[1] + len = len + 1 + cols[len] = col + + visited[col.other] = true + + local response = getResponseByName(self, col.type) + + goalX, goalY, projected_cols, projected_len = response( + self, + col, + x, y, w, h, + goalX, goalY, + visitedFilter + ) + end + + return goalX, goalY, cols, len +end + + +-- Public library functions + +bump.newWorld = function(cellSize) + cellSize = cellSize or 64 + assertIsPositiveNumber(cellSize, 'cellSize') + local world = setmetatable({ + cellSize = cellSize, + rects = {}, + rows = {}, + nonEmptyCells = {}, + responses = {} + }, World_mt) + + world:addResponse('touch', touch) + world:addResponse('cross', cross) + world:addResponse('slide', slide) + world:addResponse('bounce', bounce) + + return world +end + +bump.rect = { + getNearestCorner = rect_getNearestCorner, + getSegmentIntersectionIndices = rect_getSegmentIntersectionIndices, + getDiff = rect_getDiff, + containsPoint = rect_containsPoint, + isIntersecting = rect_isIntersecting, + getSquareDistance = rect_getSquareDistance, + detectCollision = rect_detectCollision +} + +bump.responses = { + touch = touch, + cross = cross, + slide = slide, + bounce = bounce +} + +return bump diff --git a/lib/fennel b/lib/fennel new file mode 100755 index 0000000..8fd7652 --- /dev/null +++ b/lib/fennel @@ -0,0 +1,82 @@ +#!/usr/bin/env lua + +local fennel_dir = arg[0]:match("(.-)[^\\/]+$") +package.path = fennel_dir .. "?.lua;" .. package.path +local fennel = require('fennel') + +local help = [[ +Usage: fennel [FLAG] [FILE] + + --repl : Launch an interactive repl session + --compile FILES : Compile files and write their Lua to stdout + --help : Display this text + + When not given a flag, runs the file given as the first argument.]] + +local options = { + sourcemap = true, +} + +local function dosafe(filename, opts, arg1) + local ok, val = xpcall(function() + return fennel.dofile(filename, opts, arg1) + end, fennel.traceback) + if not ok then + print(val) + os.exit(1) + end + return val +end + +local compileOptHandlers = { + ['--indent'] = function () + options.indent = table.remove(arg, 3) + if options.indent == "false" then options.indent = false end + table.remove(arg, 2) + end, + ['--sourcemap'] = function () + options.sourcemap = table.remove(arg, 3) + if options.sourcemap == "false" then options.sourcemap = false end + table.remove(arg, 2) + end, + ['--correlate'] = function () + options.correlate = true + table.remove(arg, 2) + end, +} + +if arg[1] == "--repl" or #arg == 0 then + local ppok, pp = pcall(fennel.dofile, fennel_dir .. "fennelview.fnl", options) + if ppok then + options.pp = pp + end + local initFilename = (os.getenv("HOME") or "") .. "/.fennelrc" + local init = io.open(initFilename, "rb") + if init then + init:close() + -- pass in options so fennerlrc can make changes to it + dosafe(initFilename, options, options) + end + print("Welcome to fennel!") + fennel.repl(options) +elseif arg[1] == "--compile" then + -- Handle options + while compileOptHandlers[arg[2]] do + compileOptHandlers[arg[2]]() + end + for i = 2, #arg do + local f = assert(io.open(arg[i], "rb")) + options.filename=arg[i] + local ok, val = xpcall(function() + return fennel.compileString(f:read("*all"), options) + end, fennel.traceback) + print(val) + if not ok then os.exit(1) end + f:close() + end +elseif #arg >= 1 and arg[1] ~= "--help" then + local filename = table.remove(arg, 1) -- let the script have remaining args + dosafe(filename) +else + print(help) +end diff --git a/lib/fennel.lua b/lib/fennel.lua new file mode 100644 index 0000000..8b6d96a --- /dev/null +++ b/lib/fennel.lua @@ -0,0 +1,1785 @@ +--[[ +Copyright (c) 2016-2018 Calvin Rose and contributors +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. +]] + +-- Make global variables local. +local setmetatable = setmetatable +local getmetatable = getmetatable +local type = type +local assert = assert +local pairs = pairs +local ipairs = ipairs +local tostring = tostring +local unpack = unpack or table.unpack + +-- +-- Main Types and support functions +-- + +local function deref(self) return self[1] end + +local SYMBOL_MT = { 'SYMBOL', __tostring = deref } +local EXPR_MT = { 'EXPR', __tostring = deref } +local VARARG = setmetatable({ '...' }, { 'VARARG', __tostring = deref }) +local LIST_MT = { 'LIST', + __tostring = function (self) + local strs = {} + for _, s in ipairs(self) do + table.insert(strs, tostring(s)) + end + return '(' .. table.concat(strs, ', ', 1, #self) .. ')' + end +} + +-- Load code with an environment in all recent Lua versions +local function loadCode(code, environment, filename) + environment = environment or _ENV or _G + if setfenv and loadstring then + local f = assert(loadstring(code, filename)) + setfenv(f, environment) + return f + else + return assert(load(code, filename, "t", environment)) + end +end + +-- Create a new list +local function list(...) + return setmetatable({...}, LIST_MT) +end + +-- Create a new symbol +local function sym(str, scope, meta) + local s = {str, scope = scope} + if meta then + for k, v in pairs(meta) do + if type(k) == 'string' then s[k] = v end + end + end + return setmetatable(s, SYMBOL_MT) +end + +-- Create a new expr +-- etype should be one of +-- "literal", -- literals like numbers, strings, nil, true, false +-- "expression", -- Complex strings of Lua code, may have side effects, etc, but is an expression +-- "statement", -- Same as expression, but is also a valid statement (function calls). +-- "vargs", -- varargs symbol +-- "sym", -- symbol reference +local function expr(strcode, etype) + return setmetatable({ strcode, type = etype }, EXPR_MT) +end + +local function varg() + return VARARG +end + +local function isVarg(x) + return x == VARARG and x +end + +-- Checks if an object is a List. Returns the object if is a List. +local function isList(x) + return type(x) == 'table' and getmetatable(x) == LIST_MT and x +end + +-- Checks if an object is a symbol. Returns the object if it is a symbol. +local function isSym(x) + return type(x) == 'table' and getmetatable(x) == SYMBOL_MT and x +end + +-- Checks if an object any kind of table, EXCEPT list or symbol +local function isTable(x) + return type(x) == 'table' and + x ~= VARARG and + getmetatable(x) ~= LIST_MT and getmetatable(x) ~= SYMBOL_MT and x +end + +-- +-- Parser +-- + +-- Convert a stream of chunks to a stream of bytes. +-- Also returns a second function to clear the buffer in the byte stream +local function granulate(getchunk) + local c = '' + local index = 1 + local done = false + return function () + if done then return nil end + if index <= #c then + local b = c:byte(index) + index = index + 1 + return b + else + c = getchunk() + if not c or c == '' then + done = true + return nil + end + index = 2 + return c:byte(1) + end + end, function () + c = '' + end +end + +-- Convert a string into a stream of bytes +local function stringStream(str) + local index = 1 + return function() + local r = str:byte(index) + index = index + 1 + return r + end +end + +-- Table of delimiter bytes - (, ), [, ], {, } +-- Opener keys have closer as the value, and closers keys +-- have true as their value. +local delims = { + [40] = 41, -- ( + [41] = true, -- ) + [91] = 93, -- [ + [93] = true, -- ] + [123] = 125, -- { + [125] = true -- } +} + +local function iswhitespace(b) + return b == 32 or (b >= 9 and b <= 13) or b == 44 +end + +local function issymbolchar(b) + return b > 32 and + not delims[b] and + b ~= 127 and + b ~= 34 and + b ~= 39 and + b ~= 59 and + b ~= 44 +end + +-- Parse one value given a function that +-- returns sequential bytes. Will throw an error as soon +-- as possible without getting more bytes on bad input. Returns +-- if a value was read, and then the value read. Will return nil +-- when input stream is finished. +local function parser(getbyte, filename) + + -- Stack of unfinished values + local stack = {} + + -- Provide one character buffer and keep + -- track of current line and byte index + local line = 1 + local byteindex = 0 + local lastb + local function ungetb(ub) + if ub == 10 then line = line - 1 end + byteindex = byteindex - 1 + lastb = ub + end + local function getb() + local r + if lastb then + r, lastb = lastb, nil + else + r = getbyte() + end + byteindex = byteindex + 1 + if r == 10 then line = line + 1 end + return r + end + + -- Parse stream + return function () + + -- Dispatch when we complete a value + local done, retval + local function dispatch(v) + if #stack == 0 then + retval = v + done = true + else + table.insert(stack[#stack], v) + end + end + + -- The main parse loop + repeat + local b + + -- Skip whitespace + repeat + b = getb() + until not b or not iswhitespace(b) + if not b then + if #stack > 0 then error 'unexpected end of source' end + return nil + end + + if b == 59 then -- ; Comment + repeat + b = getb() + until not b or b == 10 -- newline + elseif type(delims[b]) == 'number' then -- Opening delimiter + table.insert(stack, setmetatable({ + closer = delims[b], + line = line, + filename = filename, + bytestart = byteindex + }, LIST_MT)) + elseif delims[b] then -- Closing delimiter + if #stack == 0 then error 'unexpected closing delimiter' end + local last = stack[#stack] + local val + if last.closer ~= b then + error('unexpected delimiter ' .. string.char(b) .. ', expected ' .. string.char(last.closer)) + end + last.byteend = byteindex -- Set closing byte index + if b == 41 then -- ) + val = last + elseif b == 93 then -- ] + val = {} + for i = 1, #last do + val[i] = last[i] + end + else -- } + if #last % 2 ~= 0 then + error 'expected even number of values in table literal' + end + val = {} + for i = 1, #last, 2 do + val[last[i]] = last[i + 1] + end + end + stack[#stack] = nil + dispatch(val) + elseif b == 34 or b == 39 then -- Quoted string + local start = b + local state = "base" + local chars = {start} + repeat + b = getb() + chars[#chars + 1] = b + if state == "base" then + if b == 92 then + state = "backslash" + elseif b == start then + state = "done" + end + else + -- state == "backslash" + state = "base" + end + until not b or (state == "done") + if not b then error 'unexpected end of source' end + local raw = string.char(unpack(chars)) + local loadFn = loadCode(('return %s'):format(raw), nil, filename) + dispatch(loadFn()) + else -- Try symbol + local chars = {} + local bytestart = byteindex + repeat + chars[#chars + 1] = b + b = getb() + until not b or not issymbolchar(b) + if b then ungetb(b) end + local rawstr = string.char(unpack(chars)) + if rawstr == 'true' then dispatch(true) + elseif rawstr == 'false' then dispatch(false) + elseif rawstr == '...' then dispatch(VARARG) + elseif rawstr:match('^:.+$') then -- keyword style strings + dispatch(rawstr:sub(2)) + else + local forceNumber = rawstr:match('^%d') + local x + if forceNumber then + x = tonumber(rawstr) or error('could not read token "' .. rawstr .. '"') + else + x = tonumber(rawstr) or sym(rawstr, nil, { + line = line, + filename = filename, + bytestart = bytestart, + byteend = byteindex + }) + end + dispatch(x) + end + end + until done + return true, retval + end +end + +-- +-- Compilation +-- + +-- Create a new Scope, optionally under a parent scope. Scopes are compile time constructs +-- that are responsible for keeping track of local variables, name mangling, and macros. +-- They are accessible to user code via the '*compiler' special form (may change). They +-- use metatables to implement nesting via inheritance. +local function makeScope(parent) + return { + unmanglings = setmetatable({}, { + __index = parent and parent.unmanglings + }), + manglings = setmetatable({}, { + __index = parent and parent.manglings + }), + specials = setmetatable({}, { + __index = parent and parent.specials + }), + symmeta = setmetatable({}, { + __index = parent and parent.symmeta + }), + parent = parent, + vararg = parent and parent.vararg, + depth = parent and ((parent.depth or 0) + 1) or 0 + } +end + +-- Assert a condition and raise a compile error with line numbers. The ast arg +-- should be unmodified so that its first element is the form being called. +local function assertCompile(condition, msg, ast) + -- if we use regular `assert' we can't provide the `level' argument of zero + if not condition then + error(string.format("Compile error in '%s' %s:%s: %s", ast[1][1], + ast.filename or "unknown", ast.line or '?', msg), 0) + end + return condition +end + +local GLOBAL_SCOPE = makeScope() +local SPECIALS = GLOBAL_SCOPE.specials +local COMPILER_SCOPE = makeScope(GLOBAL_SCOPE) + +local luaKeywords = { + 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', 'function', + 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', 'return', 'then', 'true', + 'until', 'while' +} +for i, v in ipairs(luaKeywords) do + luaKeywords[v] = i +end + +local function isValidLuaIdentifier(str) + return (str:match('^[%a_][%w_]*$') and not luaKeywords[str]) +end + +-- Allow printing a string to Lua, also keep as 1 line. +local serializeSubst = { + ['\a'] = '\\a', + ['\b'] = '\\b', + ['\f'] = '\\f', + ['\n'] = 'n', + ['\t'] = '\\t', + ['\v'] = '\\v' +} +local function serializeString(str) + local s = ("%q"):format(str) + s = s:gsub('.', serializeSubst):gsub("[\128-\255]", function(c) + return "\\" .. c:byte() + end) + return s +end + +-- A multi symbol is a symbol that is actually composed of +-- two or more symbols using the dot syntax. The main differences +-- from normal symbols is that they cannot be declared local, and +-- they may have side effects on invocation (metatables) +local function isMultiSym(str) + if type(str) ~= 'string' then return end + local parts = {} + for part in str:gmatch('[^%.]+') do + parts[#parts + 1] = part + end + return #parts > 0 and + str:match('%.') and + (not str:match('%.%.')) and + str:byte() ~= string.byte '.' and + str:byte(-1) ~= string.byte '.' and + parts +end + +-- Mangler for global symbols. Does not protect against collisions, +-- but makes them unlikely. This is the mangling that is exposed to +-- to the world. +local function globalMangling(str) + if isValidLuaIdentifier(str) then + return str + end + -- Use underscore as escape character + return '__fnl_global__' .. str:gsub('[^%w]', function (c) + return ('_%02x'):format(c:byte()) + end) +end + +-- Reverse a global mangling. Takes a Lua identifier and +-- returns the fennel symbol string that created it. +local function globalUnmangling(identifier) + local rest = identifier:match('^__fnl_global__(.*)$') + if rest then + return rest:gsub('_[%da-f][%da-f]', function (code) + return string.char(tonumber(code:sub(2), 16)) + end) + else + return identifier + end +end + +-- Creates a symbol from a string by mangling it. +-- ensures that the generated symbol is unique +-- if the input string is unique in the scope. +local function localMangling(str, scope) + if scope.manglings[str] then + return scope.manglings[str] + end + local append = 0 + local mangling = str + if isMultiSym(str) then error 'did not expect a multi symbol' end + + -- Mapping mangling to a valid Lua identifier + if luaKeywords[mangling] or mangling:match('^%d') then + mangling = '_' .. mangling + end + mangling = mangling:gsub('-', '_') + mangling = mangling:gsub('[^%w_]', function (c) + return ('_%02x'):format(c:byte()) + end) + + local raw = mangling + while scope.unmanglings[mangling] do + mangling = raw .. append + append = append + 1 + end + scope.unmanglings[mangling] = str + scope.manglings[str] = mangling + return mangling +end + +-- Combine parts of a symbol +local function combineParts(parts, scope) + local ret = scope.manglings[parts[1]] or globalMangling(parts[1]) + for i = 2, #parts do + if isValidLuaIdentifier(parts[i]) then + ret = ret .. '.' .. parts[i] + else + ret = ret .. '[' .. serializeString(parts[i]) .. ']' + end + end + return ret +end + +-- Generates a unique symbol in the scope. +local function gensym(scope) + local mangling + local append = 0 + repeat + mangling = '_' .. append .. '_' + append = append + 1 + until not scope.unmanglings[mangling] + scope.unmanglings[mangling] = true + return mangling +end + +-- Declare a local symbol +local function declareLocal(symbol, meta, scope, ast) + local name = symbol[1] + assertCompile(not isMultiSym(name), "did not expect mutltisym", ast) + local mangling = localMangling(name, scope) + scope.symmeta[name] = meta + return mangling +end + +-- Convert symbol to Lua code. Will only work for local symbols +-- if they have already been declared via declareLocal +local function symbolToExpression(symbol, scope) + local name = symbol[1] + local parts = isMultiSym(name) or {name} + local etype = (#parts > 1) and "expression" or "sym" + return expr(combineParts(parts, scope), etype) +end + + +-- Emit Lua code +local function emit(chunk, out, ast) + if type(out) == 'table' then + table.insert(chunk, out) + else + table.insert(chunk, {leaf = out, ast = ast}) + end +end + +-- Do some peephole optimization. +local function peephole(chunk) + if chunk.leaf then return chunk end + -- Optimize do ... end in some cases. + if #chunk == 3 and + chunk[1].leaf == 'do' and + not chunk[2].leaf and + chunk[3].leaf == 'end' then + return peephole(chunk[2]) + end + -- Recurse + for i, v in ipairs(chunk) do + chunk[i] = peephole(v) + end + return chunk +end + +-- correlate line numbers in input with line numbers in output +local function flattenChunkCorrelated(mainChunk) + local function flatten(chunk, out, lastLine, file) + if chunk.leaf then + out[lastLine] = (out[lastLine] or "") .. " " .. chunk.leaf + else + for _, subchunk in ipairs(chunk) do + -- Ignore empty chunks + if subchunk.leaf or #subchunk > 0 then + -- don't increase line unless it's from the same file + if subchunk.ast and file == subchunk.ast.file then + lastLine = math.max(lastLine, subchunk.ast.line or 0) + end + lastLine = flatten(subchunk, out, lastLine, file) + end + end + end + return lastLine + end + local out = {} + local last = flatten(mainChunk, out, 1, mainChunk.file) + for i = 1, last do + if out[i] == nil then out[i] = "" end + end + return table.concat(out, "\n") +end + +-- Flatten a tree of indented Lua source code lines. +-- Tab is what is used to indent a block. +local function flattenChunk(sm, chunk, tab, depth) + if type(tab) == 'boolean' then tab = tab and ' ' or '' end + if chunk.leaf then + local code = chunk.leaf + local info = chunk.ast + -- Just do line info for now to save memory + if sm then sm[#sm + 1] = info and info.line or -1 end + return code + else + local parts = {} + for i = 1, #chunk do + -- Ignore empty chunks + if chunk[i].leaf or #(chunk[i]) > 0 then + local sub = flattenChunk(sm, chunk[i], tab, depth + 1) + if depth > 0 then sub = tab .. sub:gsub('\n', '\n' .. tab) end + table.insert(parts, sub) + end + end + return table.concat(parts, '\n') + end +end + +-- Some global state for all fennel sourcemaps. For the time being, +-- this seems the easiest way to store the source maps. +-- Sourcemaps are stored with source being mapped as the key, prepended +-- with '@' if it is a filename (like debug.getinfo returns for source). +-- The value is an array of mappings for each line. +local fennelSourcemap = {} +-- TODO: loading, unloading, and saving sourcemaps? + +local function makeShortSrc(source) + source = source:gsub('\n', ' ') + if #source <= 49 then + return '[fennel "' .. source .. '"]' + else + return '[fennel "' .. source:sub(1, 46) .. '..."]' + end +end + +-- Return Lua source and source map table +local function flatten(chunk, options) + local sm = options.sourcemap and {} + chunk = peephole(chunk) + if(options.correlate) then + return flattenChunkCorrelated(chunk), {} + else + local ret = flattenChunk(sm, chunk, options.indent, 0) + if sm then + local key, short_src + if options.filename then + short_src = options.filename + key = '@' .. short_src + else + key = ret + short_src = makeShortSrc(options.source or ret) + end + sm.short_src = short_src + sm.key = key + fennelSourcemap[key] = sm + end + return ret, sm + end +end + +-- Convert expressions to Lua string +local function exprs1(exprs) + local t = {} + for _, e in ipairs(exprs) do + t[#t + 1] = e[1] + end + return table.concat(t, ', ') +end + +-- Compile side effects for a chunk +local function keepSideEffects(exprs, chunk, start, ast) + start = start or 1 + for j = start, #exprs do + local se = exprs[j] + -- Avoid the rogue 'nil' expression (nil is usually a literal, + -- but becomes an expression if a special form + -- returns 'nil'.) + if se.type == 'expression' and se[1] ~= 'nil' then + emit(chunk, ('do local _ = %s end'):format(tostring(se)), ast) + elseif se.type == 'statement' then + emit(chunk, tostring(se), ast) + end + end +end + +-- Does some common handling of returns and register +-- targets for special forms. Also ensures a list expression +-- has an acceptable number of expressions if opts contains the +-- "nval" option. +local function handleCompileOpts(exprs, parent, opts, ast) + if opts.nval then + local n = opts.nval + if n ~= #exprs then + local len = #exprs + if len > n then + -- Drop extra + keepSideEffects(exprs, parent, n + 1, ast) + for i = n, len do + exprs[i] = nil + end + else + -- Pad with nils + for i = #exprs + 1, n do + exprs[i] = expr('nil', 'literal') + end + end + end + end + if opts.tail then + emit(parent, ('return %s'):format(exprs1(exprs)), ast) + end + if opts.target then + emit(parent, ('%s = %s'):format(opts.target, exprs1(exprs)), ast) + end + if opts.tail or opts.target then + -- Prevent statements and expression from being used twice if they + -- have side-effects. Since if the target or tail options are set, + -- the expressions are already emitted, we should not return them. This + -- is fine, as when these options are set, the caller doesn't need the result + -- anyways. + exprs = {} + end + return exprs +end + +-- Compile an AST expression in the scope into parent, a tree +-- of lines that is eventually compiled into Lua code. Also +-- returns some information about the evaluation of the compiled expression, +-- which can be used by the calling function. Macros +-- are resolved here, as well as special forms in that order. +-- the 'ast' param is the root AST to compile +-- the 'scope' param is the scope in which we are compiling +-- the 'parent' param is the table of lines that we are compiling into. +-- add lines to parent by appending strings. Add indented blocks by appending +-- tables of more lines. +-- the 'opts' param contains info about where the form is being compiled. +-- Options include: +-- 'target' - mangled name of symbol(s) being compiled to. +-- Could be one variable, 'a', or a list, like 'a, b, _0_'. +-- 'tail' - boolean indicating tail position if set. If set, form will generate a return +-- instruction. +local function compile1(ast, scope, parent, opts) + opts = opts or {} + local exprs = {} + + -- Compile the form + if isList(ast) then + -- Function call or special form + local len = #ast + assert(len > 0, "expected a function to call") + -- Test for special form + local first = ast[1] + if isSym(first) then -- Resolve symbol + first = first[1] + end + local special = scope.specials[first] + if special and isSym(ast[1]) then + -- Special form + exprs = special(ast, scope, parent, opts) or expr('nil', 'literal') + -- Be very accepting of strings or expression + -- as well as lists or expressions + if type(exprs) == 'string' then exprs = expr(exprs, 'expression') end + if getmetatable(exprs) == EXPR_MT then exprs = {exprs} end + -- Unless the special form explicitly handles the target, tail, and nval properties, + -- (indicated via the 'returned' flag, handle these options. + if not exprs.returned then + exprs = handleCompileOpts(exprs, parent, opts, ast) + elseif opts.tail or opts.target then + exprs = {} + end + exprs.returned = true + return exprs + else + -- Function call + local fargs = {} + local fcallee = compile1(ast[1], scope, parent, { + nval = 1 + })[1] + assert(fcallee.type ~= 'literal', 'cannot call literal value') + fcallee = tostring(fcallee) + for i = 2, len do + local subexprs = compile1(ast[i], scope, parent, { + nval = i ~= len and 1 or nil + }) + fargs[#fargs + 1] = subexprs[1] or expr('nil', 'literal') + if i == len then + -- Add sub expressions to function args + for j = 2, #subexprs do + fargs[#fargs + 1] = subexprs[j] + end + else + -- Emit sub expression only for side effects + keepSideEffects(subexprs, parent, 2, ast[i]) + end + end + local call = ('%s(%s)'):format(tostring(fcallee), exprs1(fargs)) + exprs = handleCompileOpts({expr(call, 'statement')}, parent, opts, ast) + end + elseif isVarg(ast) then + -- assertCompile(scope.vararg, "unexpected vararg", ast) + exprs = handleCompileOpts({expr('...', 'varg')}, parent, opts, ast) + elseif isSym(ast) then + local e + -- Handle nil as special symbol - it resolves to the nil literal rather than + -- being unmangled. Alternatively, we could remove it from the lua keywords table. + if ast[1] == 'nil' then + e = expr('nil', 'literal') + else + e = symbolToExpression(ast, scope) + end + exprs = handleCompileOpts({e}, parent, opts, ast) + elseif type(ast) == 'nil' or type(ast) == 'boolean' then + exprs = handleCompileOpts({expr(tostring(ast), 'literal')}, parent, opts) + elseif type(ast) == 'number' then + local n = ('%.17g'):format(ast) + exprs = handleCompileOpts({expr(n, 'literal')}, parent, opts) + elseif type(ast) == 'string' then + local s = serializeString(ast) + exprs = handleCompileOpts({expr(s, 'literal')}, parent, opts) + elseif type(ast) == 'table' then + local buffer = {} + for i = 1, #ast do -- Write numeric keyed values. + buffer[#buffer + 1] = tostring(compile1(ast[i], scope, parent, {nval = 1})[1]) + end + local keys = {} + for k, _ in pairs(ast) do -- Write other keys. + if type(k) ~= 'number' or math.floor(k) ~= k or k < 1 or k > #ast then + local kstr + if type(k) == 'string' and isValidLuaIdentifier(k) then + kstr = k + else + kstr = '[' .. tostring(compile1(k, scope, parent, {nval = 1})[1]) .. ']' + end + table.insert(keys, { kstr, k }) + end + end + table.sort(keys, function (a, b) return a[1] < b[1] end) + for _, k in ipairs(keys) do + local v = ast[k[2]] + buffer[#buffer + 1] = ('%s = %s'):format( + k[1], tostring(compile1(v, scope, parent, {nval = 1})[1])) + end + local tbl = '({' .. table.concat(buffer, ', ') ..'})' + exprs = handleCompileOpts({expr(tbl, 'expression')}, parent, opts, ast) + else + error('could not compile value of type ' .. type(ast)) + end + exprs.returned = true + return exprs +end + +-- SPECIALS -- + +-- For statements and expressions, put the value in a local to avoid +-- double-evaluating it. +local function once(val, ast, scope, parent) + if val.type == 'statement' or val.type == 'expression' then + local s = gensym(scope) + emit(parent, ('local %s = %s'):format(s, tostring(val)), ast) + return expr(s, 'sym') + else + return val + end +end + +-- Implements destructuring for forms like let, bindings, etc. +-- Takes a number of options to control behavior. +-- var: Whether or not to mark symbols as mutable +-- declaration: begin each assignment with 'local' in output +-- nomulti: disallow multisyms in the destructuring. Used for (local) and (global). +-- noundef: Don't set undefined bindings. (set) +-- forceglobal: Don't allow local bindings +local function destructure(to, from, ast, scope, parent, opts) + opts = opts or {} + local isvar = opts.isvar + local declaration = opts.declaration + local nomulti = opts.nomulti + local noundef = opts.noundef + local forceglobal = opts.forceglobal + local setter = declaration and "local %s = %s" or "%s = %s" + + -- Get Lua source for symbol, and check for errors + local function getname(symbol, up1) + local raw = symbol[1] + assertCompile(not (nomulti and isMultiSym(raw)), + 'did not expect multisym', up1) + if declaration then + return declareLocal(symbol, {var = isvar}, scope, symbol) + else + local parts = isMultiSym(raw) or {raw} + local meta = scope.symmeta[parts[1]] + if #parts == 1 then + assertCompile(not(forceglobal and meta), + 'expected global, found var', up1) + assertCompile(meta or not noundef, + 'expected local var ' .. parts[1], up1) + assertCompile(not (meta and not meta.var), + 'expected local var', up1) + end + return symbolToExpression(symbol, scope)[1] + end + end + + -- Recursive auxiliary function + local function destructure1(left, rightexprs, up1) + if isSym(left) and left[1] ~= "nil" then + emit(parent, setter:format(getname(left, up1), exprs1(rightexprs)), left) + elseif isTable(left) then -- table destructuring + local s = gensym(scope) + emit(parent, ("local %s = %s"):format(s, exprs1(rightexprs)), left) + for i, v in ipairs(left) do + if isSym(left[i]) and left[i][1] == "&" then + assertCompile(not left[i+2], + "expected rest argument in final position", left) + local subexpr = expr(('{(table.unpack or unpack)(%s, %s)}'):format(s, i), + 'expression') + destructure1(left[i+1], {subexpr}, left) + return + else + local subexpr = expr(('%s[%d]'):format(s, i), 'expression') + destructure1(v, {subexpr}, left) + end + end + elseif isList(left) then -- values destructuring + local leftNames, tables = {}, {} + for i, name in ipairs(left) do + local symname + if isSym(name) then -- binding directly to a name + symname = getname(name, up1) + else -- further destructuring of tables inside values + symname = gensym(scope) + tables[i] = {name, expr(symname, 'sym')} + end + table.insert(leftNames, symname) + end + emit(parent, setter: + format(table.concat(leftNames, ", "), exprs1(rightexprs)), left) + for _, pair in pairs(tables) do -- recurse if left-side tables found + destructure1(pair[1], {pair[2]}, left) + end + else + assertCompile(false, 'unable to destructure ' .. tostring(left), up1) + end + end + + local rexps = compile1(from, scope, parent) + local ret = destructure1(to, rexps, ast) + return ret +end + +-- Unlike most expressions and specials, 'values' resolves with multiple +-- values, one for each argument, allowing multiple return values. The last +-- expression, can return multiple arguments as well, allowing for more than the number +-- of expected arguments. +local function values(ast, scope, parent) + local len = #ast + local exprs = {} + for i = 2, len do + local subexprs = compile1(ast[i], scope, parent, {}) + exprs[#exprs + 1] = subexprs[1] or expr('nil', 'literal') + if i == len then + for j = 2, #subexprs do + exprs[#exprs + 1] = subexprs[j] + end + else + -- Emit sub expression only for side effects + keepSideEffects(subexprs, parent, 2, ast) + end + end + return exprs +end + +-- Compile a list of forms for side effects +local function compileDo(ast, scope, parent, start) + start = start or 2 + local len = #ast + local subScope = makeScope(scope) + for i = start, len do + compile1(ast[i], subScope, parent, { + nval = 0 + }) + end +end + +-- Implements a do statement, starting at the 'start' element. By default, start is 2. +local function doImpl(ast, scope, parent, opts, start, chunk, subScope) + start = start or 2 + subScope = subScope or makeScope(scope) + chunk = chunk or {} + local len = #ast + local outerTarget = opts.target + local outerTail = opts.tail + local retexprs = {returned = true} + + -- See if we need special handling to get the return values + -- of the do block + if not outerTarget and opts.nval ~= 0 and not outerTail then + if opts.nval then + -- Generate a local target + local syms = {} + for i = 1, opts.nval do + local s = gensym(scope) + syms[i] = s + retexprs[i] = expr(s, 'sym') + end + outerTarget = table.concat(syms, ', ') + emit(parent, ('local %s'):format(outerTarget), ast) + emit(parent, 'do', ast) + else + -- We will use an IIFE for the do + local fname = gensym(scope) + emit(parent, ('local function %s()'):format(fname), ast) + retexprs = expr(fname .. '()', 'statement') + outerTail = true + outerTarget = nil + end + else + emit(parent, 'do', ast) + end + -- Compile the body + if start > len then + -- In the unlikely case we do a do with no arguments. + compile1(nil, subScope, chunk, { + tail = outerTail, + target = outerTarget + }) + -- There will be no side effects + else + for i = start, len do + local subopts = { + nval = i ~= len and 0 or opts.nval, + tail = i == len and outerTail or nil, + target = i == len and outerTarget or nil + } + local subexprs = compile1(ast[i], subScope, chunk, subopts) + if i ~= len then + keepSideEffects(subexprs, parent, nil, ast[i]) + end + end + end + emit(parent, chunk, ast) + emit(parent, 'end', ast) + return retexprs +end + +SPECIALS['do'] = doImpl +SPECIALS['values'] = values + +-- The fn special declares a function. Syntax is similar to other lisps; +-- (fn optional-name [arg ...] (body)) +-- Further decoration such as docstrings, meta info, and multibody functions a possibility. +SPECIALS['fn'] = function(ast, scope, parent) + local fScope = makeScope(scope) + local fChunk = {} + local index = 2 + local fnName = isSym(ast[index]) + local isLocalFn + if fnName and fnName[1] ~= 'nil' then + isLocalFn = not isMultiSym(fnName[1]) + if isLocalFn then + fnName = declareLocal(fnName, {}, scope, ast) + else + fnName = symbolToExpression(fnName, scope)[1] + end + index = index + 1 + else + isLocalFn = true + fnName = gensym(scope) + end + local argList = assertCompile(isTable(ast[index]), + 'expected vector arg list [a b ...]', ast) + local argNameList = {} + for i = 1, #argList do + if isVarg(argList[i]) then + assertCompile(i == #argList, "expected vararg in last parameter position", ast) + argNameList[i] = '...' + fScope.vararg = true + elseif isSym(argList[i]) + and argList[i][1] ~= "nil" + and not isMultiSym(argList[i][1]) then + argNameList[i] = declareLocal(argList[i], {}, fScope, ast) + else + assertCompile(false, 'expected symbol for function parameter', ast) + end + end + for i = index + 1, #ast do + compile1(ast[i], fScope, fChunk, { + tail = i == #ast, + nval = i ~= #ast and 0 or nil + }) + end + if isLocalFn then + emit(parent, ('local function %s(%s)') + :format(fnName, table.concat(argNameList, ', ')), ast) + else + emit(parent, ('%s = function(%s)') + :format(fnName, table.concat(argNameList, ', ')), ast) + end + emit(parent, fChunk, ast) + emit(parent, 'end', ast) + return fnName +end + +SPECIALS['luaexpr'] = function(ast) + return tostring(ast[2]) +end + +SPECIALS['luastatement'] = function(ast) + return expr(tostring(ast[2]), 'statement') +end + +-- Wrapper for table access +SPECIALS['.'] = function(ast, scope, parent) + local len = #ast + assertCompile(len > 1, "expected table argument", ast) + local lhs = compile1(ast[2], scope, parent, {nval = 1}) + if len == 2 then + return tostring(lhs[1]) + else + local indices = {} + for i = 3, len do + local index = ast[i] + if type(index) == 'string' and isValidLuaIdentifier(index) then + table.insert(indices, '.' .. index) + else + index = compile1(index, scope, parent, {nval = 1})[1] + table.insert(indices, '[' .. tostring(index) .. ']') + end + end + return tostring(lhs[1]) .. table.concat(indices) + end +end + +SPECIALS['global'] = function(ast, scope, parent) + assertCompile(#ast == 3, "expected name and value", ast) + destructure(ast[2], ast[3], ast, scope, parent, { + nomulti = true, + forceglobal = true + }) +end + +SPECIALS['set'] = function(ast, scope, parent) + assertCompile(#ast == 3, "expected name and value", ast) + destructure(ast[2], ast[3], ast, scope, parent, { + noundef = true + }) +end + +SPECIALS['local'] = function(ast, scope, parent) + assertCompile(#ast == 3, "expected name and value", ast) + destructure(ast[2], ast[3], ast, scope, parent, { + declaration = true, + nomulti = true + }) +end + +SPECIALS['var'] = function(ast, scope, parent) + assertCompile(#ast == 3, "expected name and value", ast) + destructure(ast[2], ast[3], ast, scope, parent, { + declaration = true, + nomulti = true, + isvar = true + }) +end + +SPECIALS['let'] = function(ast, scope, parent, opts) + local bindings = ast[2] + assertCompile(isList(bindings) or isTable(bindings), + 'expected table for destructuring', ast) + assertCompile(#bindings % 2 == 0, + 'expected even number of name/value bindings', ast) + assertCompile(#ast >= 3, 'missing body expression', ast) + local subScope = makeScope(scope) + local subChunk = {} + for i = 1, #bindings, 2 do + destructure(bindings[i], bindings[i + 1], ast, subScope, subChunk, { + declaration = true, + nomulti = true + }) + end + return doImpl(ast, scope, parent, opts, 3, subChunk, subScope) +end + +-- For setting items in a table +SPECIALS['tset'] = function(ast, scope, parent) + local root = compile1(ast[2], scope, parent, {nval = 1})[1] + local keys = {} + for i = 3, #ast - 1 do + local key = compile1(ast[i], scope, parent, {nval = 1})[1] + keys[#keys + 1] = tostring(key) + end + local value = compile1(ast[#ast], scope, parent, {nval = 1})[1] + emit(parent, ('%s[%s] = %s'):format(tostring(root), + table.concat(keys, ']['), + tostring(value)), ast) +end + +-- The if special form behaves like the cond form in +-- many languages +SPECIALS['if'] = function(ast, scope, parent, opts) + local doScope = makeScope(scope) + local branches = {} + local elseBranch = nil + + -- Calculate some external stuff. Optimizes for tail calls and what not + local outerTail = true + local outerTarget = nil + local wrapper = 'iife' + if opts.tail then + wrapper = 'none' + end + + -- Compile bodies and conditions + local bodyOpts = { + tail = outerTail, + target = outerTarget + } + local function compileBody(i) + local chunk = {} + local cscope = makeScope(doScope) + compile1(ast[i], cscope, chunk, bodyOpts) + return { + chunk = chunk, + scope = cscope + } + end + for i = 2, #ast - 1, 2 do + local condchunk = {} + local cond = compile1(ast[i], doScope, condchunk, {nval = 1}) + local branch = compileBody(i + 1) + branch.cond = cond + branch.condchunk = condchunk + branch.nested = i ~= 2 and next(condchunk, nil) == nil + table.insert(branches, branch) + end + local hasElse = #ast > 3 and #ast % 2 == 0 + if hasElse then elseBranch = compileBody(#ast) end + + -- Emit code + local s = gensym(scope) + local buffer = {} + local lastBuffer = buffer + for i = 1, #branches do + local branch = branches[i] + local fstr = not branch.nested and 'if %s then' or 'elseif %s then' + local condLine = fstr:format(tostring(branch.cond[1])) + if branch.nested then + emit(lastBuffer, branch.condchunk, ast) + else + for _, v in ipairs(branch.condchunk) do emit(lastBuffer, v, ast) end + end + emit(lastBuffer, condLine, ast) + emit(lastBuffer, branch.chunk, ast) + if i == #branches then + if hasElse then + emit(lastBuffer, 'else', ast) + emit(lastBuffer, elseBranch.chunk, ast) + end + emit(lastBuffer, 'end', ast) + elseif not branches[i + 1].nested then + emit(lastBuffer, 'else', ast) + local nextBuffer = {} + emit(lastBuffer, nextBuffer, ast) + emit(lastBuffer, 'end', ast) + lastBuffer = nextBuffer + end + end + + if wrapper == 'iife' then + local iifeargs = scope.vararg and '...' or '' + emit(parent, ('local function %s(%s)'):format(tostring(s), iifeargs), ast) + emit(parent, buffer, ast) + emit(parent, 'end', ast) + return expr(('%s(%s)'):format(tostring(s), iifeargs), 'statement') + elseif wrapper == 'none' then + -- Splice result right into code + for i = 1, #buffer do + emit(parent, buffer[i], ast) + end + return {returned = true} + end +end + +-- (each [k v (pairs t)] body...) => [] +SPECIALS['each'] = function(ast, scope, parent) + local binding = assertCompile(isTable(ast[2]), 'expected binding table', ast) + local iter = table.remove(binding, #binding) -- last item is iterator call + local bindVars = {} + for _, v in ipairs(binding) do + assertCompile(isSym(v), 'expected iterator symbol', ast) + table.insert(bindVars, declareLocal(v, {}, scope, ast)) + end + emit(parent, ('for %s in %s do'):format( + table.concat(bindVars, ', '), + tostring(compile1(iter, scope, parent, {nval = 1})[1])), ast) + local chunk = {} + compileDo(ast, scope, chunk, 3) + emit(parent, chunk, ast) + emit(parent, 'end', ast) +end + +-- (while condition body...) => [] +SPECIALS['while'] = function(ast, scope, parent) + local len1 = #parent + local condition = compile1(ast[2], scope, parent, {nval = 1})[1] + local len2 = #parent + local subChunk = {} + if len1 ~= len2 then + -- Compound condition + emit(parent, 'while true do', ast) + -- Move new compilation to subchunk + for i = len1 + 1, len2 do + subChunk[#subChunk + 1] = parent[i] + parent[i] = nil + end + emit(parent, ('if %s then break end'):format(condition[1]), ast) + else + -- Simple condition + emit(parent, 'while ' .. tostring(condition) .. ' do', ast) + end + compileDo(ast, makeScope(scope), subChunk, 3) + emit(parent, subChunk, ast) + emit(parent, 'end', ast) +end + +SPECIALS['for'] = function(ast, scope, parent) + local ranges = assertCompile(isTable(ast[2]), 'expected binding table', ast) + local bindingSym = assertCompile(isSym(table.remove(ast[2], 1)), + 'expected iterator symbol', ast) + local rangeArgs = {} + for i = 1, math.min(#ranges, 3) do + rangeArgs[i] = tostring(compile1(ranges[i], scope, parent, {nval = 1})[1]) + end + emit(parent, ('for %s = %s do'):format( + declareLocal(bindingSym, {}, scope, ast), + table.concat(rangeArgs, ', ')), ast) + local chunk = {} + compileDo(ast, scope, chunk, 3) + emit(parent, chunk, ast) + emit(parent, 'end', ast) +end + +SPECIALS[':'] = function(ast, scope, parent) + assertCompile(#ast >= 3, 'expected at least 3 arguments', ast) + -- Compile object + local objectexpr = compile1(ast[2], scope, parent, {nval = 1})[1] + -- Compile method selector + local methodstring + local methodident = false + if type(ast[3]) == 'string' and isValidLuaIdentifier(ast[3]) then + methodident = true + methodstring = ast[3] + else + methodstring = tostring(compile1(ast[3], scope, parent, {nval = 1})[1]) + objectexpr = once(objectexpr, ast[2], scope, parent) + end + -- Compile arguments + local args = {} + for i = 4, #ast do + local subexprs = compile1(ast[i], scope, parent, { + nval = i ~= #ast and 1 or nil + }) + for j = 1, #subexprs do + args[#args + 1] = tostring(subexprs[j]) + end + end + local fstring + if methodident then + fstring = objectexpr.type == 'literal' + and '(%s):%s(%s)' + or '%s:%s(%s)' + else + -- Make object first argument + table.insert(args, 1, tostring(objectexpr)) + fstring = objectexpr.type == 'sym' + and '%s[%s](%s)' + or '(%s)[%s](%s)' + end + return expr(fstring:format( + tostring(objectexpr), + methodstring, + table.concat(args, ', ')), 'statement') +end + +local function defineArithmeticSpecial(name, unaryPrefix) + local paddedOp = ' ' .. name .. ' ' + SPECIALS[name] = function(ast, scope, parent) + local len = #ast + if len == 1 then + return unaryPrefix or '0' + else + local operands = {} + for i = 2, len do + local subexprs = compile1(ast[i], scope, parent, { + nval = (i == 1 and 1 or nil) + }) + for j = 1, #subexprs do + operands[#operands + 1] = tostring(subexprs[j]) + end + end + if #operands == 1 and unaryPrefix then + return '(' .. unaryPrefix .. paddedOp .. operands[1] .. ')' + else + return '(' .. table.concat(operands, paddedOp) .. ')' + end + end + end +end + +defineArithmeticSpecial('+') +defineArithmeticSpecial('..') +defineArithmeticSpecial('^') +defineArithmeticSpecial('-', '') +defineArithmeticSpecial('*') +defineArithmeticSpecial('%') +defineArithmeticSpecial('/', 1) +defineArithmeticSpecial('//', 1) +defineArithmeticSpecial('or') +defineArithmeticSpecial('and') + +local function defineComparatorSpecial(name, realop) + local op = realop or name + SPECIALS[name] = function(ast, scope, parent) + assertCompile(#ast > 2, 'expected at least two arguments', ast) + local lhs = compile1(ast[2], scope, parent, {nval = 1})[1] + local lastval = compile1(ast[3], scope, parent, {nval = 1})[1] + -- avoid double-eval by introducing locals for possible side-effects + if #ast > 3 then lastval = once(lastval, ast[3], scope, parent) end + local out = ('(%s) %s (%s)'): + format(tostring(lhs), op, tostring(lastval)) + for i = 4, #ast do -- variadic comparison + local nextval = once(compile1(ast[i], scope, parent, {nval = 1})[1], + ast[i], scope, parent) + out = (out .. " and ((%s) %s (%s))"): + format(tostring(lastval), op, tostring(nextval)) + lastval = nextval + end + return out + end +end + +defineComparatorSpecial('>') +defineComparatorSpecial('<') +defineComparatorSpecial('>=') +defineComparatorSpecial('<=') +defineComparatorSpecial('=', '==') +defineComparatorSpecial('~=') + +local function defineUnarySpecial(op, realop) + SPECIALS[op] = function(ast, scope, parent) + assertCompile(#ast == 2, 'expected one argument', ast) + local tail = compile1(ast[2], scope, parent, {nval = 1}) + return (realop or op) .. tostring(tail[1]) + end +end + +defineUnarySpecial('not', 'not ') +defineUnarySpecial('#') + +-- Covert a macro function to a special form +local function macroToSpecial(mac) + return function(ast, scope, parent, opts) + local ok, transformed = pcall(mac, unpack(ast, 2)) + assertCompile(ok, transformed, ast) + return compile1(transformed, scope, parent, opts) + end +end + +local function compile(ast, options) + options = options or {} + if options.indent == nil then options.indent = ' ' end + local chunk = {} + local scope = options.scope or makeScope(GLOBAL_SCOPE) + local exprs = compile1(ast, scope, chunk, {tail = true}) + keepSideEffects(exprs, chunk, nil, ast) + return flatten(chunk, options) +end + +local function compileStream(strm, options) + options = options or {} + if options.indent == nil then options.indent = ' ' end + local scope = options.scope or makeScope(GLOBAL_SCOPE) + local vals = {} + for ok, val in parser(strm, options.filename) do + if not ok then break end + vals[#vals + 1] = val + end + local chunk = {} + for i = 1, #vals do + local exprs = compile1(vals[i], scope, chunk, { + tail = i == #vals + }) + keepSideEffects(exprs, chunk, nil, vals[i]) + end + return flatten(chunk, options) +end + +local function compileString(str, options) + local strm = stringStream(str) + return compileStream(strm, options) +end + +--- +--- Evaluation +--- + +-- A custom traceback function for Fennel that looks similar to +-- the Lua's debug.traceback. +-- Use with xpcall to produce fennel specific stacktraces. +local function traceback(msg, start) + local level = start or 2 -- Can be used to skip some frames + local lines = {} + if msg then + table.insert(lines, msg) + end + table.insert(lines, 'stack traceback:') + while true do + local info = debug.getinfo(level, "Sln") + if not info then break end + local line + if info.what == "C" then + if info.name then + line = (' [C]: in function \'%s\''):format(info.name) + else + line = ' [C]: in ?' + end + else + local remap = fennelSourcemap[info.source] + if remap and remap[info.currentline] then + -- And some global info + info.short_src = remap.short_src + local mapping = remap[info.currentline] + -- Overwrite info with values from the mapping (mapping is now just integer, + -- but may eventually be a table + info.currentline = mapping + end + if info.what == 'Lua' then + local n = info.name and ("'" .. info.name .. "'") or '?' + line = (' %s:%d: in function %s'):format(info.short_src, info.currentline, n) + elseif info.short_src == '(tail call)' then + line = ' (tail call)' + else + line = (' %s:%d: in main chunk'):format(info.short_src, info.currentline) + end + end + table.insert(lines, line) + level = level + 1 + end + return table.concat(lines, '\n') +end + +local function eval(str, options, ...) + options = options or {} + local luaSource = compileString(str, options) + local loader = loadCode(luaSource, options.env, + options.filename and ('@' .. options.filename) or str) + return loader(...) +end + +local function dofile_fennel(filename, options, ...) + options = options or {sourcemap = true} + local f = assert(io.open(filename, "rb")) + local source = f:read("*all") + f:close() + options.filename = options.filename or filename + return eval(source, options, ...) +end + +-- Implements a configurable repl +local function repl(options) + + local opts = options or {} + + local env = opts.env or setmetatable({}, { + __index = _ENV or _G + }) + + local function defaultReadChunk() + io.write('>> ') + io.flush() + local input = io.read() + return input and input .. '\n' + end + + local function defaultOnValues(xs) + io.write(table.concat(xs, '\t')) + io.write('\n') + end + + local function defaultOnError(errtype, err, luaSource) + if (errtype == 'Lua Compile') then + io.write('Bad code generated - likely a bug with the compiler:\n') + io.write('--- Generated Lua Start ---\n') + io.write(luaSource .. '\n') + io.write('--- Generated Lua End ---\n') + end + if (errtype == 'Runtime') then + io.write(traceback(err, 4)) + io.write('\n') + else + io.write(('%s error: %s\n'):format(errtype, tostring(err))) + end + end + + -- Read options + local readChunk = opts.readChunk or defaultReadChunk + local onValues = opts.onValues or defaultOnValues + local onError = opts.onError or defaultOnError + local pp = opts.pp or tostring + + -- Make parser + local bytestream, clearstream = granulate(readChunk) + local chars = {} + local read = parser(function() + local c = bytestream() + chars[#chars + 1] = c + return c + end) + + -- REPL loop + while true do + chars = {} + local ok, parseok, x = pcall(read) + local srcstring = string.char(unpack(chars)) + if not ok then + onError('Parse', parseok) + clearstream() + else + if not parseok then break end -- eof + local compileOk, luaSource = pcall(compile, x, { + sourcemap = opts.sourcemap, + source = srcstring + }) + if not compileOk then + clearstream() + onError('Compile', luaSource) -- luaSource is error message in this case + else + local luacompileok, loader = pcall(loadCode, luaSource, env) + if not luacompileok then + clearstream() + onError('Lua Compile', loader, luaSource) + else + local loadok, ret = xpcall(function () return {loader()} end, + function (runtimeErr) + onError('Runtime', runtimeErr) + end) + if loadok then + env._ = ret[1] + env.__ = ret + for i = 1, #ret do ret[i] = pp(ret[i]) end + onValues(ret) + end + end + end + end + end +end + +local module = { + parser = parser, + granulate = granulate, + stringStream = stringStream, + compile = compile, + compileString = compileString, + compileStream = compileStream, + compile1 = compile1, + mangle = globalMangling, + unmangle = globalUnmangling, + list = list, + sym = sym, + varg = varg, + scope = makeScope, + gensym = gensym, + eval = eval, + repl = repl, + dofile = dofile_fennel, + path = "./?.fnl;./?/init.fnl", + traceback = traceback +} + +local function searchModule(modulename) + modulename = modulename:gsub("%.", "/") + for path in string.gmatch(module.path..";", "([^;]*);") do + local filename = path:gsub("%?", modulename) + local file = io.open(filename, "rb") + if(file) then + file:close() + return filename + end + end +end + +module.make_searcher = function(options) + return function(modulename) + local opts = {} + for k,v in pairs(options or {}) do opts[k] = v end + local filename = searchModule(modulename) + if filename then + return function(modname) + return dofile_fennel(filename, opts, modname) + end + end + end +end + +-- This will allow regular `require` to work with Fennel: +-- table.insert(package.loaders, fennel.searcher) +module.searcher = module.make_searcher() + +local function makeCompilerEnv(ast, scope, parent) + return setmetatable({ + -- State of compiler if needed + _SCOPE = scope, + _CHUNK = parent, + _AST = ast, + _IS_COMPILER = true, + _SPECIALS = SPECIALS, + _VARARG = VARARG, + -- Expose the module in the compiler + fennel = module, + -- Useful for macros and meta programming. All of Fennel can be accessed + -- via fennel.myfun, for example (fennel.eval "(print 1)"). + list = list, + sym = sym, + [globalMangling("list?")] = isList, + [globalMangling("multi-sym?")] = isMultiSym, + [globalMangling("sym?")] = isSym, + [globalMangling("table?")] = isTable, + [globalMangling("varg?")] = isVarg, + }, { __index = _ENV or _G }) +end + +SPECIALS['require-macros'] = function(ast, scope, parent) + for i = 2, #ast do + local filename = assertCompile(searchModule(ast[i]), + ast[i] .. " not found.", ast) + local mod = dofile_fennel(filename, {env=makeCompilerEnv(ast, scope, parent)}) + for k, v in pairs(assertCompile(isTable(mod), 'expected ' .. ast[i] .. + 'module to be table', ast)) do + scope.specials[k] = macroToSpecial(v) + end + end +end + +SPECIALS['eval-compiler'] = function(ast, scope, parent) + local oldFirst = ast[1] + ast[1] = sym('do') + local luaSource = compile(ast, { scope = makeScope(COMPILER_SCOPE) }) + ast[1] = oldFirst + local loader = loadCode(luaSource, makeCompilerEnv(ast, scope, parent)) + loader() +end + +-- Load standard macros +local stdmacros = [===[ +{"->" (fn [val ...] + (var x val) + (each [_ elt (ipairs [...])] + (table.insert elt 2 x) + (set x elt)) + x) + "->>" (fn [val ...] + (var x val) + (each [_ elt (pairs [...])] + (table.insert elt x) + (set x elt)) + x) + :defn (fn [name args ...] + (assert (sym? name) "defn: function names must be symbols") + (list (sym :fn) name args ...)) + :when (fn [condition body1 ...] + (assert body1 "expected body") + (list (sym 'if') condition + (list (sym 'do') body1 ...))) + :partial (fn [f ...] + (let [body (list f ...)] + (table.insert body _VARARG) + (list (sym "fn") [_VARARG] body))) + :lambda (fn [...] + (let [args [...] + has-internal-name? (sym? (. args 1)) + arglist (if has-internal-name? (. args 2) (. args 1)) + arity-check-position (if has-internal-name? 3 2)] + (assert (> (# args) 1) "missing body expression") + (each [i a (ipairs arglist)] + (if (and (not (: (tostring a) :match "^?")) + (~= (tostring a) "...")) + (table.insert args arity-check-position + (list (sym "assert") + (list (sym "~=") (sym "nil") a) + (: "Missing argument %s on %s:%s" + :format (tostring a) + (or a.filename "unknown") + (or a.line "?")))))) + (list (sym "fn") ((or unpack table.unpack) args)))) +} +]===] +for name, fn in pairs(eval(stdmacros, { + env = makeCompilerEnv(nil, GLOBAL_SCOPE, {}) +})) do + SPECIALS[name] = macroToSpecial(fn) +end +SPECIALS['λ'] = SPECIALS['lambda'] + +return module diff --git a/lib/fennelview.lua b/lib/fennelview.lua new file mode 100644 index 0000000..1259f6e --- /dev/null +++ b/lib/fennelview.lua @@ -0,0 +1,201 @@ +local function _0_(str) + return ("\"" .. str:gsub("\"", "\\\"") .. "\"") +end +local quote = _0_ +local short_control_char_escapes = ({["\11"] = "\\v", ["\12"] = "\\f", ["\13"] = "\\r", ["\7"] = "\\a", ["\8"] = "\\b", ["\9"] = "\\t", ["\n"] = "\\n"}) +local function _1_() + local long = ({}) + for i = 0, 31 do + local ch = string.char(i) + local function _2_() + if not short_control_char_escapes[ch] then + short_control_char_escapes[ch] = ("\\" .. i) + long[ch] = ("\\%03d"):format(i) + return nil + end + end + _2_() + end + return long +end +local long_control_char_esapes = _1_() +local function _2_(str) + local str = str:gsub("\\", "\\\\") + local str = str:gsub("(%c)%f[0-9]", long_control_char_esapes) + return str:gsub("%c", short_control_char_escapes) +end +local escape = _2_ +local function _3_(k, len) + return ((type(k)) == ("number") and (1) <= (k) and (k) <= (len) and (math.floor(k)) == (k)) +end +local sequence_key_3f = _3_ +local type_order = ({["function"] = 5, boolean = 2, number = 1, string = 3, table = 4, thread = 7, userdata = 6}) +local function _4_(a, b) + local ta = type(a) + local tb = type(b) + if ((ta) == (tb) and (ta) ~= ("boolean") and ((ta) == ("string") or (ta) == ("number"))) then + return (a) < (b) + else + local dta = type_order[a] + local dtb = type_order[b] + if (dta and dtb) then + return (dta) < (dtb) + elseif dta then + return true + elseif dtb then + return false + elseif "else" then + return (ta) < (tb) + end + end +end +local sort_keys = _4_ +local function _5_(t) + local len = 1 + for i in ipairs(t) do + len = i + end + return len +end +local get_sequence_length = _5_ +local function _6_(t) + local keys = ({}) + local sequence_length = get_sequence_length(t) + for k in pairs(t) do + local function _7_() + if not sequence_key_3f(k, sequence_length) then + return table.insert(keys, k) + end + end + _7_() + end + table.sort(keys, sort_keys) + return keys, sequence_length +end +local get_nonsequential_keys = _6_ +local function recur(t, appearances) + local function _7_() + if (type(t)) == ("table") then + if not appearances[t] then + appearances[t] = 1 + for k, v in pairs(t) do + recur(k, appearances) + recur(v, appearances) + end + return nil + end + else + if (t and (t) == (t)) then + appearances[t] = ((appearances[t] or 0) + 1) + return nil + end + end + end + _7_() + return appearances +end +local count_table_appearances = recur +local put_value = nil +local function _7_(self, ...) + for _, v in ipairs(({...})) do + table.insert(self.buffer, v) + end + return nil +end +local puts = _7_ +local function _8_(self) + return puts(self, "\n", self.indent:rep(self.level)) +end +local tabify = _8_ +local function _9_(self, v) + return (self.ids[v]) ~= (nil) +end +local already_visited_3f = _9_ +local function _10_(self, v) + local id = self.ids[v] + local function _11_() + if not id then + local tv = type(v) + id = ((self["max-ids"][tv] or 0) + 1) + self["max-ids"][tv] = id + self.ids[v] = id + return nil + end + end + _11_() + return tostring(id) +end +local get_id = _10_ +local function _11_(self, t, length) + puts(self, "[") + self.level = (self.level + 1) + for i = 1, length do + puts(self, " ") + put_value(self, t[i]) + end + self.level = (self.level - 1) + return puts(self, " ]") +end +local put_sequential_table = _11_ +local function _12_(self, k) + if ((type(k)) == ("string") and k:find("^[%w?\\^_`!#$%&*+-./@~:|<=>]+$")) then + return puts(self, ":", k) + else + return put_value(self, k) + end +end +local put_key = _12_ +local function _13_(self, t) + puts(self, "{") + self.level = (self.level + 1) + for k, v in pairs(t) do + tabify(self) + put_key(self, k) + puts(self, " ") + put_value(self, v) + end + self.level = (self.level - 1) + tabify(self) + return puts(self, "}") +end +local put_kv_table = _13_ +local function _14_(self, t) + if already_visited_3f(self, t) then + return puts(self, "#") + elseif (self.level) >= (self.depth) then + return puts(self, "{...}") + elseif "else" then + local non_seq_keys, length = get_nonsequential_keys(t) + local id = get_id(self, t) + if (self.appearances[t]) > (1) then + return puts(self, "#<", id, ">") + elseif ((#non_seq_keys) == (0) and (#t) == (0)) then + return puts(self, "{}") + elseif (#non_seq_keys) == (0) then + return put_sequential_table(self, t, length) + elseif "else" then + return put_kv_table(self, t) + end + end +end +local put_table = _14_ +local function _15_(self, v) + local tv = type(v) + if (tv) == ("string") then + return puts(self, quote(escape(v))) + elseif ((tv) == ("number") or (tv) == ("boolean") or (tv) == ("nil")) then + return puts(self, tostring(v)) + elseif (tv) == ("table") then + return put_table(self, v) + elseif "else" then + return puts(self, "#<", tv, " ", get_id(self, v), ">") + end +end +put_value = _15_ +local function _16_(root, options) + local options = (options or ({})) + local inspector = ({["max-ids"] = ({}), appearances = count_table_appearances(root, ({})), buffer = ({}), depth = (options.depth or 128), ids = ({}), indent = (options.indent or " "), level = 0}) + put_value(inspector, root) + return table.concat(inspector.buffer) +end +return _16_ diff --git a/lib/intersect.lua b/lib/intersect.lua new file mode 100644 index 0000000..10ad856 --- /dev/null +++ b/lib/intersect.lua @@ -0,0 +1,15 @@ +-- https://love2d.org/wiki/General_math +return function(l1p1x,l1p1y, l1p2x,l1p2y, l2p1x,l2p1y, l2p2x,l2p2y) + local a1,b1,a2,b2 = l1p2y-l1p1y, l1p1x-l1p2x, l2p2y-l2p1y, l2p1x-l2p2x + local c1,c2 = a1*l1p1x+b1*l1p1y, a2*l2p1x+b2*l2p1y + local det,x,y = a1*b2 - a2*b1 + if det==0 then return false, "The lines are parallel." end + x,y = (b2*c1-b1*c2)/det, (a1*c2-a2*c1)/det + if not (math.min(l1p1x,l1p2x) <= x and x <= math.max(l1p1x,l1p2x) and + math.min(l1p1y,l1p2y) <= y and y <= math.max(l1p1y,l1p2y)) or + not (math.min(l2p1x,l2p2x) <= x and x <= math.max(l2p1x,l2p2x) and + math.min(l2p1y,l2p2y) <= y and y <= math.max(l2p1y,l2p2y)) then + return false, "The lines don't intersect." + end + return x,y +end diff --git a/lib/lume.lua b/lib/lume.lua new file mode 100644 index 0000000..a5f0d9a --- /dev/null +++ b/lib/lume.lua @@ -0,0 +1,768 @@ +-- +-- lume +-- +-- Copyright (c) 2015 rxi +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. +-- + +local lume = { _version = "2.2.3" } + +local pairs, ipairs = pairs, ipairs +local type, assert, unpack = type, assert, unpack or table.unpack +local tostring, tonumber = tostring, tonumber +local math_floor = math.floor +local math_ceil = math.ceil +local math_random = math.random +local math_atan2 = math.atan2 or math.atan +local math_sqrt = math.sqrt +local math_abs = math.abs + +local noop = function() +end + +local identity = function(x) + return x +end + +local patternescape = function(str) + return str:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1") +end + +local absindex = function(len, i) + return i < 0 and (len + i + 1) or i +end + +local iscallable = function(x) + if type(x) == "function" then return true end + local mt = getmetatable(x) + return mt and mt.__call ~= nil +end + +local getiter = function(x) + if lume.isarray(x) then + return ipairs + elseif type(x) == "table" then + return pairs + end + error("expected table", 3) +end + +local iteratee = function(x) + if x == nil then return identity end + if iscallable(x) then return x end + if type(x) == "table" then + return function(z) + for k, v in pairs(x) do + if z[k] ~= v then return false end + end + return true + end + end + return function(z) return z[x] end +end + + + +function lume.clamp(x, min, max) + return x < min and min or (x > max and max or x) +end + + +function lume.round(x, increment) + if increment then return lume.round(x / increment) * increment end + return x >= 0 and math_floor(x + .5) or math_ceil(x - .5) +end + + +function lume.sign(x) + return x < 0 and -1 or 1 +end + + +function lume.lerp(a, b, amount) + return a + (b - a) * lume.clamp(amount, 0, 1) +end + + +function lume.smooth(a, b, amount) + local t = lume.clamp(amount, 0, 1) + local m = t * t * (3 - 2 * t) + return a + (b - a) * m +end + + +function lume.pingpong(x) + return 1 - math_abs(1 - x % 2) +end + + +function lume.distance(x1, y1, x2, y2, squared) + local dx = x1 - x2 + local dy = y1 - y2 + local s = dx * dx + dy * dy + return squared and s or math_sqrt(s) +end + + +function lume.angle(x1, y1, x2, y2) + return math_atan2(y2 - y1, x2 - x1) +end + + +function lume.random(a, b) + if not a then a, b = 0, 1 end + if not b then b = 0 end + return a + math_random() * (b - a) +end + + +function lume.randomchoice(t) + return t[math_random(#t)] +end + + +function lume.weightedchoice(t) + local sum = 0 + for _, v in pairs(t) do + assert(v >= 0, "weight value less than zero") + sum = sum + v + end + assert(sum ~= 0, "all weights are zero") + local rnd = lume.random(sum) + for k, v in pairs(t) do + if rnd < v then return k end + rnd = rnd - v + end +end + + +function lume.isarray(x) + return (type(x) == "table" and x[1] ~= nil) and true or false +end + + +function lume.push(t, ...) + local n = select("#", ...) + for i = 1, n do + t[#t + 1] = select(i, ...) + end + return ... +end + + +function lume.remove(t, x) + local iter = getiter(t) + for i, v in iter(t) do + if v == x then + if lume.isarray(t) then + table.remove(t, i) + break + else + t[i] = nil + break + end + end + end + return x +end + + +function lume.clear(t) + local iter = getiter(t) + for k in iter(t) do + t[k] = nil + end + return t +end + + +function lume.extend(t, ...) + for i = 1, select("#", ...) do + local x = select(i, ...) + if x then + for k, v in pairs(x) do + t[k] = v + end + end + end + return t +end + + +function lume.shuffle(t) + local rtn = {} + for i = 1, #t do + local r = math_random(i) + if r ~= i then + rtn[i] = rtn[r] + end + rtn[r] = t[i] + end + return rtn +end + + +function lume.sort(t, comp) + local rtn = lume.clone(t) + if comp then + if type(comp) == "string" then + table.sort(rtn, function(a, b) return a[comp] < b[comp] end) + else + table.sort(rtn, comp) + end + else + table.sort(rtn) + end + return rtn +end + + +function lume.array(...) + local t = {} + for x in ... do t[#t + 1] = x end + return t +end + + +function lume.each(t, fn, ...) + local iter = getiter(t) + if type(fn) == "string" then + for _, v in iter(t) do v[fn](v, ...) end + else + for _, v in iter(t) do fn(v, ...) end + end + return t +end + + +function lume.map(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + for k, v in iter(t) do rtn[k] = fn(v) end + return rtn +end + + +function lume.all(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for _, v in iter(t) do + if not fn(v) then return false end + end + return true +end + + +function lume.any(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for _, v in iter(t) do + if fn(v) then return true end + end + return false +end + + +function lume.reduce(t, fn, first) + local acc = first + local started = first and true or false + local iter = getiter(t) + for _, v in iter(t) do + if started then + acc = fn(acc, v) + else + acc = v + started = true + end + end + assert(started, "reduce of an empty table with no first value") + return acc +end + + +function lume.set(t) + local rtn = {} + for k in pairs(lume.invert(t)) do + rtn[#rtn + 1] = k + end + return rtn +end + + +function lume.filter(t, fn, retainkeys) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + if retainkeys then + for k, v in iter(t) do + if fn(v) then rtn[k] = v end + end + else + for _, v in iter(t) do + if fn(v) then rtn[#rtn + 1] = v end + end + end + return rtn +end + + +function lume.reject(t, fn, retainkeys) + fn = iteratee(fn) + local iter = getiter(t) + local rtn = {} + if retainkeys then + for k, v in iter(t) do + if not fn(v) then rtn[k] = v end + end + else + for _, v in iter(t) do + if not fn(v) then rtn[#rtn + 1] = v end + end + end + return rtn +end + + +function lume.merge(...) + local rtn = {} + for i = 1, select("#", ...) do + local t = select(i, ...) + local iter = getiter(t) + for k, v in iter(t) do + rtn[k] = v + end + end + return rtn +end + + +function lume.concat(...) + local rtn = {} + for i = 1, select("#", ...) do + local t = select(i, ...) + if t ~= nil then + local iter = getiter(t) + for _, v in iter(t) do + rtn[#rtn + 1] = v + end + end + end + return rtn +end + + +function lume.find(t, value) + local iter = getiter(t) + for k, v in iter(t) do + if v == value then return k end + end + return nil +end + + +function lume.match(t, fn) + fn = iteratee(fn) + local iter = getiter(t) + for k, v in iter(t) do + if fn(v) then return v, k end + end + return nil +end + + +function lume.count(t, fn) + local count = 0 + local iter = getiter(t) + if fn then + fn = iteratee(fn) + for _, v in iter(t) do + if fn(v) then count = count + 1 end + end + else + if lume.isarray(t) then + return #t + end + for _ in iter(t) do count = count + 1 end + end + return count +end + + +function lume.slice(t, i, j) + i = i and absindex(#t, i) or 1 + j = j and absindex(#t, j) or #t + local rtn = {} + for x = i < 1 and 1 or i, j > #t and #t or j do + rtn[#rtn + 1] = t[x] + end + return rtn +end + + +function lume.first(t, n) + if not n then return t[1] end + return lume.slice(t, 1, n) +end + + +function lume.last(t, n) + if not n then return t[#t] end + return lume.slice(t, -n, -1) +end + + +function lume.invert(t) + local rtn = {} + for k, v in pairs(t) do rtn[v] = k end + return rtn +end + + +function lume.pick(t, ...) + local rtn = {} + for i = 1, select("#", ...) do + local k = select(i, ...) + rtn[k] = t[k] + end + return rtn +end + + +function lume.keys(t) + local rtn = {} + local iter = getiter(t) + for k in iter(t) do rtn[#rtn + 1] = k end + return rtn +end + + +function lume.clone(t) + local rtn = {} + for k, v in pairs(t) do rtn[k] = v end + return rtn +end + + +function lume.fn(fn, ...) + assert(iscallable(fn), "expected a function as the first argument") + local args = { ... } + return function(...) + local a = lume.concat(args, { ... }) + return fn(unpack(a)) + end +end + + +function lume.once(fn, ...) + local f = lume.fn(fn, ...) + local done = false + return function(...) + if done then return end + done = true + return f(...) + end +end + + +local memoize_fnkey = {} +local memoize_nil = {} + +function lume.memoize(fn) + local cache = {} + return function(...) + local c = cache + for i = 1, select("#", ...) do + local a = select(i, ...) or memoize_nil + c[a] = c[a] or {} + c = c[a] + end + c[memoize_fnkey] = c[memoize_fnkey] or {fn(...)} + return unpack(c[memoize_fnkey]) + end +end + + +function lume.combine(...) + local n = select('#', ...) + if n == 0 then return noop end + if n == 1 then + local fn = select(1, ...) + if not fn then return noop end + assert(iscallable(fn), "expected a function or nil") + return fn + end + local funcs = {} + for i = 1, n do + local fn = select(i, ...) + if fn ~= nil then + assert(iscallable(fn), "expected a function or nil") + funcs[#funcs + 1] = fn + end + end + return function(...) + for _, f in ipairs(funcs) do f(...) end + end +end + + +function lume.call(fn, ...) + if fn then + return fn(...) + end +end + + +function lume.time(fn, ...) + local start = os.clock() + local rtn = {fn(...)} + return (os.clock() - start), unpack(rtn) +end + + +local lambda_cache = {} + +function lume.lambda(str) + if not lambda_cache[str] then + local args, body = str:match([[^([%w,_ ]-)%->(.-)$]]) + assert(args and body, "bad string lambda") + local s = "return function(" .. args .. ")\nreturn " .. body .. "\nend" + lambda_cache[str] = lume.dostring(s) + end + return lambda_cache[str] +end + + +local serialize + +local serialize_map = { + [ "boolean" ] = tostring, + [ "nil" ] = tostring, + [ "string" ] = function(v) return string.format("%q", v) end, + [ "number" ] = function(v) + if v ~= v then return "0/0" -- nan + elseif v == 1 / 0 then return "1/0" -- inf + elseif v == -1 / 0 then return "-1/0" end -- -inf + return tostring(v) + end, + [ "table" ] = function(t, stk) + stk = stk or {} + if stk[t] then error("circular reference") end + local rtn = {} + stk[t] = true + for k, v in pairs(t) do + rtn[#rtn + 1] = "[" .. serialize(k, stk) .. "]=" .. serialize(v, stk) + end + stk[t] = nil + return "{" .. table.concat(rtn, ",") .. "}" + end +} + +setmetatable(serialize_map, { + __index = function(_, k) error("unsupported serialize type: " .. k) end +}) + +serialize = function(x, stk) + return serialize_map[type(x)](x, stk) +end + +function lume.serialize(x) + return serialize(x) +end + + +function lume.deserialize(str) + return lume.dostring("return " .. str) +end + + +function lume.split(str, sep) + if not sep then + return lume.array(str:gmatch("([%S]+)")) + else + assert(sep ~= "", "empty separator") + local psep = patternescape(sep) + return lume.array((str..sep):gmatch("(.-)("..psep..")")) + end +end + + +function lume.trim(str, chars) + if not chars then return str:match("^[%s]*(.-)[%s]*$") end + chars = patternescape(chars) + return str:match("^[" .. chars .. "]*(.-)[" .. chars .. "]*$") +end + + +function lume.wordwrap(str, limit) + limit = limit or 72 + local check + if type(limit) == "number" then + check = function(s) return #s >= limit end + else + check = limit + end + local rtn = {} + local line = "" + for word, spaces in str:gmatch("(%S+)(%s*)") do + local s = line .. word + if check(s) then + table.insert(rtn, line .. "\n") + line = word + else + line = s + end + for c in spaces:gmatch(".") do + if c == "\n" then + table.insert(rtn, line .. "\n") + line = "" + else + line = line .. c + end + end + end + table.insert(rtn, line) + return table.concat(rtn) +end + + +function lume.format(str, vars) + if not vars then return str end + local f = function(x) + return tostring(vars[x] or vars[tonumber(x)] or "{" .. x .. "}") + end + return (str:gsub("{(.-)}", f)) +end + + +function lume.trace(...) + local info = debug.getinfo(2, "Sl") + local t = { info.short_src .. ":" .. info.currentline .. ":" } + for i = 1, select("#", ...) do + local x = select(i, ...) + if type(x) == "number" then + x = string.format("%g", lume.round(x, .01)) + end + t[#t + 1] = tostring(x) + end + print(table.concat(t, " ")) +end + + +function lume.dostring(str) + return assert((loadstring or load)(str))() +end + + +function lume.uuid() + local fn = function(x) + local r = math_random(16) - 1 + r = (x == "x") and (r + 1) or (r % 4) + 9 + return ("0123456789abcdef"):sub(r, r) + end + return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn)) +end + + +function lume.hotswap(modname) + local oldglobal = lume.clone(_G) + local updated = {} + local function update(old, new) + if updated[old] then return end + updated[old] = true + local oldmt, newmt = getmetatable(old), getmetatable(new) + if oldmt and newmt then update(oldmt, newmt) end + for k, v in pairs(new) do + if type(v) == "table" then update(old[k], v) else old[k] = v end + end + end + local err = nil + local function onerror(e) + for k in pairs(_G) do _G[k] = oldglobal[k] end + err = lume.trim(e) + end + local ok, oldmod = pcall(require, modname) + oldmod = ok and oldmod or nil + xpcall(function() + package.loaded[modname] = nil + local newmod = require(modname) + if type(oldmod) == "table" then update(oldmod, newmod) end + for k, v in pairs(oldglobal) do + if v ~= _G[k] and type(v) == "table" then + update(v, _G[k]) + _G[k] = v + end + end + end, onerror) + package.loaded[modname] = oldmod + if err then return nil, err end + return oldmod +end + + +local ripairs_iter = function(t, i) + i = i - 1 + local v = t[i] + if v then return i, v end +end + +function lume.ripairs(t) + return ripairs_iter, t, (#t + 1) +end + + +function lume.color(str, mul) + mul = mul or 1 + local r, g, b, a + r, g, b = str:match("#(%x%x)(%x%x)(%x%x)") + if r then + r = tonumber(r, 16) / 0xff + g = tonumber(g, 16) / 0xff + b = tonumber(b, 16) / 0xff + a = 1 + elseif str:match("rgba?%s*%([%d%s%.,]+%)") then + local f = str:gmatch("[%d.]+") + r = (f() or 0) / 0xff + g = (f() or 0) / 0xff + b = (f() or 0) / 0xff + a = f() or 1 + else + error(("bad color string '%s'"):format(str)) + end + return r * mul, g * mul, b * mul, a * mul +end + + +function lume.rgba(color) + local a = math_floor((color / 16777216) % 256) + local r = math_floor((color / 65536) % 256) + local g = math_floor((color / 256) % 256) + local b = math_floor((color) % 256) + return r, g, b, a +end + + +local chain_mt = {} +chain_mt.__index = lume.map(lume.filter(lume, iscallable, true), + function(fn) + return function(self, ...) + self._value = fn(self._value, ...) + return self + end + end) +chain_mt.__index.result = function(x) return x._value end + +function lume.chain(value) + return setmetatable({ _value = value }, chain_mt) +end + +setmetatable(lume, { + __call = function(_, ...) + return lume.chain(...) + end +}) + + +return lume diff --git a/lib/sfxr.lua b/lib/sfxr.lua new file mode 100644 index 0000000..d44b06e --- /dev/null +++ b/lib/sfxr.lua @@ -0,0 +1,1536 @@ +-- sfxr.lua +-- original by Tomas Pettersson, ported to Lua by nucular + +--[[ +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. +]]-- + +--[[-- +A port of the sfxr sound effect synthesizer to pure Lua, designed to be used +together with the *awesome* [LÖVE](https://love2d.org) game framework. +]]-- +-- @module sfxr +local sfxr = {} +local bit = bit32 or require("bit") + +-- Constants + +--- The module version (SemVer format) +-- @within Constants +sfxr.VERSION = "0.0.2" + +--- [Waveform](https://en.wikipedia.org/wiki/Waveform) constants +-- @within Constants +-- @field SQUARE [square wave](https://en.wikipedia.org/wiki/Square_wave) (`= 0`) +-- @field SAW [saw wave](https://en.wikipedia.org/wiki/Sawtooth_wave) (`= 1`) +-- @field SINE [sine wave](https://en.wikipedia.org/wiki/Sine_wave) (`= 2`) +-- @field NOISE [white noise](https://en.wikipedia.org/wiki/White_noise) (`= 3`) +sfxr.WAVEFORM = { + SQUARE = 0, + [0] = 0, + SAW = 1, + [1] = 1, + SINE = 2, + [2] = 2, + NOISE = 3, + [3] = 3 +} + +--- [Sampling rate](https://en.wikipedia.org/wiki/Sampling_(signal_processing)#Sampling_rate) constants +-- (use the number values directly, these are just for lookup) +-- @within Constants +-- @field 22050 22.05 kHz (`= 22050`) +-- @field 44100 44.1 kHz (`= 44100`) +sfxr.SAMPLERATE = { + [22050] = 22050, --- 22.05 kHz + [44100] = 44100 --- 44.1 kHz +} + +--- [Bit depth](https://en.wikipedia.org/wiki/Audio_bit_depth) constants +-- (use the number values directly, these are just for lookup) +-- @within Constants +-- @field 0 floating point bit depth, -1 to 1 (`= 0`) +-- @field 8 unsigned 8 bit, 0x00 to 0xFF (`= 8`) +-- @field 16 unsigned 16 bit, 0x0000 to 0xFFFF (`= 16`) +sfxr.BITDEPTH = { + [0] = 0, + [16] = 16, + [8] = 8 +} + +--- [Endianness](https://en.wikipedia.org/wiki/Endianness) constants +-- @within Constants +-- @field LITTLE little endian (`= 0`) +-- @field BIG big endian (`= 1`) +sfxr.ENDIANNESS = { + LITTLE = 0, + [0] = 0, + BIG = 1, + [1] = 1 +} + +-- Utilities + +--- Truncate a number to an unsigned integer. +-- @tparam number n a (signed) number +-- @treturn int the number, truncated and unsigned +local function trunc(n) + if n >= 0 then + return math.floor(n) + else + return -math.floor(-n) + end +end + +--- Set the random seed and initializes the generator. +-- @tparam number seed the random seed +local function setseed(seed) + math.randomseed(seed) + for i=0, 5 do + math.random() + end +end + +--- Return a random number between low and high. +-- @tparam number low the lower bound +-- @tparam number high the upper bound +-- @treturn number a random number where `low < n < high` +local function random(low, high) + return low + math.random() * (high - low) +end + +--- Return a random boolean weighted towards false by n. +-- w = 1: uniform distribution +-- w = n: false is n times as likely as true +-- Note: n < 0 do not work, use `not maybe(w)` instead +-- @tparam[opt=1] number w the weight towards false +-- @treturn bool a random boolean +local function maybe(w) + return trunc(random(0, w or 1)) == 0 +end + +--- Clamp n between min and max. +-- @tparam number n the number +-- @tparam number min the lower bound +-- @tparam number max the upper bound +-- @treturn number the number where `min <= n <= max` +local function clamp(n, min, max) + return math.max(min or -math.huge, math.min(max or math.huge, n)) +end + +--- Copy a table (shallow) or a primitive. +-- @param t a table or primitive +-- @return a copy of t +local function shallowcopy(t) + if type(t) == "table" then + local t2 = {} + for k,v in pairs(t) do + t2[k] = v + end + return t2 + else + return t + end +end + +--- Recursively merge table t2 into t1. +-- @tparam tab t1 a table +-- @tparam tab t2 a table to merge into t1 +-- @treturn tab t1 +local function mergetables(t1, t2) + for k, v in pairs(t2) do + if type(v) == "table" then + if type(t1[k] or false) == "table" then + mergetables(t1[k] or {}, t2[k] or {}) + else + t1[k] = v + end + else + t1[k] = v + end + end + return t1 +end + +--- Pack a number into a IEEE754 32-bit big-endian floating point binary string. +-- [source](https://stackoverflow.com/questions/14416734/) +-- @tparam number number a number +-- @treturn string a binary string +local function packIEEE754(number) + if number == 0 then + return string.char(0x00, 0x00, 0x00, 0x00) + elseif number ~= number then + return string.char(0xFF, 0xFF, 0xFF, 0xFF) + else + local sign = 0x00 + if number < 0 then + sign = 0x80 + number = -number + end + local mantissa, exponent = math.frexp(number) + exponent = exponent + 0x7F + if exponent <= 0 then + mantissa = math.ldexp(mantissa, exponent - 1) + exponent = 0 + elseif exponent > 0 then + if exponent >= 0xFF then + return string.char(sign + 0x7F, 0x80, 0x00, 0x00) + elseif exponent == 1 then + exponent = 0 + else + mantissa = mantissa * 2 - 1 + exponent = exponent - 1 + end + end + mantissa = math.floor(math.ldexp(mantissa, 23) + 0.5) + return string.char( + sign + math.floor(exponent / 2), + (exponent % 2) * 0x80 + math.floor(mantissa / 0x10000), + math.floor(mantissa / 0x100) % 0x100, + mantissa % 0x100) + end +end + +--- Unpack a IEEE754 32-bit big-endian floating point string to a number. +-- [source](https://stackoverflow.com/questions/14416734/) +-- @tparam string packed a binary string +-- @treturn number a number +local function unpackIEEE754(packed) + local b1, b2, b3, b4 = string.byte(packed, 1, 4) + local exponent = (b1 % 0x80) * 0x02 + math.floor(b2 / 0x80) + local mantissa = math.ldexp(((b2 % 0x80) * 0x100 + b3) * 0x100 + b4, -23) + if exponent == 0xFF then + if mantissa > 0 then + return 0 / 0 + else + mantissa = math.huge + exponent = 0x7F + end + elseif exponent > 0 then + mantissa = mantissa + 1 + else + exponent = exponent + 1 + end + if b1 >= 0x80 then + mantissa = -mantissa + end + return math.ldexp(mantissa, exponent - 0x7F) +end + +--- Construct and return a new @{Sound} instance. +-- @treturn Sound a Sound instance +function sfxr.newSound(...) + local instance = setmetatable({}, sfxr.Sound) + instance:__init(...) + return instance +end + +--- The main Sound class. +-- @type Sound +sfxr.Sound = {} +sfxr.Sound.__index = sfxr.Sound + +--- Initialize the Sound instance. +-- Called by @{sfxr.newSound|the constructor}. +function sfxr.Sound:__init() + --- Number of supersampling passes to perform (*default* 8) + -- @within Parameters + self.supersampling = 8 + --- Repeat speed: + -- Times to repeat the frequency slide over the course of the envelope + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Parameters + self.repeatspeed = 0.0 + --- The base @{WAVEFORM|waveform} (*default* @{WAVEFORM|SQUARE}) + -- @within Parameters + self.waveform = sfxr.WAVEFORM.SQUARE + + -- Build tables to store the parameters in + + --- **The sound volume and gain all samples are multiplied with** + -- @within Volume + self.volume = {} + --- **The [ASD envelope](https://en.wikipedia.org/wiki/Synthesizer#Attack_ + --Decay_Sustain_Release_.28ADSR.29_envelope) that controls the sound + -- amplitude (volume) over time** + -- @within Envelope + self.envelope = {} + --- **The base and minimum frequencies of the tone generator and their + -- slides** + -- @within Frequency + self.frequency = {} + --- **A [vibrato](https://en.wikipedia.org/wiki/Vibrato)-like amplitude + -- modulation effect** + -- SerializationVibrato + self.vibrato = {} + --- **Changes the frequency mid-sound to create a characteristic + -- "coin"-effect** + -- @within Change + self.change = {} + --- **The [duty](https://en.wikipedia.org/wiki/Duty_cycle) of the square + -- waveform** + -- @within Duty + self.duty = {} + --- **A simple [phaser](https://en.wikipedia.org/wiki/Phaser_(effect)) + -- effect** + -- @within Phaser + self.phaser = {} + --- **A [lowpass filter](https://en.wikipedia.org/wiki/Low-pass_filter) + -- effect** + -- @within Lowpass + self.lowpass = {} + --- **A [highpass filter](https://en.wikipedia.org/wiki/High-pass_filter) + -- effect** + -- @within Highpass + self.highpass = {} + + -- These are not affected by resetParameters() + + --- Master volume (*default* 0.5) + -- @within Volume + self.volume.master = 0.5 + --- Additional gain (*default* 0.5) + -- @within Volume + self.volume.sound = 0.5 + + self:resetParameters() +end + +--- Set all parameters to their default values. Does not affect +-- @{self.supersampling|supersampling} and @{self.volume|volume}. +-- Called by @{sfxr.Sound:__init|the initializer}. +function sfxr.Sound:resetParameters() + self.repeatspeed = 0.0 + self.waveform = sfxr.WAVEFORM.SQUARE + + --- Attack time: + -- Time the sound takes to reach its peak amplitude + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.attack = 0.0 + --- Sustain time: + -- Time the sound stays on its peak amplitude + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.sustain = 0.3 + --- Sustain punch: + -- Amount by which the sound peak amplitude is increased at the start of the + -- sustain time + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.punch = 0.0 + --- Decay time: + -- Time the sound takes to decay after its sustain time + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Envelope + self.envelope.decay = 0.4 + + --- Start frequency: + -- Base tone of the sound, before sliding + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Frequency + self.frequency.start = 0.3 + --- Min frequency: + -- Tone below which the sound will get cut off + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Frequency + self.frequency.min = 0.0 + --- Slide: + -- Amount by which the frequency is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Frequency + self.frequency.slide = 0.0 + --- Delta slide: + -- Amount by which the slide is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Frequency + self.frequency.dslide = 0.0 + + --- Vibrato depth: + -- Amount of amplitude modulation + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Vibrato + self.vibrato.depth = 0.0 + --- Vibrato speed: + -- Oscillation speed of the vibrato + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Vibrato + self.vibrato.speed = 0.0 + --- Vibrato delay: + -- Unused and unimplemented + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Vibrato + self.vibrato.delay = 0.0 + + --- Change amount: + -- Amount by which the frequency is changed mid-sound + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Change + self.change.amount = 0.0 + --- Change speed: + -- Time before the frequency change happens + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Change + self.change.speed = 0.0 + + --- Square duty: + -- Width of the square wave pulse cycle (doesn't affect other waveforms) + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Duty + self.duty.ratio = 0.0 + --- Duty sweep: + -- Amount by which the square duty is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Duty + self.duty.sweep = 0.0 + + --- Phaser offset: + -- Amount by which the phaser signal is offset from the sound + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Phaser + self.phaser.offset = 0.0 + --- Phaser sweep: + -- Amount by which the phaser offset is increased or decreased over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Phaser + self.phaser.sweep = 0.0 + + --- Lowpass filter cutoff: + -- Lower bound for frequencies allowed to pass through this filter + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Lowpass + self.lowpass.cutoff = 1.0 + --- Lowpass filter cutoff sweep: + -- Amount by which the LP filter cutoff is increased or decreased + -- over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Lowpass + self.lowpass.sweep = 0.0 + --- Lowpass filter resonance: + -- Amount by which certain resonant frequencies near the cutoff are + -- increased + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Lowpass + self.lowpass.resonance = 0.0 + --- Highpass filter cutoff: + -- Upper bound for frequencies allowed to pass through this filter + -- (*default* 0.0, *min* 0.0, *max* 1.0) + -- @within Highpass + self.highpass.cutoff = 0.0 + --- Highpass filter cutoff sweep: + -- Amount by which the HP filter cutoff is increased or decreased + -- over time + -- (*default* 0.0, *min* -1.0, *max* 1.0) + -- @within Highpass + self.highpass.sweep = 0.0 +end + +--- Clamp all parameters within their sane ranges. +function sfxr.Sound:sanitizeParameters() + self.repeatspeed = clamp(self.repeatspeed, 0, 1) + self.waveform = clamp(self.waveform, 0, #sfxr.WAVEFORM) + + self.envelope.attack = clamp(self.envelope.attack, 0, 1) + self.envelope.sustain = clamp(self.envelope.sustain, 0, 1) + self.envelope.punch = clamp(self.envelope.punch, 0, 1) + self.envelope.decay = clamp(self.envelope.decay, 0, 1) + + self.frequency.start = clamp(self.frequency.start, 0, 1) + self.frequency.min = clamp(self.frequency.min, 0, 1) + self.frequency.slide = clamp(self.frequency.slide, -1, 1) + self.frequency.dslide = clamp(self.frequency.dslide, -1, 1) + + self.vibrato.depth = clamp(self.vibrato.depth, 0, 1) + self.vibrato.speed = clamp(self.vibrato.speed, 0, 1) + self.vibrato.delay = clamp(self.vibrato.delay, 0, 1) + + self.change.amount = clamp(self.change.amount, -1, 1) + self.change.speed = clamp(self.change.speed, 0, 1) + + self.duty.ratio = clamp(self.duty.ratio, 0, 1) + self.duty.sweep = clamp(self.duty.sweep, -1, 1) + + self.phaser.offset = clamp(self.phaser.offset, -1, 1) + self.phaser.sweep = clamp(self.phaser.sweep, -1, 1) + + self.lowpass.cutoff = clamp(self.lowpass.cutoff, 0, 1) + self.lowpass.sweep = clamp(self.lowpass.sweep, -1, 1) + self.lowpass.resonance = clamp(self.lowpass.resonance, 0, 1) + self.highpass.cutoff = clamp(self.highpass.cutoff, 0, 1) + self.highpass.sweep = clamp(self.highpass.sweep, -1, 1) +end + +--- Generate the sound and yield the sample data. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @treturn function() a generator that yields the next sample when called +-- @usage for s in sound:generate(44100, 0) do +-- -- do something with s +-- end +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:generate(rate, depth) + rate = rate or 44100 + depth = depth or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth], "invalid bit depth: " .. tostring(depth)) + + -- Initialize all locals + local fperiod, maxperiod + local slide, dslide + local square_duty, square_slide + local chg_mod, chg_time, chg_limit + + local phaserbuffer = {} + local noisebuffer = {} + + -- Initialize the sample buffers + for i=1, 1024 do + phaserbuffer[i] = 0 + end + + for i=1, 32 do + noisebuffer[i] = random(-1, 1) + end + + --- Reset the sound period + local function reset() + fperiod = 100 / (self.frequency.start^2 + 0.001) + maxperiod = 100 / (self.frequency.min^2 + 0.001) + period = trunc(fperiod) + + slide = 1.0 - self.frequency.slide^3 * 0.01 + dslide = -self.frequency.dslide^3 * 0.000001 + + square_duty = 0.5 - self.duty.ratio * 0.5 + square_slide = -self.duty.sweep * 0.00005 + + if self.change.amount >= 0 then + chg_mod = 1.0 - self.change.amount^2 * 0.9 + else + chg_mod = 1.0 + self.change.amount^2 * 10 + end + + chg_time = 0 + if self.change.speed == 1 then + chg_limit = 0 + else + chg_limit = trunc((1 - self.change.speed)^2 * 20000 + 32) + end + end + + local phase = 0 + reset() + + local second_sample = false + + local env_vol = 0 + local env_stage = 1 + local env_time = 0 + local env_length = {self.envelope.attack^2 * 100000, + self.envelope.sustain^2 * 100000, + self.envelope.decay^2 * 100000} + + local fphase = self.phaser.offset^2 * 1020 + if self.phaser.offset < 0 then fphase = -fphase end + local dphase = self.phaser.sweep^2 + if self.phaser.sweep < 0 then dphase = -dphase end + local ipp = 0 + + local iphase = math.abs(trunc(fphase)) + + local fltp = 0 + local fltdp = 0 + local fltw = self.lowpass.cutoff^3 * 0.1 + local fltw_d = 1 + self.lowpass.sweep * 0.0001 + local fltdmp = 5 / (1 + self.lowpass.resonance^2 * 20) * (0.01 + fltw) + fltdmp = clamp(fltdmp, nil, 0.8) + local fltphp = 0 + local flthp = self.highpass.cutoff^2 * 0.1 + local flthp_d = 1 + self.highpass.sweep * 0.0003 + + local vib_phase = 0 + local vib_speed = self.vibrato.speed^2 * 0.01 + local vib_amp = self.vibrato.depth * 0.5 + + local rep_time = 0 + local rep_limit = trunc((1 - self.repeatspeed)^2 * 20000 + 32) + if self.repeatspeed == 0 then + rep_limit = 0 + end + + -- The main closure (returned as a generator) + + local function next() + -- Repeat when needed + rep_time = rep_time + 1 + if rep_limit ~= 0 and rep_time >= rep_limit then + rep_time = 0 + reset() + end + + -- Update the change time and apply it if needed + chg_time = chg_time + 1 + if chg_limit ~= 0 and chg_time >= chg_limit then + chg_limit = 0 + fperiod = fperiod * chg_mod + end + + -- Apply the frequency slide and stuff + slide = slide + dslide + fperiod = fperiod * slide + + if fperiod > maxperiod then + fperiod = maxperiod + -- Fail if the minimum frequency is too small + if (self.frequency.min > 0) then + return nil + end + end + + -- Vibrato + local rfperiod = fperiod + if vib_amp > 0 then + vib_phase = vib_phase + vib_speed + -- Apply to the frequency period + rfperiod = fperiod * (1.0 + math.sin(vib_phase) * vib_amp) + end + + -- Update the period + period = trunc(rfperiod) + if (period < 8) then period = 8 end + + -- Update the square duty + square_duty = clamp(square_duty + square_slide, 0, 0.5) + + -- Volume envelopes + + env_time = env_time + 1 + + if env_time > env_length[env_stage] then + env_time = 0 + env_stage = env_stage + 1 + -- After the decay stop generating + if env_stage == 4 then + return nil + end + end + + -- Attack, Sustain, Decay/Release + if env_stage == 1 then + env_vol = env_time / env_length[1] + elseif env_stage == 2 then + env_vol = 1 + (1 - env_time / env_length[2])^1 * 2 * self.envelope.punch + elseif env_stage == 3 then + env_vol = 1 - env_time / env_length[3] + end + + -- Phaser + + fphase = fphase + dphase + iphase = clamp(math.abs(trunc(fphase)), nil, 1023) + + -- Filter stuff + + if flthp_d ~= 0 then + flthp = clamp(flthp * flthp_d, 0.00001, 0.1) + end + + -- And finally the actual tone generation and supersampling + + local ssample = 0 + for si = 0, self.supersampling-1 do + local sample = 0 + + phase = phase + 1 + + -- fill the noise buffer every period + if phase >= period then + --phase = 0 + phase = phase % period + if self.waveform == sfxr.WAVEFORM.NOISE then + for i = 1, 32 do + noisebuffer[i] = random(-1, 1) + end + end + end + + -- Tone generators ahead + + local fp = phase / period + + -- Square, including square duty + if self.waveform == sfxr.WAVEFORM.SQUARE then + if fp < square_duty then + sample = 0.5 + else + sample = -0.5 + end + + -- Sawtooth + elseif self.waveform == sfxr.WAVEFORM.SAWTOOTH then + sample = 1 - fp * 2 + + -- Sine + elseif self.waveform == sfxr.WAVEFORM.SINE then + sample = math.sin(fp * 2 * math.pi) + + -- Pitched white noise + elseif self.waveform == sfxr.WAVEFORM.NOISE then + sample = noisebuffer[trunc(phase * 32 / period) % 32 + 1] + end + + -- Apply the lowpass filter to the sample + + local pp = fltp + fltw = clamp(fltw * fltw_d, 0, 0.1) + if self.lowpass.cutoff ~= 1 then + fltdp = fltdp + (sample - fltp) * fltw + fltdp = fltdp - fltdp * fltdmp + else + fltp = sample + fltdp = 0 + end + fltp = fltp + fltdp + + -- Apply the highpass filter to the sample + + fltphp = fltphp + (fltp - pp) + fltphp = fltphp - (fltphp * flthp) + sample = fltphp + + -- Apply the phaser to the sample + + phaserbuffer[bit.band(ipp, 1023) + 1] = sample + sample = sample + phaserbuffer[bit.band(ipp - iphase + 1024, 1023) + 1] + ipp = bit.band(ipp + 1, 1023) + + -- Accumulation and envelope application + ssample = ssample + sample * env_vol + end + + -- Apply the volumes + ssample = (ssample / self.supersampling) * self.volume.master + ssample = ssample * (2 * self.volume.sound) + + -- Hard limit + ssample = clamp(ssample, -1, 1) + + -- Frequency conversion + second_sample = not second_sample + if rate == 22050 and second_sample then + -- hah! + local nsample = next() + if nsample then + return (ssample + nsample) / 2 + else + return nil + end + end + + -- bit conversions + if depth == 0 then + return ssample + elseif depth == 16 then + return trunc(ssample * 32000) + else + return trunc(ssample * 127 + 128) + end + end + + return next +end + +--- Get the maximum sample limit allowed by the current envelope. +-- Does not take any other limits into account, so the returned count might be +-- higher than samples actually generated. Still useful though. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:getEnvelopeLimit(rate) + rate = rate or 44100 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + + local env_length = { + self.envelope.attack^2 * 100000, --- attack + self.envelope.sustain^2 * 100000, --- sustain + self.envelope.decay^2 * 100000 --- decay + } + local limit = trunc(env_length[1] + env_length[2] + env_length[3] + 2) + + return math.ceil(limit / (rate / 44100)) +end + +--- Generate the sound into a table. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @tparam[opt] {} tab the table to synthesize into +-- @treturn {number,...} the table filled with sample data +-- @treturn int the number of written samples (== #tab) +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:generateTable(rate, depth, tab) + rate = rate or 44100 + depth = depth or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth], "invalid bit depth: " .. tostring(depth)) + + -- this could really use table pre-allocation, but Lua doesn't provide that + local t = tab or {} + local i = 1 + for v in self:generate(rate, depth) do + t[i] = v + i = i + 1 + end + return t, i +end + +--- Generate the sound to a binary string. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=16] BITDEPTH depth the bit depth (may not be @{BITDEPTH|0}) +-- @tparam[opt=0] ENDIANNESS endianness the endianness (ignored when depth == 8) +-- @treturn string a binary string of sample data +-- @treturn int the number of written samples +-- @raise "invalid sampling rate: x", "invalid bit depth: x", "invalid endianness: x" +function sfxr.Sound:generateString(rate, depth, endianness) + rate = rate or 44100 + depth = depth or 16 + endianness = endianness or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth] and depth ~= 0, "invalid bit depth: " .. tostring(depth)) + assert(sfxr.ENDIANNESS[endianness], "invalid endianness: " .. tostring(endianness)) + + local s = "" + --- buffer for arguments to string.char + local buf = {} + buf[100] = 0 + local bi = 1 + + local i = 0 + for v in self:generate(rate, depth) do + if depth == 8 then + buf[i] = v + bi = bi + 1 + else + if endianness == sfxr.ENDIANNESS.BIG then + buf[bi] = bit.rshift(v, 8) + buf[bi + 1] = bit.band(v, 0xFF) + bi = bi + 2 + else + buf[bi] = bit.band(v, 0xFF) + buf[bi + 1] = bit.rshift(v, 8) + bi = bi + 2 + end + end + + if bi >= 100 then + s = s .. string.char(unpack(buf)) + bi = 0 + end + i = i + 1 + end + + -- pass in up to 100 characters + s = s .. string.char(unpack(buf, i, 100)) + return s, i +end + +--- Synthesize the sound to a LÖVE SoundData instance. +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @tparam[opt] love.sound.SoundData sounddata a SoundData instance (will be +-- created if not passed) +-- @treturn love.sound.SoundData a SoundData instance +-- @treturn int the number of written samples +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:generateSoundData(rate, depth, sounddata) + rate = rate or 44100 + depth = depth or 0 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth] and depth, "invalid bit depth: " .. tostring(depth)) + + local tab, count = self:generateTable(rate, depth) + + if count == 0 then + return nil + end + + local data = sounddata or love.sound.newSoundData(count, freq, bits, 1) + + for i = 0, #tab - 1 do + data:setSample(i, tab[i + 1]) + end + + return data, count +end + +--- Randomize all sound parameters +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomize(seed) + if seed then setseed(seed) end + + local waveform = self.waveform + self:resetParameters() + self.waveform = waveform + + if maybe() then + self.repeatspeed = random(0, 1) + end + + if maybe() then + self.frequency.start = random(-1, 1)^3 + 0.5 + else + self.frequency.start = random(-1, 1)^2 + end + self.frequency.limit = 0 + self.frequency.slide = random(-1, 1)^5 + if self.frequency.start > 0.7 and self.frequency.slide > 0.2 then + self.frequency.slide = -self.frequency.slide + elseif self.frequency.start < 0.2 and self.frequency.slide <-0.05 then + self.frequency.slide = -self.frequency.slide + end + self.frequency.dslide = random(-1, 1)^3 + + self.duty.ratio = random(-1, 1) + self.duty.sweep = random(-1, 1)^3 + + self.vibrato.depth = random(-1, 1)^3 + self.vibrato.speed = random(-1, 1) + self.vibrato.delay = random(-1, 1) + + self.envelope.attack = random(-1, 1)^3 + self.envelope.sustain = random(-1, 1)^2 + self.envelope.punch = random(-1, 1)^2 + self.envelope.decay = random(-1, 1) + + if self.envelope.attack + self.envelope.sustain + self.envelope.decay < 0.2 then + self.envelope.sustain = self.envelope.sustain + 0.2 + random(0, 0.3) + self.envelope.decay = self.envelope.decay + 0.2 + random(0, 0.3) + end + + self.lowpass.resonance = random(-1, 1) + self.lowpass.cutoff = 1 - random(0, 1)^3 + self.lowpass.sweep = random(-1, 1)^3 + if self.lowpass.cutoff < 0.1 and self.lowpass.sweep < -0.05 then + self.lowpass.sweep = -self.lowpass.sweep + end + self.highpass.cutoff = random(0, 1)^3 + self.highpass.sweep = random(-1, 1)^5 + + self.phaser.offset = random(-1, 1)^3 + self.phaser.sweep = random(-1, 1)^3 + + self.change.speed = random(-1, 1) + self.change.amount = random(-1, 1) + + self:sanitizeParameters() +end + +--- Mutate all sound parameters +-- @within Randomization +-- @tparam[opt=1] number amount by how much to mutate the parameters +-- @tparam[opt] number seed a random seed +-- @tparam[changefreq=true] bool changefreq whether to change the frequency parameters +function sfxr.Sound:mutate(amount, seed, changefreq) + if seed then setseed(seed) end + local amount = (amount or 1) + local a = amount / 20 + local b = (1 - a) * 10 + local changefreq = (changefreq == nil) and true or changefreq + + if changefreq == true then + if maybe(b) then self.frequency.start = self.frequency.start + random(-a, a) end + if maybe(b) then self.frequency.slide = self.frequency.slide + random(-a, a) end + if maybe(b) then self.frequency.dslide = self.frequency.dslide + random(-a, a) end + end + + if maybe(b) then self.duty.ratio = self.duty.ratio + random(-a, a) end + if maybe(b) then self.duty.sweep = self.duty.sweep + random(-a, a) end + + if maybe(b) then self.vibrato.depth = self.vibrato.depth + random(-a, a) end + if maybe(b) then self.vibrato.speed = self.vibrato.speed + random(-a, a) end + if maybe(b) then self.vibrato.delay = self.vibrato.delay + random(-a, a) end + + if maybe(b) then self.envelope.attack = self.envelope.attack + random(-a, a) end + if maybe(b) then self.envelope.sustain = self.envelope.sustain + random(-a, a) end + if maybe(b) then self.envelope.punch = self.envelope.punch + random(-a, a) end + if maybe(b) then self.envelope.decay = self.envelope.decay + random(-a, a) end + + if maybe(b) then self.lowpass.resonance = self.lowpass.resonance + random(-a, a) end + if maybe(b) then self.lowpass.cutoff = self.lowpass.cutoff + random(-a, a) end + if maybe(b) then self.lowpass.sweep = self.lowpass.sweep + random(-a, a) end + if maybe(b) then self.highpass.cutoff = self.highpass.cutoff + random(-a, a) end + if maybe(b) then self.highpass.sweep = self.highpass.sweep + random(-a, a) end + + if maybe(b) then self.phaser.offset = self.phaser.offset + random(-a, a) end + if maybe(b) then self.phaser.sweep = self.phaser.sweep + random(-a, a) end + + if maybe(b) then self.change.speed = self.change.speed + random(-a, a) end + if maybe(b) then self.change.amount = self.change.amount + random(-a, a) end + + if maybe(b) then self.repeatspeed = self.repeatspeed + random(-a, a) end + + self:sanitizeParameters() +end + +--- Randomize all sound parameters to generate a "pick up" sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomPickup(seed) + if seed then setseed(seed) end + self:resetParameters() + self.frequency.start = random(0.4, 0.9) + self.envelope.attack = 0 + self.envelope.sustain = random(0, 0.1) + self.envelope.punch = random(0.3, 0.6) + self.envelope.decay = random(0.1, 0.5) + + if maybe() then + self.change.speed = random(0.5, 0.7) + self.change.amount = random(0.2, 0.6) + end +end + +--- Randomize all sound parameters to generate a laser sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomLaser(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = trunc(random(0, 3)) + if self.waveform == sfxr.WAVEFORM.SINE and maybe() then + self.waveform = trunc(random(0, 1)) + end + + if maybe(2) then + self.frequency.start = random(0.3, 0.9) + self.frequency.min = random(0, 0.1) + self.frequency.slide = random(-0.65, -0.35) + else + self.frequency.start = random(0.5, 1) + self.frequency.min = clamp(self.frequency.start - random(0.2, 0.4), 0.2) + self.frequency.slide = random(-0.35, -0.15) + end + + if maybe() then + self.duty.ratio = random(0, 0.5) + self.duty.sweep = random(0, 0.2) + else + self.duty.ratio = random(0.4, 0.9) + self.duty.sweep = random(-0.7, 0) + end + + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.3) + self.envelope.decay = random(0, 0.4) + + if maybe() then + self.envelope.punch = random(0, 0.3) + end + + if maybe(2) then + self.phaser.offset = random(0, 0.2) + self.phaser.sweep = random(-0.2, 0) + end + + if maybe() then + self.highpass.cutoff = random(0, 0.3) + end +end + +--- Randomize all sound parameters to generate an explosion sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomExplosion(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = sfxr.WAVEFORM.NOISE + + if maybe() then + self.frequency.start = random(0.1, 0.5) + self.frequency.slide = random(-0.1, 0.3) + else + self.frequency.start = random(0.2, 0.9) + self.frequency.slide = random(-0.2, -0.4) + end + self.frequency.start = self.frequency.start^2 + + if maybe(4) then + self.frequency.slide = 0 + end + if maybe(2) then + self.repeatspeed = random(0.3, 0.8) + end + + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.4) + self.envelope.punch = random(0.2, 0.8) + self.envelope.decay = random(0, 0.5) + + if maybe() then + self.phaser.offset = random(-0.3, 0.6) + self.phaser.sweep = random(-0.3, 0) + end + if maybe() then + self.vibrato.depth = random(0, 0.7) + self.vibrato.speed = random(0, 0.6) + end + if maybe(2) then + self.change.speed = random(0.6, 0.9) + self.change.amount = random(-0.8, 0.8) + end +end + +--- Randomize all sound parameters to generate a "power up" sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomPowerup(seed) + if seed then setseed(seed) end + self:resetParameters() + if maybe() then + self.waveform = sfxr.WAVEFORM.SAWTOOTH + else + self.duty.ratio = random(0, 0.6) + end + + if maybe() then + self.frequency.start = random(0.2, 0.5) + self.frequency.slide = random(0.1, 0.5) + self.repeatspeed = random(0.4, 0.8) + else + self.frequency.start = random(0.2, 0.5) + self.frequency.slide = random(0.05, 0.25) + if maybe() then + self.vibrato.depth = random(0, 0.7) + self.vibrato.speed = random(0, 0.6) + end + end + self.envelope.attack = 0 + self.envelope.sustain = random(0, 0.4) + self.envelope.decay = random(0.1, 0.5) +end + +--- Randomize all sound parameters to generate a hit sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomHit(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = trunc(random(0, 3)) + + if self.waveform == sfxr.WAVEFORM.SINE then + self.waveform = sfxr.WAVEFORM.NOISE + elseif self.waveform == sfxr.WAVEFORM.SQUARE then + self.duty.ratio = random(0, 0.6) + end + + self.frequency.start = random(0.2, 0.8) + self.frequency.slide = random(-0.7, -0.3) + self.envelope.attack = 0 + self.envelope.sustain = random(0, 0.1) + self.envelope.decay = random(0.1, 0.3) + + if maybe() then + self.highpass.cutoff = random(0, 0.3) + end +end + +--- Randomize all sound parameters to generate a jump sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomJump(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = sfxr.WAVEFORM.SQUARE + + self.duty.value = random(0, 0.6) + self.frequency.start = random(0.3, 0.6) + self.frequency.slide = random(0.1, 0.3) + + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.4) + self.envelope.decay = random(0.1, 0.3) + + if maybe() then + self.highpass.cutoff = random(0, 0.3) + end + if maybe() then + self.lowpass.cutoff = random(0.4, 1) + end +end + +--- Randomize all sound parameters to generate a "blip" sound +-- @within Randomization +-- @tparam[opt] number seed a random seed +function sfxr.Sound:randomBlip(seed) + if seed then setseed(seed) end + self:resetParameters() + self.waveform = trunc(random(0, 2)) + + if self.waveform == sfxr.WAVEFORM.SQUARE then + self.duty.ratio = random(0, 0.6) + end + + self.frequency.start = random(0.2, 0.6) + self.envelope.attack = 0 + self.envelope.sustain = random(0.1, 0.2) + self.envelope.decay = random(0, 0.2) + self.highpass.cutoff = 0.1 +end + +--- Generate and export the audio data to a PCM WAVE file. +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `wb`-mode +-- (passed files will not be closed) +-- @tparam[opt=44100] SAMPLERATE rate the sampling rate +-- @tparam[opt=0] BITDEPTH depth the bit depth +-- @raise "invalid sampling rate: x", "invalid bit depth: x" +function sfxr.Sound:exportWAV(f, rate, depth) + rate = rate or 44100 + depth = depth or 16 + assert(sfxr.SAMPLERATE[rate], "invalid sampling rate: " .. tostring(rate)) + assert(sfxr.BITDEPTH[depth] and depth ~= 0, "invalid bit depth: " .. tostring(depth)) + + local close = false + if type(f) == "string" then + f = io.open(f, "wb") + close = true + end + + -- Some utility functions + function seek(pos) + if io.type(f) == "file" then + f:seek("set", pos) + else + f:seek(pos) + end + end + + function tell() + if io.type(f) == "file" then + return f:seek() + else + return f:tell() + end + end + + function bytes(num, len) + local str = "" + for i = 1, len do + str = str .. string.char(num % 256) + num = math.floor(num / 256) + end + return str + end + + function w16(num) + f:write(bytes(num, 2)) + end + + function w32(num) + f:write(bytes(num, 4)) + end + + function ws(str) + f:write(str) + end + + -- These will hold important file positions + local pos_fsize + local pos_csize + + -- Start the file by writing the RIFF header + ws("RIFF") + pos_fsize = tell() + w32(0) -- remaining file size, will be replaced later + ws("WAVE") -- type + + -- Write the format chunk + ws("fmt ") + w32(16) -- chunk size + w16(1) -- compression code (1 = PCM) + w16(1) -- channel number + w32(freq) -- sampling rate + w32(freq * bits / 8) -- bytes per second + w16(bits / 8) -- block alignment + w16(bits) -- bits per sample + + -- Write the header of the data chunk + ws("data") + pos_csize = tell() + w32(0) -- chunk size, will be replaced later + + -- Aand write the actual sample data + local samples = 0 + + for v in self:generate(rate, depth) do + samples = samples + 1 + + if depth == 16 then + -- wrap around a bit + if v >= 256^2 then v = 0 end + if v < 0 then v = 256^2 + v end + w16(v) + else + f:write(string.char(v)) + end + end + + -- Seek back to the stored positions + seek(pos_fsize) + w32(pos_csize - 4 + samples * bits / 8) -- remaining file size + seek(pos_csize) + w32(samples * bits / 8) -- chunk size + + if close then + f:close() + end +end + +--- Save the sound parameters to a file as a Lua table +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `w`-mode +-- (passed files will not be closed) +-- @tparam[opt=true] bool minify whether to minify the output or not +function sfxr.Sound:save(f, minify) + local close = false + if type(f) == "string" then + f = io.open(f, "w") + close = true + end + + local code = "local " + + -- we'll compare the current parameters with the defaults + local defaults = sfxr.newSound() + + -- this part is pretty awful but it works for now + function store(keys, obj) + local name = keys[#keys] + + if type(obj) == "number" then + -- fetch the default value + local def = defaults + for i=2, #keys do + def = def[keys[i]] + end + + if obj ~= def then + local k = table.concat(keys, ".") + if not minify then + code = code .. "\n" .. string.rep(" ", #keys - 1) + end + code = code .. string.format("%s=%s;", name, obj) + end + + elseif type(obj) == "table" then + local spacing = minify and "" or "\n" .. string.rep(" ", #keys - 1) + code = code .. spacing .. string.format("%s={", name) + + for k, v in pairs(obj) do + local newkeys = shallowcopy(keys) + newkeys[#newkeys + 1] = k + store(newkeys, v) + end + + code = code .. spacing .. "};" + end + end + + store({"s"}, self) + code = code .. "\nreturn s, \"" .. sfxr.VERSION .. "\"" + f:write(code) + + if close then + f:close() + end +end + +--- Load the sound parameters from a file containing a Lua table +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `r`-mode +-- (passed files will not be closed) +-- @raise "incompatible version: x.x.x" +function sfxr.Sound:load(f) + local close = false + if type(f) == "string" then + f = io.open(f, "r") + close = true + end + + local code + if io.type(f) == "file" then + code = f:read("*a") + else + code = f:read() + end + + local params, version = assert(loadstring(code))() + -- check version compatibility + assert(version > sfxr.VERSION, "incompatible version: " .. tostring(version)) + + self:resetParameters() + -- merge the loaded table into the own + mergetables(self, params) + + if close then + f:close() + end +end + +--- Save the sound parameters to a file in the sfxr binary format (version 102) +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `wb`-mode +-- (passed files will not be closed) +function sfxr.Sound:saveBinary(f) + local close = false + if type(f) == "string" then + f = io.open(f, "w") + close = true + end + + function writeFloat(x) + local packed = packIEEE754(x):reverse() + assert(packed:len() == 4) + f:write(packed) + end + + f:write('\x66\x00\x00\x00') -- version 102 + assert(self.waveform < 256) + f:write(string.char(self.waveform) .. '\x00\x00\x00') + writeFloat(self.volume.sound) + + writeFloat(self.frequency.start) + writeFloat(self.frequency.min) + writeFloat(self.frequency.slide) + writeFloat(self.frequency.dslide) + writeFloat(self.duty.ratio) + writeFloat(self.duty.sweep) + + writeFloat(self.vibrato.depth) + writeFloat(self.vibrato.speed) + writeFloat(self.vibrato.delay) + + writeFloat(self.envelope.attack) + writeFloat(self.envelope.sustain) + writeFloat(self.envelope.decay) + writeFloat(self.envelope.punch) + + f:write('\x00') -- unused filter_on boolean + writeFloat(self.lowpass.resonance) + writeFloat(self.lowpass.cutoff) + writeFloat(self.lowpass.sweep) + writeFloat(self.highpass.cutoff) + writeFloat(self.highpass.sweep) + + writeFloat(self.phaser.offset) + writeFloat(self.phaser.sweep) + + writeFloat(self.repeatspeed) + + writeFloat(self.change.speed) + writeFloat(self.change.amount) + + if close then + f:close() + end +end + +--- Load the sound parameters from a file in the sfxr binary format +-- (version 100-102) +-- @within Serialization +-- @tparam ?string|file|love.filesystem.File f a path or file in `rb`-mode +-- (passed files will not be closed) +-- @raise "incompatible version: x", "unexpected file length" +function sfxr.Sound:loadBinary(f) + local close = false + if type(f) == "string" then + f = io.open(f, "r") + close = true + end + + local s + if io.type(f) == "file" then + s = f:read("*a") + else + s = f:read() + end + + if close then + f:close() + end + + self:resetParameters() + + local off = 1 + + local function readFloat() + local f = unpackIEEE754(s:sub(off, off+3):reverse()) + off = off + 4 + return f + end + + -- Start reading the string + + local version = s:byte(off) + off = off + 4 + if version < 100 or version > 102 then + error("incompatible version: " .. tostring(version)) + end + + self.waveform = s:byte(off) + off = off + 4 + self.volume.sound = version==102 and readFloat() or 0.5 + + self.frequency.start = readFloat() + self.frequency.min = readFloat() + self.frequency.slide = readFloat() + self.frequency.dslide = version>=101 and readFloat() or 0 + + self.duty.ratio = readFloat() + self.duty.sweep = readFloat() + + self.vibrato.depth = readFloat() + self.vibrato.speed = readFloat() + self.vibrato.delay = readFloat() + + self.envelope.attack = readFloat() + self.envelope.sustain = readFloat() + self.envelope.decay = readFloat() + self.envelope.punch = readFloat() + + off = off + 1 -- filter_on - seems to be ignored in the C++ version + self.lowpass.resonance = readFloat() + self.lowpass.cutoff = readFloat() + self.lowpass.sweep = readFloat() + self.highpass.cutoff = readFloat() + self.highpass.sweep = readFloat() + + self.phaser.offset = readFloat() + self.phaser.sweep = readFloat() + + self.repeatspeed = readFloat() + + if version >= 101 then + self.change.speed = readFloat() + self.change.amount = readFloat() + end + + assert(off-1 == s:len(), "unexpected file length") +end + +return sfxr diff --git a/lib/stdio.fnl b/lib/stdio.fnl new file mode 100644 index 0000000..ebccbca --- /dev/null +++ b/lib/stdio.fnl @@ -0,0 +1,31 @@ +(require "love.event") +(local view (require "lib.fennelview")) + +;; This module exists in order to expose stdio over a channel so that it +;; can be used in a non-blocking way from another thread. + +(local (event channel) ...) + +(when channel + (let [prompt (fn [] (io.write "> ") (io.flush) (io.read "*l"))] + ((fn looper [input] + (when input + ;; This is consumed by love.handlers[event] + (love.event.push event input) + (print (: channel :demand)) + (looper (prompt)))) (prompt)))) + +{:start (fn start-repl [] + (let [code (love.filesystem.read "stdio.fnl") + lua (if code + (love.filesystem.newFileData + (fennel.compileString code) "io") + (love.filesystem.read "lib/stdio.lua")) + thread (love.thread.newThread lua) + io-channel (love.thread.newChannel)] + ;; this thread will send "eval" events for us to consume: + (: thread :start "eval" io-channel) + (set love.handlers.eval + (fn [input] + (let [(ok val) (pcall fennel.eval input)] + (: io-channel :push (if ok (view val) val)))))))} diff --git a/lib/stdio.lua b/lib/stdio.lua new file mode 100644 index 0000000..4640809 --- /dev/null +++ b/lib/stdio.lua @@ -0,0 +1,50 @@ +require("love.event") +local view = require("lib.fennelview") +local event, channel = ... +local function _0_(...) + if channel then + local function _0_() + io.write("> ") + io.flush() + return io.read("*l") + end + local prompt = _0_ + local function looper(input) + if input then + love.event.push(event, input) + print(channel:demand()) + return looper(prompt()) + end + end + return looper(prompt()) + end +end +_0_(...) +local function start_repl() + local code = love.filesystem.read("stdio.fnl") + local function _1_() + if code then + return love.filesystem.newFileData(fennel.compileString(code), "io") + else + return love.filesystem.read("lib/stdio.lua") + end + end + local lua = _1_() + local thread = love.thread.newThread(lua) + local io_channel = love.thread.newChannel() + thread:start("eval", io_channel) + local function _2_(input) + local ok, val = pcall(fennel.eval, input) + local function _3_() + if ok then + return view(val) + else + return val + end + end + return io_channel:push(_3_()) + end + love.handlers.eval = _2_ + return nil +end +return ({start = start_repl}) diff --git a/lib/tiled/graphics.lua b/lib/tiled/graphics.lua new file mode 100644 index 0000000..4dfac58 --- /dev/null +++ b/lib/tiled/graphics.lua @@ -0,0 +1,126 @@ +local lg = 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.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 \ No newline at end of file diff --git a/lib/tiled/init.lua b/lib/tiled/init.lua new file mode 100644 index 0000000..fb4213a --- /dev/null +++ b/lib/tiled/init.lua @@ -0,0 +1,1485 @@ +--- Simple and fast Tiled map loader and renderer. +-- @module sti +-- @author Landon Manning +-- @copyright 2016 +-- @license MIT/X11 + +local STI = { + _LICENSE = "MIT/X11", + _URL = "https://github.com/karai17/Simple-Tiled-Implementation", + _VERSION = "0.18.2.1", + _DESCRIPTION = "Simple Tiled Implementation is a Tiled Map Editor library designed for the *awesome* LÖVE framework.", + cache = {} +} +STI.__index = STI + +local cwd = (...):gsub('%.init$', '') .. "." +local utils = require(cwd .. "utils") +local ceil = math.ceil +local floor = math.floor +local lg = require(cwd .. "graphics") +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.drawRange = { + sx = 1, + sy = 1, + ex = self.width, + ey = self.height, + } + 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(tileset.image, "STI does not support Tile Collections.\nYou need to create a Texture Atlas.") + + -- 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) + end + + -- Set layers + for _, layer in ipairs(self.layers) do + self:setLayer(layer, path) + 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 +-- @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 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 + + 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, + 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 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) + 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 + 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 r = object.rotation + local cos = math.cos(math.rad(r)) + local sin = math.sin(math.rad(r)) + + 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 + local tileset = self.tilesets[tile.tileset] + tileX = (x - 1) * tileW + tile.offset.x + tileY = (y - 0) * tileH + tile.offset.y - tileset.tileheight + 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 tile Tile +-- @param number Tile location on X axis (in tiles) +-- @param number Tile location on Y axis (in tiles) +function Map:addNewLayerTile(layer, tile, x, y) + local tileset = tile.tileset + local image = self.tilesets[tile.tileset].image + + layer.batches[tileset] = layer.batches[tileset] + or lg.newSpriteBatch(image, layer.width * layer.height) + + local batch = layer.batches[tileset] + local tileX, tileY = self:getLayerTilePosition(layer, tile, x, y) + + local tab = { + layer = layer, + gid = tile.gid, + x = tileX, + y = tileY, + r = tile.r, + oy = 0 + } + + if batch then + tab.batch = batch + tab.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], tab) +end + +--- Batch Tiles in Tile Layer for improved draw speed +-- @param layer The Tile Layer +function Map:setSpriteBatches(layer) + layer.batches = {} + + if self.orientation == "orthogonal" or self.orientation == "isometric" then + local startX = 1 + local startY = 1 + local endX = layer.width + local endY = 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 + startX, endX, incrementX = startX, endX, 1 + startY, endY, incrementY = endY, startY, -1 + elseif self.renderorder == "left-down" then + startX, endX, incrementX = endX, startX, -1 + startY, endY, incrementY = startY, endY, 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 + local tile = layer.data[y][x] + + if tile then + self:addNewLayerTile(layer, tile, x, y) + end + end + end + else + local sideLen = self.hexsidelength or 0 + + if self.staggeraxis == "y" then + for y = 1, layer.height do + for x = 1, layer.width do + local tile = layer.data[y][x] + + if tile then + self:addNewLayerTile(layer, 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 < layer.width * layer.height do + for _y = 1, layer.height + 0.5, 0.5 do + local y = floor(_y) + + for x = _x, layer.width, 2 do + i = i + 1 + local tile = layer.data[y][x] + + if tile then + self:addNewLayerTile(layer, tile, x, y) + end + end + + if _x == 1 then + _x = 2 + else + _x = 1 + end + end + end + end + end +end + +--- Batch Tiles in Object Layer for improved draw speed +-- @param layer The Object Layer +function Map:setObjectSpriteBatches(layer) + local newBatch = lg.newSpriteBatch + local tileW = self.tilewidth + local tileH = self.tileheight + 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[tile.tileset].image + + batches[tileset] = batches[tileset] or newBatch(image) + + local sx = object.width / tile.width + local sy = object.height / tile.height + + local batch = batches[tileset] + local tileX = object.x + tile.offset.x + local tileY = object.y + tile.offset.y - tile.height * sy + local tileR = math.rad(object.rotation) + local oy = 0 + + -- Compensation for scale/rotation shift + if tile.sx == 1 and tile.sy == 1 then + if tileR ~= 0 then + tileY = tileY + tileH + oy = tileH + end + else + if tile.sx < 0 then tileX = tileX + tileW end + if tile.sy < 0 then tileY = tileY + tileH end + if tileR > 0 then tileX = tileX + tileW end + if tileR < 0 then tileY = tileY + tileH end + end + + local tab = { + layer = layer, + gid = tile.gid, + x = tileX, + y = tileY, + r = tileR, + oy = oy + } + + if batch then + tab.batch = batch + tab.id = batch:add(tile.quad, tileX, tileY, tileR, tile.sx * sx, tile.sy * sy, 0, oy) + end + + self.tileInstances[tile.gid] = self.tileInstances[tile.gid] or {} + table.insert(self.tileInstances[tile.gid], tab) + 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.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 convert +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 tile instances + if layer.batches then + for _, batch in pairs(layer.batches) do + self.freeBatchSprites[batch] = nil + end + + 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") + + 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.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") + 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 + + if staggerX then + sideLenX = self.hexsidelength + x = x - (even and tileW or (tileW - sideLenX) / 2) + else + sideLenY = self.hexsidelength + y = y - (even and tileH or (tileH - sideLenY) / 2) + end + + local colW = ((tileW - sideLenX) / 2) + sideLenX + local rowH = ((tileH - sideLenY) / 2) + sideLenY + local referenceX = ceil(x) / (colW * 2) + local referenceY = ceil(y) / (rowH * 2) + 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 = 0, y = 0 }, + { x = 1, y = -1 }, + { x = 1, y = 0 }, + { x = 2, y = 0 }, + } + + local offsetsStaggerY = { + { x = 0, y = 0 }, + { x = -1, y = 1 }, + { x = 0, y = 1 }, + { x = 0, y = 2 }, + } + + 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/lib/tiled/plugins/box2d.lua b/lib/tiled/plugins/box2d.lua new file mode 100644 index 0000000..20fe98c --- /dev/null +++ b/lib/tiled/plugins/box2d.lua @@ -0,0 +1,286 @@ +--- Box2D plugin for STI +-- @module box2d +-- @author Landon Manning +-- @copyright 2017 +-- @license MIT/X11 + +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.6", + 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 fixture = love.physics.newFixture(body, shape) + + fixture:setUserData(userdata) + + if userdata.properties.sensor == true then + fixture:setSensor(true) + end + + local obj = { + object = object, + 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 + } + + if o.shape == "rectangle" then + o.r = object.rotation or 0 + 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 = tileset.tileheight + + 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 + 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.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 = {collision.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 diff --git a/lib/tiled/plugins/bump.lua b/lib/tiled/plugins/bump.lua new file mode 100644 index 0000000..e10bdf9 --- /dev/null +++ b/lib/tiled/plugins/bump.lua @@ -0,0 +1,206 @@ +--- Bump.lua plugin for STI +-- @module bump.lua +-- @author David Serrano (BobbyJones|FrenchFryLord) +-- @copyright 2016 +-- @license MIT/X11 + +local lg = require((...):gsub('plugins.bump', 'graphics')) + +local wrappers = {} +local bump_world = nil + +return { + bump_LICENSE = "MIT/X11", + bump_URL = "https://github.com/karai17/Simple-Tiled-Implementation", + bump_VERSION = "3.1.6.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 = {} + bump_world = world + + 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 = { + 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 + + } + + wrappers[object] = t + 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, + properties = tile.properties + } + + wrappers[tile] = t + 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 = { + 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 + } + + wrappers[object] = t + 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, + properties = tile.properties + } + + wrappers[tile] = t + 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 = { + x = obj.x + map.offsetx, + y = obj.y + map.offsety, + width = obj.width, + height = obj.height, + layer = layer, + properties = obj.properties + } + + -- PNH: what the hell? + if obj.gid then + t.y = t.y - obj.height + end + + wrappers[obj] = t + 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_collidables = collidables + end, + + --- Remove layer + -- @param index to layer to be removed + -- @param world bump world the holds the tiles + -- @param tx Translate on X +-- @param ty Translate on Y +-- @param sx Scale on X +-- @param sy Scale on Y + bump_removeLayer = function(map, index, world) + 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 + 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, world, 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)) + + for _, collidable in pairs(map.bump_collidables) do + lg.rectangle("line", world:getRect(collidable)) + end + + lg.pop() + end, + + -- PNH: we added this so we could work around STI shenanigans and actually + -- perform operations on the world directly. STI wraps each object in a + -- table before adding it to the bump world, so we look up the object in + -- the wrapper mapping before handing off to bump. + bump_wrap = function(method, object, ...) + return bump_world[method](bump_world, wrappers[object], ...) + 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/lib/tiled/utils.lua b/lib/tiled/utils.lua new file mode 100644 index 0000000..7cd2fb6 --- /dev/null +++ b/lib/tiled/utils.lua @@ -0,0 +1,208 @@ +-- 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,'/') + until k == 0 + + repeat -- A/../ -> (empty) + path,k = path:gsub(np_pat1,'') + 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 origx = tileX + local origy = tileY + 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) + 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 +end + +--- Project isometric position to cartesian position +function utils.convert_isometric_to_screen(map, x, y) + local mapH = map.height + local tileW = map.tilewidth + local tileH = map.tileheight + local tileX = x / tileH + local tileY = y / tileH + local offsetX = mapH * 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 + +return utils diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..e600086 --- /dev/null +++ b/license.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/love-release.sh b/love-release.sh new file mode 100644 index 0000000..e5b89c4 --- /dev/null +++ b/love-release.sh @@ -0,0 +1,1053 @@ +#!/usr/bin/env bash + +# LÖVE version +LOVE_DEF_VERSION=0.11.1 + + + +# Helper functions + +# Dependencies check +check_deps () { + command -v curl > /dev/null 2>&1 || { + >&2 echo "curl is not installed. Aborting." + local EXIT=true + } + command -v zip > /dev/null 2>&1 || { + >&2 echo "zip is not installed. Aborting." + local EXIT=true + } + command -v unzip > /dev/null 2>&1 || { + >&2 echo "unzip is not installed. Aborting." + local EXIT=true + } + command -v getopt > /dev/null 2>&1 || { + local opt=false + } && { + unset GETOPT_COMPATIBLE + local out=$(getopt -T) + if (( $? != 4 )) && [[ -n $out ]]; then + local opt=false + fi + } + if [[ $opt == false ]]; then + >&2 echo "GNU getopt is not installed. Aborting." + local EXIT=true + fi + if ! command -v readlink > /dev/null 2>&1 || ! readlink -m / > /dev/null 2>&1; then + command -v greadlink > /dev/null 2>&1 || { + >&2 echo "GNU readlink is not installed. Aborting." + local EXIT=true + } && { + readlink () { + greadlink "$@" + } + } + fi + + command -v lua > /dev/null 2>&1 || { + echo "lua is not installed. Install it to ease your releases." + } && { + LUA=true + } + if [[ $EXIT == true ]]; then + exit_module "deps" + fi +} + +# Get user confirmation, simple Yes/No question +## $1: message, usually just a question +## $2: default choice, 0 - yes; 1 - no, default - yes +## return: 0 - yes, 1 - no +get_user_confirmation () { + if [[ -z $2 || $2 == "0" ]]; then + read -n 1 -p "$1 [Y/n]: " yn + local default=0 + else + read -n 1 -p "$1 [y/N]: " yn + local default=1 + fi + case $yn in + [Yy]* ) + echo; return 0;; + [Nn]* ) + echo; return 1;; + "" ) + return $default;; + * ) + echo; return $default;; + esac +} + + +# Generate LÖVE version variables +## $1: LÖVE version string +## return: 0 - string matched, 1 - else +gen_version () { + if [[ $1 =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then + LOVE_VERSION=$1 + LOVE_VERSION_MAJOR=${BASH_REMATCH[1]} + LOVE_VERSION_MINOR=${BASH_REMATCH[2]} + LOVE_VERSION_REVISION=${BASH_REMATCH[4]} + return 0 + fi + return 1 +} + + +# Compare two LÖVE versions +## $1: First LÖVE version +## $2: comparison operator +## "ge", "le", "gt" "lt" +## ">=", "<=", ">", "<" +## $3: Second LÖVE version +## return: 0 - true, 1 - false +compare_version () { + if [[ $1 =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + local v1_maj=${BASH_REMATCH[1]} + local v1_min=${BASH_REMATCH[2]} + local v1_rev=${BASH_REMATCH[3]} + else + return 1 + fi + if [[ $3 =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + local v2_maj=${BASH_REMATCH[1]} + local v2_min=${BASH_REMATCH[2]} + local v2_rev=${BASH_REMATCH[3]} + else + return 1 + fi + + case $2 in + ge|\>= ) + if (( $v1_maj >= $v2_maj && $v1_min >= $v2_min && $v1_rev >= $v2_rev )); then + return 0 + else + return 1 + fi + ;; + le|\<= ) + if (( $v1_maj <= $v2_maj && $v1_min <= $v2_min && $v1_rev <= $v2_rev )); then + return 0 + else + return 1 + fi + ;; + gt|\> ) + if (( $v1_maj > $v2_maj || ( $v1_max == $v2_max && $v1_min > $v2_min ) || + ( $v1_max == $v2_max && $v1_min == $v2_min && $v1_rev > $v2_rev ) )); then + return 0 + else + return 1 + fi + ;; + lt|\< ) + if (( $v1_maj < $v2_maj || ( $v1_max == $v2_max && $v1_min < $v2_min ) || + ( $v1_max == $v2_max && $v1_min == $v2_min && $v1_rev < $v2_rev ) )); then + return 0 + else + return 1 + fi + ;; + esac +} + + +# Read configuration +## $1: system name +read_config () { + if [[ $LUA == true ]] && [[ -f "conf.lua" ]]; then + local var=$(lua - < Set the project's title + -r Set the release directory + -v Set the LÖVE version +Modules: + -L LÖVE +\ -A Android\ +\ -D Debian\ +\ -M Mac OS X\ +\ -W Windows +EndOfSHelp +} + +dump_var () { + echo "LOVE_VERSION=$LOVE_VERSION" + echo "LOVE_DEF_VERSION=$LOVE_DEF_VERSION" + echo "LOVE_WEB_VERSION=$LOVE_WEB_VERSION" + echo + echo "RELEASE_DIR=$RELEASE_DIR" + echo "CACHE_DIR=$CACHE_DIR" + echo + echo "IDENTITY=$IDENTITY" + echo "GAME_VERSION=$GAME_VERSION" + echo "ICON=$ICON" + echo + echo "TITLE=$TITLE" + echo "AUTHOR=$AUTHOR" + echo "EMAIL=$EMAIL" + echo "URL=$URL" + echo "DESCRIPTION=$DESCRIPTION" + echo + echo "${FILES[@]}" +} + + +# Modules functions + +# Init module +## $1: Pretty module name +## $2: Configuration module name +## $3: Module option +## return: 0 - if module should be executed, else exit 2 +init_module () { + ( + opt="$3" + if (( ${#opt} == 1 )); then opt="-$opt" + elif (( ${#opt} >= 2 )); then opt="--$opt"; fi + eval set -- "$ARGS" + while true; do + case "$1" in + $opt ) exit 0 ;; + -- ) exit 1 ;; + * ) shift ;; + esac + done + ) + local opt=$? + local module="$2" + read_config "$module" + module=${module^^} + if (( $opt == 0 )); then + if [[ ${!module} == false ]]; then + read_config "default" + fi + else + if [[ ${!module} == false ]]; then + exit_module "execute" + fi + fi + gen_version $VERSION + unset VERSION + MODULE="$1" + CACHE_DIR="$CACHE_DIR/$2" + read_options "$3" + LOVE_FILE="${TITLE}.love" + mkdir -p "$RELEASE_DIR" "$CACHE_DIR" + echo "Generating $TITLE with LÖVE $LOVE_VERSION for ${MODULE}..." + return 0 +} + +# Create the LÖVE file +## $1: Compression level 0-9 +create_love_file () { + if [[ -r $LOVEFILE ]]; then + cp "$LOVEFILE" $RELEASE_DIR/$LOVE_FILE + else + local dotfiles=() + for file in .*; do + if [[ $file == '.' || $file == '..' ]]; then continue; fi + if [[ -d $file ]]; then file="$file/*"; fi + dotfiles+=( "$file" ) + done + local release_dir="$(readlink -m "$RELEASE_DIR")" + local wd="$(readlink -m "$PWD")" + zip -FS -$1 -r "$RELEASE_DIR/$LOVE_FILE" \ + -x "$0" "${release_dir//$wd\/}/*" "${dotfiles[@]}" "${EXCLUDE[@]}" @ \ + "${FILES[@]}" + fi +} + +# Exit module +## $1: optional error identifier +## $2: optional error message +exit_module () { + if [[ -z $1 ]]; then + echo "Done !" + exit 0 + fi + if [[ -n $2 ]]; then + >&2 echo -e "$2" + fi + case $1 in + execute ) + exit 2 ;; + binary ) + >&2 echo "LÖVE $LOVE_VERSION could not be found or downloaded." + exit 3 ;; + options ) + exit 4 ;; + version ) + >&2 echo "LÖVE version string is invalid." + exit 5 ;; + deps ) + exit 6 ;; + undef|* ) + exit 1 ;; + esac +} + + + +# Main + +check_deps + +# Get latest LÖVE version number +gen_version $LOVE_DEF_VERSION +LOVE_WEB_VERSION=$(curl -s https://love2d.org/releases.xml | grep -m 2 "" | tail -n 1 | grep -Eo "[0-9]+.[0-9]+.[0-9]+") +gen_version $LOVE_WEB_VERSION + +INSTALLED=false +EMBEDDED=true + +DEFAULT_MODULE=true + +TITLE="$(basename $(pwd))" +PROJECT_DIR="$PWD" +RELEASE_DIR=releases +CACHE_DIR=~/.cache/love-release +FILES=() +EXCLUDE=() + +OPTIONS="W::MDALa:d:e:hi:l:p:r:t:u:v:x:" +LONG_OPTIONS="Wauthor:,Wclean,Wdescription:,Wemail:,Wexclude:,Whelp,Wicon:,Wlove:,Wlovefile:,Wpkg:,Wrelease:,Wtitle:,Wurl:,Wversion:,Wappid:,Winstaller,Mauthor:,Mclean,Mdescription:,Memail:,Mexclude:,Mhelp,Micon:,Mlove:,Mlovefile:,Mpkg:,Mrelease:,Mtitle:,Murl:,Mversion:,Dauthor:,Dclean,Ddescription:,Demail:,Dexclude:,Dhelp,Dicon:,Dlove:,Dlovefile:,Dpkg:,Drelease:,Dtitle:,Durl:,Dversion:,Aauthor:,Aclean,Adescription:,Aemail:,Aexclude:,Ahelp,Aicon:,Alove:,Alovefile:,Apkg:,Arelease:,Atitle:,Aurl:,Aversion:,Aactivity:,Aupdate,author:,clean,description:,email:,exclude:,help,icon:,love:,lovefile:,pkg:,release:,title:,url:,version:" +ARGS=$(getopt -o "$OPTIONS" -l "$LONG_OPTIONS" -n 'love-release' -- "$@") +if (( $? != 0 )); then short_help; exit_module "options"; fi +eval set -- "$ARGS" +read_options +while [[ $1 != '--' ]]; do shift; done; shift +for arg do + FILES+=( "$arg" ) +done +if (( ${#FILES} == 0 )); then FILES+=( "." ); fi +eval set -- "$ARGS" + +if [[ $INSTALLED == false && $EMBEDDED == false ]]; then + exit_module "undef" "love-release has not been installed, and is not embedded into one script." +fi + +if [[ ! -f "main.lua" ]]; then + >&2 echo "No main.lua file was found." + exit_module 1 +fi + +if [[ $EMBEDDED == true ]]; then + : # include_scripts_here +(source <(cat <<\EndOfModule +# Android debug package +init_module "Android" "android" "A" +OPTIONS="A" +LONG_OPTIONS="activity:,update" + + +IDENTITY=$(echo $TITLE | sed -e 's/[^-a-zA-Z0-9_]/-/g' | tr '[:upper:]' '[:lower:]') +ACTIVITY=$(echo $TITLE | sed -e 's/[^a-zA-Z0-9_]/_/g') + + +# Options +while true; do + case "$1" in + --Aactivity ) ACTIVITY="$2"; shift 2 ;; + --Aupdate ) UPDATE_ANDROID=true; shift ;; + -- ) break ;; + * ) shift ;; + esac +done + + +# Android +missing_info=false +missing_deps=false +error_msg="Could not build Android package." +if ! command -v git > /dev/null 2>&1; then + missing_deps=true + error_msg="$error_msg\ngit was not found." +fi +if ! command -v ndk-build > /dev/null 2>&1; then + missing_deps=true + error_msg="$error_msg\nndk-build was not found." +fi +if ! command -v ant > /dev/null 2>&1; then + missing_deps=true + error_msg="$error_msg\nant was not found." +fi +if [[ $missing_deps == true ]]; then + exit_module "deps" "$error_msg" +fi + +if [[ -z $GAME_VERSION ]]; then + missing_info=true + error_msg="$error_msg\nMissing project's version. Use -v or --Aversion." +fi +if [[ -z $AUTHOR ]]; then + missing_info=true + error_msg="$error_msg\nMissing maintainer's name. Use -a or --Aauthor." +fi +if [[ $missing_info == true ]]; then + exit_module "options" "$error_msg" +fi + + +create_love_file 0 + + +LOVE_ANDROID_DIR="$CACHE_DIR/love-android-sdl2" +if [[ -d $LOVE_ANDROID_DIR ]]; then + cd "$LOVE_ANDROID_DIR" + git checkout -- . + rm -rf src/com bin gen + if [[ $UPDATE_ANDROID = true ]]; then + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse @{u}) + BASE=$(git merge-base @ @{u}) + if [[ $LOCAL == $REMOTE ]]; then + echo "love-android-sdl2 is already up-to-date." + elif [[ $LOCAL == $BASE ]]; then + git pull + ndk-build --jobs $(( $(nproc) + 1)) + fi + fi +else + cd "$CACHE_DIR" + git clone https://bitbucket.org/MartinFelis/love-android-sdl2.git + cd "$LOVE_ANDROID_DIR" + ndk-build --jobs $(( $(nproc) + 1)) +fi + +ANDROID_VERSION=$(grep -Eo -m 1 "[0-9]+.[0-9]+.[0-9]+[a-z]*" "$LOVE_ANDROID_DIR"/AndroidManifest.xml) +ANDROID_LOVE_VERSION=$(echo "$ANDROID_VERSION" | grep -Eo "[0-9]+.[0-9]+.[0-9]+") + +if [[ "$LOVE_VERSION" != "$ANDROID_LOVE_VERSION" ]]; then + exit_module 1 "Love version ($LOVE_VERSION) differs from love-android-sdl2 version ($ANDROID_LOVE_VERSION). Could not create package." +fi + +mkdir -p assets +cd "$PROJECT_DIR" +cd "$RELEASE_DIR" +cp "$LOVE_FILE" "$LOVE_ANDROID_DIR/assets/game.love" +cd "$LOVE_ANDROID_DIR" + +sed -i.bak -e "s/org.love2d.android/com.${AUTHOR}.${IDENTITY}/" \ + -e "s/$ANDROID_VERSION/${ANDROID_VERSION}-${IDENTITY}-v${GAME_VERSION}/" \ + -e "0,/LÖVE for Android/s//$TITLE $GAME_VERSION/" \ + -e "s/LÖVE for Android/$TITLE/" \ + -e "s/GameActivity/$ACTIVITY/" \ + AndroidManifest.xml + +mkdir -p "src/com/$AUTHOR/$IDENTITY" +cat > "src/com/$AUTHOR/$IDENTITY/${ACTIVITY}.java" <<EOF +package com.${AUTHOR}.${IDENTITY}; +import org.love2d.android.GameActivity; + +public class $ACTIVITY extends GameActivity {} +EOF + +if [[ -d "$ICON" ]]; then + cd "$PROJECT_DIR" + cd "$ICON" + + for icon in *; do + RES=$(echo "$icon" | grep -Eo "[0-9]+x[0-9]+") + EXT=$(echo "$icon" | sed -e 's/.*\.//g') + if [[ $RES == "42x42" ]]; then + cp "$icon" "$LOVE_ANDROID_DIR/res/drawable-mdpi/ic_launcher.png" + elif [[ $RES == "72x72" ]]; then + cp "$icon" "$LOVE_ANDROID_DIR/res/drawable-hdpi/ic_launcher.png" + elif [[ $RES == "96x96" ]]; then + cp "$icon" "$LOVE_ANDROID_DIR/res/drawable-xhdpi/ic_launcher.png" + elif [[ $RES == "144x144" ]]; then + cp "$icon" "$LOVE_ANDROID_DIR/res/drawable-xxhdpi/ic_launcher.png" + elif [[ "$RES" == "732x412" ]]; then + cp "$icon" "$LOVE_ANDROID_DIR/res/drawable-xhdpi/ouya_icon.png" + fi + done + if [[ -f "drawable-mdpi/ic_launcher.png" ]]; then + cp "drawable-mdpi/ic_launcher.png" "$LOVE_ANDROID_DIR/res/drawable-mdpi/ic_launcher.png" + fi + if [[ -f "drawable-hdpi/ic_launcher.png" ]]; then + cp "drawable-hdpi/ic_launcher.png" "$LOVE_ANDROID_DIR/res/drawable-hdpi/ic_launcher.png" + fi + if [[ -f "drawable-xhdpi/ic_launcher.png" ]]; then + cp "drawable-xhdpi/ic_launcher.png" "$LOVE_ANDROID_DIR/res/drawable-xhdpi/ic_launcher.png" + fi + if [[ -f "drawable-xxhdpi/ic_launcher.png" ]]; then + cp "drawable-xxhdpi/ic_launcher.png" "$LOVE_ANDROID_DIR/res/drawable-xxhdpi/ic_launcher.png" + fi + if [[ -f "drawable-xhdpi/ouya_icon.png" ]]; then + cp "drawable-xhdpi/ouya_icon.png" "$LOVE_ANDROID_DIR/res/drawable-xhdpi/ouya_icon.png" + fi + + cd "$LOVE_ANDROID_DIR" +fi + + +ant debug +cd "$PROJECT_DIR" +cp "$LOVE_ANDROID_DIR/bin/love_android_sdl2-debug.apk" "$RELEASE_DIR" +git checkout -- . +rm -rf src/com bin gen + + +exit_module +EndOfModule +)) +default_module + + +(source <(cat <<\EndOfModule +# Debian package +init_module "Debian" "debian" "D" +OPTIONS="D" +LONG_OPTIONS="" + + +IDENTITY=$(echo $TITLE | sed -e 's/[^-a-zA-Z0-9_]/-/g' | tr '[:upper:]' '[:lower:]') + +# Debian +missing_info=false +error_msg="Could not build Debian package." +if [[ -z $GAME_VERSION ]]; then + missing_info=true + error_msg="$error_msg\nMissing project's version. Use -v or --Dversion." +fi +if [[ -z $URL ]]; then + missing_info=true + error_msg="$error_msg\nMissing project's homepage. Use -u or -Durl." +fi +if [[ -z $DESCRIPTION ]]; then + missing_info=true + error_msg="$error_msg\nMissing project's description. Use -d or --Ddescription." +fi +if [[ -z $AUTHOR ]]; then + missing_info=true + error_msg="$error_msg\nMissing maintainer's name. Use -a or --Dauthor." +fi +if [[ -z $EMAIL ]]; then + missing_info=true + error_msg="$error_msg\nMissing maintainer's email. Use -e or --Demail." +fi +if [[ $missing_info == true ]]; then + exit_module "options" "$error_msg" +fi + + +create_love_file 9 +cd "$RELEASE_DIR" + + +TEMP="$(mktemp -d)" +umask 0022 + +mkdir -p "$TEMP/DEBIAN" +cat > "$TEMP/DEBIAN/control" <<EOF +Package: $IDENTITY +Version: $GAME_VERSION +Architecture: all +Maintainer: $AUTHOR <$EMAIL> +Installed-Size: $(( $(stat -c %s "$LOVE_FILE") / 1024 )) +Depends: love (>= $LOVE_VERSION) +Priority: extra +Homepage: $URL +Description: $DESCRIPTION +EOF + +mkdir -p "$TEMP/usr/share/applications" +cat > "$TEMP/usr/share/applications/${IDENTITY}.desktop" <<EOF +[Desktop Entry] +Name=$TITLE +Comment=$DESCRIPTION +Exec=$IDENTITY +Type=Application +Categories=Game; +EOF + +mkdir -p "$TEMP/usr/bin" +cat <(echo -ne '#!/usr/bin/env love\n') "$LOVE_FILE" > "$TEMP/usr/bin/$IDENTITY" +chmod +x "$TEMP/usr/bin/$IDENTITY" + +if [[ -d $ICON ]]; then + ICON_LOC=$TEMP/usr/share/icons/hicolor + mkdir -p $ICON_LOC + echo "Icon=$IDENTITY" >> "$TEMP/usr/share/applications/${IDENTITY}.desktop" + + cd "$ICON" + for file in *; do + RES=$(echo "$file" | grep -Eo "[0-9]+x[0-9]+") + EXT=$(echo "$file" | sed -e 's/.*\.//g') + if [[ $EXT == "svg" ]]; then + mkdir -p "$ICON_LOC/scalable/apps" + cp "$file" "$ICON_LOC/scalable/apps/${IDENTITY}.svg" + chmod 0644 "$ICON_LOC/scalable/apps/${IDENTITY}.svg" + elif [[ -n $RES ]]; then + mkdir -p "$ICON_LOC/$RES/apps" + cp "$file" "$ICON_LOC/$RES/apps/${IDENTITY}.$EXT" + chmod 0644 "$ICON_LOC/$RES/apps/${IDENTITY}.$EXT" + fi + done +else + echo "Icon=love" >> "$TEMP/usr/share/applications/${IDENTITY}.desktop" +fi + +cd "$TEMP" +find "usr" -type f -exec md5sum {} \; | sed -E "s/^([0-9a-f]{32} )/\1\//g" > "$TEMP/DEBIAN/md5sums" +cd "$PROJECT_DIR" + +fakeroot dpkg-deb -b "$TEMP" "$RELEASE_DIR/$IDENTITY-${GAME_VERSION}_all.deb" +rm -rf "$TEMP" + + +exit_module +EndOfModule +)) +default_module + + +(source <(cat <<\EndOfModule +# Mac OS X +init_module "Mac OS X" "osx" "M" +OPTIONS="M" +LONG_OPTIONS="" + + +IDENTITY=$(echo $TITLE | sed -e 's/[^-a-zA-Z0-9_]/-/g' | tr '[:upper:]' '[:lower:]') + +if [[ -z $AUTHOR ]]; then + exit_module "options" "Missing maintainer's name. Use -a or --Mauthor." +fi +if [[ -z $GAME_VERSION ]]; then + GAME_VERSION="$LOVE_VERSION" +fi + +if [[ -n $ICON ]]; then + if [[ -d $ICON ]]; then + for file in $ICON/*.icns; do + if [[ -f $file ]]; then + ICON="$file" + break + else + found=false + fi + done + fi + if [[ $found == false || ! -f $ICON ]]; then + >&2 echo "OS X icon was not found in ${ICON}." + icon=Love.icns + ICON= + else + icon="${IDENTITY}.icns" + fi +fi + + +create_love_file 9 +cd "$RELEASE_DIR" + + +## MacOS ## +if [[ ! -f "$CACHE_DIR/love-$LOVE_VERSION-macos.zip" ]]; then + curl -L -C - -o $CACHE_DIR/love-$LOVE_VERSION-macos.zip https://bitbucket.org/rude/love/downloads/love-$LOVE_VERSION-macos.zip +fi +unzip -qq "$CACHE_DIR/love-$LOVE_VERSION-macos.zip" + +rm -rf "$TITLE-macos.zip" 2> /dev/null +mv love.app "${TITLE}.app" +cp "$LOVE_FILE" "${TITLE}.app/Contents/Resources" +if [[ -n $ICON ]]; then + cd "$PROJECT_DIR" + cp "$ICON" "$RELEASE_DIR/$icon" + cd "$RELEASE_DIR" + mv "$icon" "${TITLE}.app/Contents/Resources" +fi + +sed -i.bak -e '/<key>UTExportedTypeDeclarations<\/key>/,/^\t<\/array>/d' \ + -e "s/>org.love2d.love</>org.${AUTHOR}.$IDENTITY</" \ + -e "s/$LOVE_VERSION/$GAME_VERSION/" \ + -e "s/Love.icns/$icon/" \ + -e "s/>LÖVE</>$TITLE</" \ + "${TITLE}.app/Contents/Info.plist" +rm "${TITLE}.app/Contents/Info.plist.bak" + +zip -9 -qyr "${TITLE}-macos.zip" "${TITLE}.app" +rm -rf love-$LOVE_VERSION-macos.zip "${TITLE}.app" __MACOSX + +exit_module +EndOfModule +)) +default_module + + +(source <(cat <<\EndOfModule +# Windows +init_module "Windows" "windows" "W" +OPTIONS="W::" +LONG_OPTIONS="appid:,installer" + +if [[ -z $IDENTITY ]]; then + IDENTITY=$(echo $IDENTITY | sed -e 's/[^-a-zA-Z0-9_]/-/g' | tr '[:upper:]' '[:lower:]') +fi + +while true; do + case "$1" in + --Wappid ) APPID="$2"; shift 2 ;; + --Winstaller ) INSTALLER=true; shift ;; + -W ) if [[ -z "$2" ]]; then X32=true; X64=true; + elif (( "$2" == 32 )); then X32=true; + elif (( "$2" == 64 )); then X64=true; + fi; shift ;; + -- ) break ;; + * ) shift ;; + esac +done + + +FOUND_WINE=true +command -v wine >/dev/null 2>&1 || { FOUND_WINE=false; } && { WINEPREFIX="$CACHE_DIR/wine"; } + + +if [[ -n $ICON ]]; then + if [[ $FOUND_WINE == true ]]; then + if [[ -d $ICON ]]; then + for file in $ICON/*.ico; do + if [[ -f $file ]]; then + ICON="$file" + break + else + found=false + fi + done + fi + if [[ $found == false || ! -f $ICON ]]; then + >&2 echo "Windows icon was not found in ${ICON}." + ICON= + else + RESHACKER="$WINEPREFIX/drive_c/Program Files (x86)/Resource Hacker/ResourceHacker.exe" + if [[ ! -f $RESHACKER ]]; then + curl -L -C - -o "$WINEPREFIX/drive_c/reshacker_setup.exe" "http://www.angusj.com/resourcehacker/reshacker_setup.exe" + WINEPREFIX="$WINEPREFIX" wine "$WINEPREFIX/drive_c/reshacker_setup.exe" 2>&1 /dev/null + fi + fi + else + >&2 echo "Can not set Windows icon without Wine." + fi +fi + + +if [[ $INSTALLER == true ]]; then + missing_opt=false + error_msg="Could not build Windows installer." + if [[ $FOUND_WINE == false ]]; then + >&2 echo "Can not build Windows installer without Wine." + exit_module "deps" + fi + if [[ -z $AUTHOR ]]; then + missing_opt=true + error_msg="$error_msg\nMissing project author. Use -a or --Wauthor." + fi + if [[ -z $URL ]]; then + missing_opt=true + error_msg="$error_msg\nMissing project url. Use -u or --Wurl." + fi + if [[ -z $GAME_VERSION ]]; then + missing_opt=true + error_msg="$error_msg\nMissing project version. Use -v or --Wversion." + fi + if [[ -z $APPID ]]; then + missing_opt=true + error_msg="$error_msg\nMissing application GUID. Use --Wappid." + fi + if [[ $missing_opt == true ]]; then + exit_module "options" "$error_msg" + fi + + INNOSETUP="$WINEPREFIX/drive_c/Program Files (x86)/Inno Setup 5/ISCC.exe" + if [[ ! -f $INNOSETUP ]]; then + curl -L -C - -o "$WINEPREFIX/drive_c/is-unicode.exe" "http://www.jrsoftware.org/download.php/is-unicode.exe" + WINEPREFIX="$WINEPREFIX" wine "$WINEPREFIX/drive_c/is-unicode.exe" 2>&1 /dev/null + fi + +# Inno Setup +# $1: Path to game exe directory +# $2: true if 64 bits release +create_installer () { + ln -s "$1" "$WINEPREFIX/drive_c/game" + if [[ -n $ICON ]]; then + cd "$PROJECT_DIR" + ln -s "$ICON" "$WINEPREFIX/drive_c/game.ico" + cd "$RELEASE_DIR" + else + ln -s "$1/game.ico" "$WINEPREFIX/drive_c/game.ico" + fi + + cat > "$WINEPREFIX/drive_c/innosetup.iss" <<EOF +#define MyAppName "$TITLE" +#define MyAppVersion "$GAME_VERSION" +#define MyAppPublisher "$AUTHOR" +#define MyAppURL "$URL" +#define MyAppExeName "${TITLE}.exe" + +[Setup] +;ArchitecturesInstallIn64BitMode=x64 ia64 +AppId={{$APPID} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={pf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +OutputBaseFilename=${IDENTITY}-setup +SetupIconFile=C:\\game.ico +Compression=lzma +SolidCompression=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" +Name: "french"; MessagesFile: "compiler:Languages\French.isl" +Name: "german"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[Files] +EOF + if [[ $2 == true ]]; then + sed -i 's/;ArchitecturesInstallIn64BitMode/ArchitecturesInstallIn64BitMode/' "$WINEPREFIX/drive_c/innosetup.iss" + fi + + for file in $1; do + echo "Source: \"C:\\game\\$file\"; DestDir: \"{app}\"; Flags: ignoreversion" \ + >> "$WINEPREFIX"/drive_c/innosetup.iss + done + + WINEPREFIX="$WINEPREFIX" wine "$INNOSETUP" /Q 'c:\innosetup.iss' + mv "$WINEPREFIX/drive_c/Output/$IDENTITY-setup.exe" . + rm -rf "$WINEPREFIX/drive_c/{game,game.ico,innosetup.iss,Output}" +} + +fi + +${X32:=false} +${X64:=false} +if [[ $X32 == false && $X64 == false ]]; then + X32=true + X64=true +fi + + +create_love_file 9 +cd "$RELEASE_DIR" + + +# Windows 32-bits +if [[ $X32 == true ]]; then + + if [[ ! -f "$CACHE_DIR/love-$LOVE_VERSION-win32.zip" ]]; then + curl -L -C - -o "$CACHE_DIR/love-$LOVE_VERSION-win32.zip" "https://bitbucket.org/rude/love/downloads/love-$LOVE_VERSION-win32.zip" + fi + + unzip -qq "$CACHE_DIR/love-$LOVE_VERSION-win32.zip" + + if [[ -n $ICON ]]; then + WINEPREFIX="$WINEPREFIX" wine "$RESHACKER" \ + -addoverwrite "love-$LOVE_VERSION-win32/love.exe,love-$LOVE_VERSION-win32/love.exe,$ICON,ICONGROUP,MAINICON,0" 2>&1 /dev/null + fi + + # version number is incorrect inside zip file; oops + LOVE_VERSION="$LOVE_VERSION".0 + cat love-$LOVE_VERSION-win32/love.exe "$LOVE_FILE" > "love-$LOVE_VERSION-win32/${TITLE}.exe" + rm love-$LOVE_VERSION-win32/love.exe + mv love-$LOVE_VERSION-win32 "$TITLE"-win32 + if [[ $INSTALLER == true ]]; then + rm -rf "$IDENTITY-setup-win32.exe" 2> /dev/null + create_installer "$TITLE-win32" + mv "$IDENTITY-setup.exe" "$IDENTITY-setup-win32.exe" + else + zip -FS -9 -qr "$TITLE-win32.zip" "$TITLE-win32" + fi + rm -rf "$TITLE-win32" +fi + +## Windows 64-bits ## +if [[ $X64 == true ]] && compare_version "$LOVE_VERSION" '>=' '0.8.0'; then + + if [[ ! -f "$CACHE_DIR/love-$LOVE_VERSION-win64.zip" ]]; then + if compare_version "$LOVE_VERSION" '>=' '0.9.0'; then + curl -L -C - -o "$CACHE_DIR/love-$LOVE_VERSION-win64.zip" "https://bitbucket.org/rude/love/downloads/love-$LOVE_VERSION-win64.zip" + else + curl -L -C - -o "$CACHE_DIR/love-$LOVE_VERSION-win-x64.zip" "https://bitbucket.org/rude/love/downloads/love-$LOVE_VERSION-win-x64.zip" + fi + fi + + unzip -qq "$CACHE_DIR/love-$LOVE_VERSION-win64.zip" + + if [[ -n $ICON ]]; then + WINEPREFIX="$WINEPREFIX" wine "$RESHACKER" \ + -addoverwrite "love-$LOVE_VERSION-win64/love.exe,love-$LOVE_VERSION-win64/love.exe,$ICON,ICONGROUP,MAINICON,0" 2>&1 /dev/null + fi + + cat love-$LOVE_VERSION-win64/love.exe "$LOVE_FILE" > "love-$LOVE_VERSION-win64/${TITLE}.exe" + rm love-$LOVE_VERSION-win64/love.exe + mv love-$LOVE_VERSION-win64 "$TITLE-win64" + if [[ $INSTALLER == true ]]; then + rm -rf "$IDENTITY-setup-win64.exe" 2> /dev/null + create_installer "$TITLE-win64" "true" + mv "$IDENTITY-setup.exe" "$IDENTITY-setup-win64.exe" + else + zip -FS -9 -qr "$TITLE-win64.zip" "$TITLE-win64" + fi + rm -rf "$TITLE-win64" +fi + + +exit_module +EndOfModule +)) +default_module + + +elif [[ $INSTALLED == true ]]; then + SCRIPTS_DIR="scripts" + for file in "$SCRIPTS_DIR"/*.sh; do + (source "$file") + default_module + done +fi + + +( + init_module "LÖVE" "love" "L" + create_love_file 9 + exit_module +) +if [[ $? -ne 0 && $DEFAULT_MODULE == true ]]; then +( + init_module "LÖVE" "default" + create_love_file 9 + exit_module +) +fi + +exit 0 + diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..79cc40c --- /dev/null +++ b/main.lua @@ -0,0 +1,7 @@ +-- bootstrap the compiler +fennel = require("lib.fennel") +table.insert(package.loaders, fennel.make_searcher({correlate=true})) +pp = function(x) print(require("lib.fennelview")(x)) end +lume = require("lib.lume") + +require("wrap") diff --git a/makefile b/makefile new file mode 100644 index 0000000..f2395c2 --- /dev/null +++ b/makefile @@ -0,0 +1,44 @@ +VERSION=0.1.0 +NAME=min-love2d-fennel +URL=https://gitlab.com/alexjgriffith/min-love2d-fennel +AUTHOR="Alexander Griffith" +DESCRIPTION="Minimal setup for trying out Phil Hagelberg's fennel/love game design process." + +LIBS := $(wildcard lib/*) + +$(LOVEFILE): $(LUA) $(OUT) $(LIBS) assets text + mkdir -p releases/ + find $^ -type f | LC_ALL=C sort | env TZ=UTC zip -r -q -9 -X $@ -@ + +love: $(LOVEFILE) + +# platform-specific distributables + +REL="$(PWD)/love-release.sh" # https://p.hagelb.org/love-release.sh +FLAGS=-a "$(AUTHOR)" --description $(DESCRIPTION) \ + --love 11.1 --url $(URL) --version $(VERSION) --lovefile $(LOVEFILE) + +releases/$(NAME)-$(VERSION)-x86_64.AppImage: $(LOVEFILE) + cd appimage && ./build.sh 11.1 $(PWD)/$(LOVEFILE) + mv appimage/game-x86_64.AppImage $@ + +releases/$(NAME)-$(VERSION)-macos.zip: $(LOVEFILE) + $(REL) $(FLAGS) -M + mv releases/EXO_encounter-667-macos.zip $@ + +releases/$(NAME)-$(VERSION)-win.zip: $(LOVEFILE) + $(REL) $(FLAGS) -W32 + mv releases/EXO_encounter-667-win32.zip $@ + +linux: releases/$(NAME)-$(VERSION)-x86_64.AppImage +mac: releases/$(NAME)-$(VERSION)-macos.zip +windows: releases/$(NAME)-$(VERSION)-win.zip + +uploadlinux: releases/$(NAME)-$(VERSION)-x86_64.AppImage + butler push $^ technomancy/exo-encounter-667:linux +uploadmac: releases/$(NAME)-$(VERSION)-macos.zip + butler push $^ technomancy/exo-encounter-667:mac +uploadwindows: releases/$(NAME)-$(VERSION)-win.zip + butler push $^ technomancy/exo-encounter-667:windows + +upload: uploadlinux uploadmac uploadwindows diff --git a/mode-intro.fnl b/mode-intro.fnl new file mode 100644 index 0000000..379058b --- /dev/null +++ b/mode-intro.fnl @@ -0,0 +1,14 @@ +(var counter 0) +(var time 0) + +{:draw (fn draw [message] + (love.graphics.print "This window should close in 3s") 32 16) + :update (fn update [dt set-mode] + (if (< counter 65535) + (set counter (+ counter 1)) + (set counter 0)) + (set time (+ time dt)) + (when (> time 3) + (love.event.quit))) + :keypressed (fn keypressed [key set-mode] + (love.event.quit))} diff --git a/readme.org b/readme.org new file mode 100644 index 0000000..1391fe5 --- /dev/null +++ b/readme.org @@ -0,0 +1,38 @@ +* Minimal Fennel Love2D Setup + +In a lead up to the annual [[https://itch.io/jam/autumn-lisp-game-jam-2018][Autumn Lisp Game Jam]] I thought I'd look into Phil Hegelberg's approach to last Aprils Jam, using [[https://love2d.org/][love2d]] in concert with [[https://fennel-lang.org/][fennel]]. Phil outlines his approach on his [[https://love2d.org/][blog]]. + +The first step is to see how fennel works as a standalone execution environment, then slowly integrate some of the love2d API. + +This repo contains the minimal viable setup to get started with Phil Hegelberg's game design process, plus some additional libraries. + +* Phil's Modal Callbacks (PMC) +You can imagine that your game is a series of states in a very simple state machine. For example, most games will progress from *start-screen* to *play* to *end*, with unidirectional progression. In each state we will need certain actions that are state specific (and specific to the callback system that love uses). In order to have state dependant rendering we will need a *draw callback* for each state. Similarly if we need state dependent logic and keyboard input we will need *update and keyboard callbacks*. Based on the complexity of your game you can include more states and more callbacks. This architecture provides a very modular way to set out your code. So long as state progression is simple, you should be able to add states and remove easily as you iterate on your game. + + +* Emacs Setup + +#+BEGIN_SRC emacs-lisp +(autoload 'fennel-mode "/path/to/fennel-mode" nil t) +(require 'fennel-mode) + +;; By default fennel-mode moves focus to the \*inferior-lisp\* buffer +;; setting `fennel-mode-switch-to-repl-after-reload' to nil prevents this +(customize-save-variable 'fennel-mode-switch-to-repl-after-reload nil) + +(add-to-list 'auto-mode-alist '("\\.fnl\\'" . fennel-mode)) + +;; you can bind this to whatever you like +(defun run-love () + (interactive) + (run-lisp "love .")) + +;; for some reason when I load a fnl file it starts the slime minor mode +;; I have included the hook below to prevent this +(defun fennel-mode-hook-fun () + (interactive) + (slime-mode nil)) + +(add-hook 'fennel-mode-hook 'fennel-mode-hook-fun) +#+END_SRC + diff --git a/wrap.fnl b/wrap.fnl new file mode 100644 index 0000000..721ca3b --- /dev/null +++ b/wrap.fnl @@ -0,0 +1,38 @@ +(local repl (require "lib.stdio")) +(local canvas (love.graphics.newCanvas 720 450)) + +(var scale 1) + +;; set the first mode +(var mode (require "mode-intro")) + +(fn set-mode [mode-name ...] + (set mode (require mode-name)) + (when mode.activate + (mode.activate ...))) + +(fn love.load [] + (: canvas :setFilter "nearest" "nearest") + (repl.start)) + +(fn love.draw [] + (love.graphics.setCanvas canvas) + (love.graphics.clear) + (love.graphics.setColor 1 1 1) + (mode.draw) + (love.graphics.setCanvas) + (love.graphics.setColor 1 1 1) + (love.graphics.draw canvas 0 0 0 scale scale)) + +(fn love.update[dt] + (mode.update dt set-mode) + ) + +(fn love.keypressed [key] + (if + (and (love.keyboard.isDown "lctrl" "rctrl" "capslock") (= key "q")) + (love.event.quit) + ;; add what each keypress should do in each mode + (mode.keypressed key set-mode) + )) +