From 61b2683818d1466929ca3800b83d04ea36f4438e Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Mon, 17 Jul 2023 16:30:30 +0300 Subject: [PATCH] initial commit --- .gitignore | 1 + .luarc.json | 11 + .vimrc.lua | 6 + README.md | 1 + build.sh | 17 + src/conf.lua | 11 + src/helpers/darken.lua | 8 + src/helpers/hue-shift.lua | 8 + src/helpers/is-in-rect.lua | 4 + src/helpers/lighten.lua | 8 + src/helpers/pick-text-color.lua | 17 + src/lib/brinevector.lua | 279 ++++++++++++ src/lib/bump.lua | 773 ++++++++++++++++++++++++++++++++ src/lib/hump/camera.lua | 257 +++++++++++ src/lib/hump/class.lua | 98 ++++ src/lib/hump/gamestate.lua | 113 +++++ src/lib/hump/signal.lua | 102 +++++ src/lib/hump/timer.lua | 215 +++++++++ src/lib/log.lua | 89 ++++ src/lib/lume.lua | 688 ++++++++++++++++++++++++++++ src/lib/nata.lua | 546 ++++++++++++++++++++++ src/lib/pprint.lua | 475 ++++++++++++++++++++ src/lib/ripple.lua | 522 +++++++++++++++++++++ src/lib/vivid.lua | 503 +++++++++++++++++++++ src/main.lua | 9 + src/states/main.lua | 7 + 26 files changed, 4768 insertions(+) create mode 100644 .gitignore create mode 100644 .luarc.json create mode 100644 .vimrc.lua create mode 100644 README.md create mode 100755 build.sh create mode 100644 src/conf.lua create mode 100644 src/helpers/darken.lua create mode 100644 src/helpers/hue-shift.lua create mode 100644 src/helpers/is-in-rect.lua create mode 100644 src/helpers/lighten.lua create mode 100644 src/helpers/pick-text-color.lua create mode 100644 src/lib/brinevector.lua create mode 100644 src/lib/bump.lua create mode 100644 src/lib/hump/camera.lua create mode 100644 src/lib/hump/class.lua create mode 100644 src/lib/hump/gamestate.lua create mode 100644 src/lib/hump/signal.lua create mode 100644 src/lib/hump/timer.lua create mode 100644 src/lib/log.lua create mode 100644 src/lib/lume.lua create mode 100644 src/lib/nata.lua create mode 100644 src/lib/pprint.lua create mode 100644 src/lib/ripple.lua create mode 100644 src/lib/vivid.lua create mode 100644 src/main.lua create mode 100644 src/states/main.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..f0b7609 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", + "Lua.runtime.version": "LuaJIT", + "Lua.workspace.checkThirdParty": false, + "Lua.workspace.library": [ + "${3rd}/love2d/library" + ], + "Lua.workspace.userThirdParty": [ + "love2d" + ] +} \ No newline at end of file diff --git a/.vimrc.lua b/.vimrc.lua new file mode 100644 index 0000000..b0d8af8 --- /dev/null +++ b/.vimrc.lua @@ -0,0 +1,6 @@ +local map = vim.api.nvim_set_keymap + +map('n', 'l', ":execute 'silent !kitty love src &' | redraw!", { + silent = true, + noremap = true +}) diff --git a/README.md b/README.md new file mode 100644 index 0000000..debee67 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Love2D project diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d37dcca --- /dev/null +++ b/build.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +BUILD_NAME="love2d-project" +ZIP_OPTIONS="-x '*.xcf'" + +# Remove existing contents +rm -r build +mkdir build + +# Build project +echo Building project... +cd src +zip -q $ZIP_OPTIONS -r ../build/$BUILD_NAME.love * +cd - > /dev/null + +# Jobs done +echo Done. diff --git a/src/conf.lua b/src/conf.lua new file mode 100644 index 0000000..0100748 --- /dev/null +++ b/src/conf.lua @@ -0,0 +1,11 @@ +function love.conf(t) + t.title = "Love2D project" + + t.console = true + + t.window.width = 1280 + t.window.height = 720 + t.window.resizable = true + + t.modules.joystick = false +end diff --git a/src/helpers/darken.lua b/src/helpers/darken.lua new file mode 100644 index 0000000..f7aebe3 --- /dev/null +++ b/src/helpers/darken.lua @@ -0,0 +1,8 @@ +local vivid = require("lib.vivid") +local hueShift = require("helpers.hue-shift") +local BLUE_HUE = 240/360 + +return function(r, g, b) + return hueShift(BLUE_HUE, 0.08, vivid.desaturate(0.1, vivid.darken(0.1, r, g, b))) +end + diff --git a/src/helpers/hue-shift.lua b/src/helpers/hue-shift.lua new file mode 100644 index 0000000..43a56c3 --- /dev/null +++ b/src/helpers/hue-shift.lua @@ -0,0 +1,8 @@ +local lerp = require("lib.lume").lerp +local vivid = require("lib.vivid") + +return function (target_hue, amount, r, g, b) + local h, s, l = vivid.RGBtoHSL(r, g, b) + h = lerp(h, target_hue, amount) + return vivid.HSLtoRGB(h, s, l) +end diff --git a/src/helpers/is-in-rect.lua b/src/helpers/is-in-rect.lua new file mode 100644 index 0000000..08b82ec --- /dev/null +++ b/src/helpers/is-in-rect.lua @@ -0,0 +1,4 @@ +return function(x0, y0, x, y, w, h) + return x0 >= x and x0 < x+w and y0 >= y and y0 < y+h +end + diff --git a/src/helpers/lighten.lua b/src/helpers/lighten.lua new file mode 100644 index 0000000..e475157 --- /dev/null +++ b/src/helpers/lighten.lua @@ -0,0 +1,8 @@ +local vivid = require("lib.vivid") +local hueShift = require("helpers.hue-shift") +local YELLOW_HUE = 60/360 + +return function(r, g, b) + return hueShift(YELLOW_HUE, 0.05, vivid.saturate(0.1, vivid.lighten(0.1, r, g, b))) +end + diff --git a/src/helpers/pick-text-color.lua b/src/helpers/pick-text-color.lua new file mode 100644 index 0000000..0fa9c95 --- /dev/null +++ b/src/helpers/pick-text-color.lua @@ -0,0 +1,17 @@ +-- Pick a color for text that would be approriate depending on the given +-- background color. +-- If background color is bright, returns black. +-- IF background color is dark, returns white. +return function(p1, p2, p3) + local r, g, b + if p1 and p2 and p3 then + r, g, b = p1, p2, p3 + else + r, g, b = p1[1], p1[2], p1[3] + end + if r+g+b < 1.5 then + return 1, 1, 1, 1 + else + return 0, 0, 0, 1 + end +end diff --git a/src/lib/brinevector.lua b/src/lib/brinevector.lua new file mode 100644 index 0000000..d4f2fcc --- /dev/null +++ b/src/lib/brinevector.lua @@ -0,0 +1,279 @@ +-- LAST UPDATED: 9/25/18 +-- added Vector.getCeil and v.ceil +-- added Vector.clamp +-- changed float to double as per Mike Pall's advice +-- added Vector.floor +-- added fallback to table if luajit ffi is not detected (used for unit tests) + +--[[ +BrineVector: a luajit ffi-accelerated vector library + +Copyright 2018 Brian Sarfati + +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. +--]] + +local ffi +local VECTORTYPE = "cdata" + +if jit and jit.status() then + ffi = require "ffi" + ffi.cdef[[ + typedef struct { + double x; + double y; + } brinevector; + ]] +else + VECTORTYPE = "table" +end + +local Vector = {} +setmetatable(Vector,Vector) + +local special_properties = { + length = "getLength", + normalized = "getNormalized", + angle = "getAngle", + length2 = "getLengthSquared", + copy = "getCopy", + inverse = "getInverse", + floor = "getFloor", + ceil = "getCeil", +} + +function Vector.__index(t, k) + if special_properties[k] then + return Vector[special_properties[k]](t) + end + return rawget(Vector,k) +end + +function Vector.getLength(v) + return math.sqrt(v.x * v.x + v.y * v.y) +end + +function Vector.getLengthSquared(v) + return v.x*v.x + v.y*v.y +end + +function Vector.getNormalized(v) + local length = v.length + if length == 0 then return Vector(0,0) end + return Vector(v.x / length, v.y / length) +end + +function Vector.getAngle(v) + return math.atan2(v.y, v.x) +end + +function Vector.getCopy(v) + return Vector(v.x,v.y) +end + +function Vector.getInverse(v) + return Vector(1 / v.x, 1 / v.y) +end + +function Vector.getFloor(v) + return Vector(math.floor(v.x), math.floor(v.y)) +end + +function Vector.getCeil(v) + return Vector(math.ceil(v.x), math.ceil(v.y)) +end + +function Vector.__newindex(t,k,v) + if k == "length" then + local res = t.normalized * v + t.x = res.x + t.y = res.y + return + end + if k == "angle" then + local res = t:angled(v) + t.x = res.x + t.y = res.y + return + end + if t == Vector then + rawset(t,k,v) + else + error("Cannot assign a new property '" .. k .. "' to a Vector", 2) + end +end + +function Vector.angled(v, angle) + local length = v.length + return Vector(math.cos(angle) * length, math.sin(angle) * length) +end + +function Vector.trim(v,mag) + if v.length < mag then return v end + return v.normalized * mag +end + +function Vector.split(v) + return v.x, v.y +end + +local function clamp(x, min, max) + -- because Mike Pall says math.min and math.max are JIT-optimized + return math.min(math.max(min, x), max) +end + +function Vector.clamp(v, topleft, bottomright) + -- clamps a vector to a certain bounding box about the origin + return Vector( + clamp(v.x, topleft.x, bottomright.x), + clamp(v.y, topleft.y, bottomright.y) + ) +end + +function Vector.hadamard(v1, v2) -- also known as "Componentwise multiplication" + return Vector(v1.x * v2.x, v1.y * v2.y) +end + +function Vector.rotated(v, angle) + local cos = math.cos(angle) + local sin = math.sin(angle) + return Vector(v.x * cos - v.y * sin, v.x * sin + v.y * cos) +end + +local iteraxes_lookup = { + xy = {"x","y"}, + yx = {"y","x"} +} +local function iteraxes(ordertable, i) + i = i + 1 + if i > 2 then return nil end + return i, ordertable[i] +end + +function Vector.axes(order) + return iteraxes, iteraxes_lookup[order or "yx"], 0 +end + +local function iterpairs(vector, k) + if k == nil then + k = "x" + elseif k == "x" then + k = "y" + else + k = nil + end + return k, vector[k] +end + +function Vector.__pairs(v) + return iterpairs, v, nil +end + +if ffi then + function Vector.isVector(arg) + return ffi.istype("brinevector",arg) + end +else + function Vector.isVector(arg) + return type(arg) == VECTORTYPE and arg.x and arg.y + end +end + +function Vector.__add(v1, v2) + return Vector(v1.x + v2.x, v1.y + v2.y) +end + +function Vector.__sub(v1, v2) + return Vector(v1.x - v2.x, v1.y - v2.y) +end + +function Vector.__mul(v1, op) + -- acts as a dot multiplication if op is a vector + -- if op is a scalar then works as usual + if type(v1) == "number" then + return Vector(op.x * v1, op.y * v1) + end + if type(op) == VECTORTYPE then + return v1.x * op.x + v1.y * op.y + else + return Vector(v1.x * op, v1.y * op) + end +end + +function Vector.__div(v1, op) + if type(op) == "number" then + if op == 0 then error("Vector NaN occured", 2) end + return Vector(v1.x / op, v1.y / op) + elseif type(op) == VECTORTYPE then + if op.x * op.y == 0 then error("Vector NaN occured", 2) end + return Vector(v1.x / op.x, v1.y / op.y) + end +end + +function Vector.__unm(v) + return Vector(-v.x, -v.y) +end + +function Vector.__eq(v1,v2) + if (not Vector.isVector(v1)) or (not Vector.isVector(v2)) then return false end + return v1.x == v2.x and v1.y == v2.y +end + +function Vector.__mod(v1,v2) -- ran out of symbols, so i chose % for the hadamard product + return Vector(v1.x * v2.x, v1.y * v2.y) +end + +function Vector.__tostring(t) + return string.format("Vector{%.4f, %.4f}",t.x,t.y) +end + +function Vector.__concat(str, v) + return tostring(str) .. tostring(v) +end + +function Vector.__len(v) + return math.sqrt(v.x * v.x + v.y * v.y) +end + +if ffi then + function Vector.__call(t,x,y) + return ffi.new("brinevector",x or 0,y or 0) + end +else + function Vector.__call(t,x,y) + return setmetatable({x = x or 0, y = y or 0}, Vector) + end +end + +local dirs = { + up = Vector(0,-1), + down = Vector(0,1), + left = Vector(-1,0), + right = Vector(1,0), + top = Vector(0,-1), + bottom = Vector(0,1) +} + +function Vector.dir(dir) + return dirs[dir] and dirs[dir].copy or Vector() +end + + +if ffi then + ffi.metatype("brinevector",Vector) +end + +return Vector diff --git a/src/lib/bump.lua b/src/lib/bump.lua new file mode 100644 index 0000000..6dabca7 --- /dev/null +++ b/src/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/src/lib/hump/camera.lua b/src/lib/hump/camera.lua new file mode 100644 index 0000000..f91f28d --- /dev/null +++ b/src/lib/hump/camera.lua @@ -0,0 +1,257 @@ +--[[ +Copyright (c) 2010-2015 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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. +]]-- + +local _PATH = (...):match('^(.*[%./])[^%.%/]+$') or '' +local cos, sin = math.cos, math.sin + +local camera = {} +camera.__index = camera + +-- Movement interpolators (for camera locking/windowing) +camera.smooth = {} + +function camera.smooth.none() + return function(dx,dy) return dx,dy end +end + +function camera.smooth.linear(speed) + assert(type(speed) == "number", "Invalid parameter: speed = "..tostring(speed)) + return function(dx,dy, s) + -- normalize direction + local d = math.sqrt(dx*dx+dy*dy) + local dts = math.min((s or speed) * love.timer.getDelta(), d) -- prevent overshooting the goal + if d > 0 then + dx,dy = dx/d, dy/d + end + + return dx*dts, dy*dts + end +end + +function camera.smooth.damped(stiffness) + assert(type(stiffness) == "number", "Invalid parameter: stiffness = "..tostring(stiffness)) + return function(dx,dy, s) + local dts = love.timer.getDelta() * (s or stiffness) + return dx*dts, dy*dts + end +end + + +local function new(x,y, zoom, rot, smoother) + x,y = x or love.graphics.getWidth()/2, y or love.graphics.getHeight()/2 + zoom = zoom or 1 + rot = rot or 0 + smoother = smoother or camera.smooth.none() -- for locking, see below + return setmetatable({x = x, y = y, scale = zoom, rot = rot, smoother = smoother}, camera) +end + +function camera:lookAt(x,y) + self.x, self.y = x, y + return self +end + +function camera:move(dx,dy) + self.x, self.y = self.x + dx, self.y + dy + return self +end + +function camera:position() + return self.x, self.y +end + +function camera:rotate(phi) + self.rot = self.rot + phi + return self +end + +function camera:rotateTo(phi) + self.rot = phi + return self +end + +function camera:zoom(mul) + self.scale = self.scale * mul + return self +end + +function camera:zoomTo(zoom) + self.scale = zoom + return self +end + +local function rotate(phi, x,y) + local c, s = cos(phi), sin(phi) + return c*x - s*y, s*x + c*y +end + +-- (dx, dy) = (((x,y) - center) * (1 / cam.scale - 1 / cam.new_scale)):rotated(-cam.rot) +function camera:zoomInto(zoom, tx, ty, x, y, w, h) + local oldScale = self.scale + self:zoom(zoom) + x, y = x or 0, y or 0 + w, h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + local cx, cy = x+w/2, y+h/2 + local scalar = (1/oldScale - 1/self.scale) + local dx = (tx - cx) * scalar + local dy = (ty - cy) * scalar + + self:move(rotate(-self.rot, dx, dy)) +end + +-- (dx, dy) = (((new_x, new_y) - (x, y)) / cam.scale):rotated(-cam.rot) +function camera:relativeMove(x, y) + local dx = x / self.scale + local dy = y / self.scale + + self:move(rotate(-self.rot, dx, dy)) +end + +function camera:getViewport(x, y, w, h) + x,y = x or 0, y or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + local corner1_x, corner1_y = self:worldCoords(0, 0, x, y, w, h) + local corner2_x, corner2_y = self:worldCoords(w, 0, x, y, w, h) + local corner3_x, corner3_y = self:worldCoords(0, h, x, y, w, h) + local corner4_x, corner4_y = self:worldCoords(w, h, x, y, w, h) + + local left = math.min(corner1_x, corner2_x, corner3_x, corner4_x) + local top = math.min(corner1_y, corner2_y, corner3_y, corner4_y) + local right = math.max(corner1_x, corner2_x, corner3_x, corner4_x) + local bottom = math.max(corner1_y, corner2_y, corner3_y, corner4_y) + + return left, top, right, bottom +end + +function camera:attach(x,y,w,h, noclip) + x,y = x or 0, y or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + self._sx,self._sy,self._sw,self._sh = love.graphics.getScissor() + if not noclip then + love.graphics.setScissor(x,y,w,h) + end + + local cx,cy = x+w/2, y+h/2 + love.graphics.push() + love.graphics.translate(cx, cy) + love.graphics.scale(self.scale) + love.graphics.rotate(self.rot) + love.graphics.translate(-self.x, -self.y) +end + +function camera:detach() + love.graphics.pop() + love.graphics.setScissor(self._sx,self._sy,self._sw,self._sh) +end + +function camera:draw(...) + local x,y,w,h,noclip,func + local nargs = select("#", ...) + if nargs == 1 then + func = ... + elseif nargs == 5 then + x,y,w,h,func = ... + elseif nargs == 6 then + x,y,w,h,noclip,func = ... + else + error("Invalid arguments to camera:draw()") + end + + self:attach(x,y,w,h,noclip) + func() + self:detach() +end + +-- world coordinates to camera coordinates +function camera:cameraCoords(x,y, ox,oy,w,h) + ox, oy = ox or 0, oy or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + -- x,y = ((x,y) - (self.x, self.y)):rotated(self.rot) * self.scale + center + x, y = rotate(self.rot, x - self.x, y - self.y) + return x*self.scale + w/2 + ox, y*self.scale + h/2 + oy +end + +-- camera coordinates to world coordinates +function camera:worldCoords(x,y, ox,oy,w,h) + ox, oy = ox or 0, oy or 0 + w,h = w or love.graphics.getWidth(), h or love.graphics.getHeight() + + -- x,y = (((x,y) - center) / self.scale):rotated(-self.rot) + (self.x,self.y) + x,y = (x - w/2 - ox) / self.scale, (y - h/2 - oy) / self.scale + x,y = rotate(-self.rot, x, y) + return x+self.x, y+self.y +end + +function camera:mousePosition(ox,oy,w,h) + local mx,my = love.mouse.getPosition() + return self:worldCoords(mx,my, ox,oy,w,h) +end + +-- camera scrolling utilities +function camera:lockX(x, smoother, ...) + local dx, dy = (smoother or self.smoother)(x - self.x, self.y, ...) + self.x = self.x + dx + return self +end + +function camera:lockY(y, smoother, ...) + local dx, dy = (smoother or self.smoother)(self.x, y - self.y, ...) + self.y = self.y + dy + return self +end + +function camera:lockPosition(x,y, smoother, ...) + return self:move((smoother or self.smoother)(x - self.x, y - self.y, ...)) +end + +function camera:lockWindow(x, y, x_min, x_max, y_min, y_max, smoother, ...) + -- figure out displacement in camera coordinates + x,y = self:cameraCoords(x,y) + local dx, dy = 0,0 + if x < x_min then + dx = x - x_min + elseif x > x_max then + dx = x - x_max + end + if y < y_min then + dy = y - y_min + elseif y > y_max then + dy = y - y_max + end + + -- transform displacement to movement in world coordinates + local c,s = cos(-self.rot), sin(-self.rot) + dx,dy = (c*dx - s*dy) / self.scale, (s*dx + c*dy) / self.scale + + -- move + self:move((smoother or self.smoother)(dx,dy,...)) +end + +-- the module +return setmetatable({new = new, smooth = camera.smooth}, + {__call = function(_, ...) return new(...) end}) diff --git a/src/lib/hump/class.lua b/src/lib/hump/class.lua new file mode 100644 index 0000000..7d62707 --- /dev/null +++ b/src/lib/hump/class.lua @@ -0,0 +1,98 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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. +]]-- + +local function include_helper(to, from, seen) + if from == nil then + return to + elseif type(from) ~= 'table' then + return from + elseif seen[from] then + return seen[from] + end + + seen[from] = to + for k,v in pairs(from) do + k = include_helper({}, k, seen) -- keys might also be tables + if to[k] == nil then + to[k] = include_helper({}, v, seen) + end + end + return to +end + +-- deeply copies `other' into `class'. keys in `other' that are already +-- defined in `class' are omitted +local function include(class, other) + return include_helper(class, other, {}) +end + +-- returns a deep copy of `other' +local function clone(other) + return setmetatable(include({}, other), getmetatable(other)) +end + +local function new(class) + -- mixins + class = class or {} -- class can be nil + local inc = class.__includes or {} + if getmetatable(inc) then inc = {inc} end + + for _, other in ipairs(inc) do + if type(other) == "string" then + other = _G[other] + end + include(class, other) + end + + -- class implementation + class.__index = class + class.init = class.init or class[1] or function() end + class.include = class.include or include + class.clone = class.clone or clone + + -- constructor call + return setmetatable(class, {__call = function(c, ...) + local o = setmetatable({}, c) + o:init(...) + return o + end}) +end + +-- interface for cross class-system compatibility (see https://github.com/bartbes/Class-Commons). +if class_commons ~= false and not common then + common = {} + function common.class(name, prototype, parent) + return new{__includes = {prototype, parent}} + end + function common.instance(class, ...) + return class(...) + end +end + + +-- the module +return setmetatable({new = new, include = include, clone = clone}, + {__call = function(_,...) return new(...) end}) diff --git a/src/lib/hump/gamestate.lua b/src/lib/hump/gamestate.lua new file mode 100644 index 0000000..e3e78c3 --- /dev/null +++ b/src/lib/hump/gamestate.lua @@ -0,0 +1,113 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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. +]]-- + +local function __NULL__() end + + -- default gamestate produces error on every callback +local state_init = setmetatable({leave = __NULL__}, + {__index = function() error("Gamestate not initialized. Use Gamestate.switch()") end}) +local stack = {state_init} +local initialized_states = setmetatable({}, {__mode = "k"}) +local state_is_dirty = true + +local GS = {} +function GS.new(t) return t or {} end -- constructor - deprecated! + +local function change_state(stack_offset, to, ...) + local pre = stack[#stack] + + -- initialize only on first call + ;(initialized_states[to] or to.init or __NULL__)(to) + initialized_states[to] = __NULL__ + + stack[#stack+stack_offset] = to + state_is_dirty = true + return (to.enter or __NULL__)(to, pre, ...) +end + +function GS.switch(to, ...) + assert(to, "Missing argument: Gamestate to switch to") + assert(to ~= GS, "Can't call switch with colon operator") + ;(stack[#stack].leave or __NULL__)(stack[#stack]) + return change_state(0, to, ...) +end + +function GS.push(to, ...) + assert(to, "Missing argument: Gamestate to switch to") + assert(to ~= GS, "Can't call push with colon operator") + return change_state(1, to, ...) +end + +function GS.pop(...) + assert(#stack > 1, "No more states to pop!") + local pre, to = stack[#stack], stack[#stack-1] + stack[#stack] = nil + ;(pre.leave or __NULL__)(pre) + state_is_dirty = true + return (to.resume or __NULL__)(to, pre, ...) +end + +function GS.current() + return stack[#stack] +end + +-- XXX: don't overwrite love.errorhandler by default: +-- this callback is different than the other callbacks +-- (see http://love2d.org/wiki/love.errorhandler) +-- overwriting thi callback can result in random crashes (issue #95) +local all_callbacks = { 'draw', 'update' } + +-- fetch event callbacks from love.handlers +for k in pairs(love.handlers) do + all_callbacks[#all_callbacks+1] = k +end + +function GS.registerEvents(callbacks) + local registry = {} + callbacks = callbacks or all_callbacks + for _, f in ipairs(callbacks) do + registry[f] = love[f] or __NULL__ + love[f] = function(...) + registry[f](...) + return GS[f](...) + end + end +end + +-- forward any undefined functions +setmetatable(GS, {__index = function(_, func) + -- call function only if at least one 'update' was called beforehand + -- (see issue #46) + if not state_is_dirty or func == 'update' then + state_is_dirty = false + return function(...) + return (stack[#stack][func] or __NULL__)(stack[#stack], ...) + end + end + return __NULL__ +end}) + +return GS diff --git a/src/lib/hump/signal.lua b/src/lib/hump/signal.lua new file mode 100644 index 0000000..e204ca0 --- /dev/null +++ b/src/lib/hump/signal.lua @@ -0,0 +1,102 @@ +--[[ +Copyright (c) 2012-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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. +]]-- + +local Registry = {} +Registry.__index = function(self, key) + return Registry[key] or (function() + local t = {} + rawset(self, key, t) + return t + end)() +end + +function Registry:register(s, f) + self[s][f] = f + return f +end + +function Registry:emit(s, ...) + for f in pairs(self[s]) do + f(...) + end +end + +function Registry:remove(s, ...) + local f = {...} + for i = 1,select('#', ...) do + self[s][f[i]] = nil + end +end + +function Registry:clear(...) + local s = {...} + for i = 1,select('#', ...) do + self[s[i]] = {} + end +end + +function Registry:emitPattern(p, ...) + for s in pairs(self) do + if s:match(p) then self:emit(s, ...) end + end +end + +function Registry:registerPattern(p, f) + for s in pairs(self) do + if s:match(p) then self:register(s, f) end + end + return f +end + +function Registry:removePattern(p, ...) + for s in pairs(self) do + if s:match(p) then self:remove(s, ...) end + end +end + +function Registry:clearPattern(p) + for s in pairs(self) do + if s:match(p) then self[s] = {} end + end +end + +-- instancing +function Registry.new() + return setmetatable({}, Registry) +end + +-- default instance +local default = Registry.new() + +-- module forwards calls to default instance +local module = {} +for k in pairs(Registry) do + if k ~= "__index" then + module[k] = function(...) return default[k](default, ...) end + end +end + +return setmetatable(module, {__call = Registry.new}) diff --git a/src/lib/hump/timer.lua b/src/lib/hump/timer.lua new file mode 100644 index 0000000..8315f68 --- /dev/null +++ b/src/lib/hump/timer.lua @@ -0,0 +1,215 @@ +--[[ +Copyright (c) 2010-2013 Matthias Richter + +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. + +Except as contained in this notice, the name(s) of the above copyright holders +shall not be used in advertising or otherwise to promote the sale, use or +other dealings in this Software without prior written authorization. + +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. +]]-- + +local Timer = {} +Timer.__index = Timer + +local function _nothing_() end + +local function updateTimerHandle(handle, dt) + -- handle: { + -- time = , + -- after = , + -- during = , + -- limit = , + -- count = , + -- } + handle.time = handle.time + dt + handle.during(dt, math.max(handle.limit - handle.time, 0)) + + while handle.time >= handle.limit and handle.count > 0 do + if handle.after(handle.after) == false then + handle.count = 0 + break + end + handle.time = handle.time - handle.limit + handle.count = handle.count - 1 + end +end + +function Timer:update(dt) + -- timers may create new timers, which leads to undefined behavior + -- in pairs() - so we need to put them in a different table first + local to_update = {} + for handle in pairs(self.functions) do + to_update[handle] = handle + end + + for handle in pairs(to_update) do + if self.functions[handle] then + updateTimerHandle(handle, dt) + if handle.count == 0 then + self.functions[handle] = nil + end + end + end +end + +function Timer:during(delay, during, after) + local handle = { time = 0, during = during, after = after or _nothing_, limit = delay, count = 1 } + self.functions[handle] = true + return handle +end + +function Timer:after(delay, func) + return self:during(delay, _nothing_, func) +end + +function Timer:every(delay, after, count) + local count = count or math.huge -- exploit below: math.huge - 1 = math.huge + local handle = { time = 0, during = _nothing_, after = after, limit = delay, count = count } + self.functions[handle] = true + return handle +end + +function Timer:cancel(handle) + self.functions[handle] = nil +end + +function Timer:clear() + self.functions = {} +end + +function Timer:script(f) + local co = coroutine.wrap(f) + co(function(t) + self:after(t, co) + coroutine.yield() + end) +end + +Timer.tween = setmetatable({ + -- helper functions + out = function(f) -- 'rotates' a function + return function(s, ...) return 1 - f(1-s, ...) end + end, + chain = function(f1, f2) -- concatenates two functions + return function(s, ...) return (s < .5 and f1(2*s, ...) or 1 + f2(2*s-1, ...)) * .5 end + end, + + -- useful tweening functions + linear = function(s) return s end, + quad = function(s) return s*s end, + cubic = function(s) return s*s*s end, + quart = function(s) return s*s*s*s end, + quint = function(s) return s*s*s*s*s end, + sine = function(s) return 1-math.cos(s*math.pi/2) end, + expo = function(s) return 2^(10*(s-1)) end, + circ = function(s) return 1 - math.sqrt(1-s*s) end, + + back = function(s,bounciness) + bounciness = bounciness or 1.70158 + return s*s*((bounciness+1)*s - bounciness) + end, + + bounce = function(s) -- magic numbers ahead + local a,b = 7.5625, 1/2.75 + return math.min(a*s^2, a*(s-1.5*b)^2 + .75, a*(s-2.25*b)^2 + .9375, a*(s-2.625*b)^2 + .984375) + end, + + elastic = function(s, amp, period) + amp, period = amp and math.max(1, amp) or 1, period or .3 + return (-amp * math.sin(2*math.pi/period * (s-1) - math.asin(1/amp))) * 2^(10*(s-1)) + end, +}, { + +-- register new tween +__call = function(tween, self, len, subject, target, method, after, ...) + -- recursively collects fields that are defined in both subject and target into a flat list + local function tween_collect_payload(subject, target, out) + for k,v in pairs(target) do + local ref = subject[k] + assert(type(v) == type(ref), 'Type mismatch in field "'..k..'".') + if type(v) == 'table' then + tween_collect_payload(ref, v, out) + else + local ok, delta = pcall(function() return (v-ref)*1 end) + assert(ok, 'Field "'..k..'" does not support arithmetic operations') + out[#out+1] = {subject, k, delta} + end + end + return out + end + + method = tween[method or 'linear'] -- see __index + local payload, t, args = tween_collect_payload(subject, target, {}), 0, {...} + + local last_s = 0 + return self:during(len, function(dt) + t = t + dt + local s = method(math.min(1, t/len), unpack(args)) + local ds = s - last_s + last_s = s + for _, info in ipairs(payload) do + local ref, key, delta = unpack(info) + ref[key] = ref[key] + delta * ds + end + end, after) +end, + +-- fetches function and generated compositions for method `key` +__index = function(tweens, key) + if type(key) == 'function' then return key end + + assert(type(key) == 'string', 'Method must be function or string.') + if rawget(tweens, key) then return rawget(tweens, key) end + + local function construct(pattern, f) + local method = rawget(tweens, key:match(pattern)) + if method then return f(method) end + return nil + end + + local out, chain = rawget(tweens,'out'), rawget(tweens,'chain') + return construct('^in%-([^-]+)$', function(...) return ... end) + or construct('^out%-([^-]+)$', out) + or construct('^in%-out%-([^-]+)$', function(f) return chain(f, out(f)) end) + or construct('^out%-in%-([^-]+)$', function(f) return chain(out(f), f) end) + or error('Unknown interpolation method: ' .. key) +end}) + +-- Timer instancing +function Timer.new() + return setmetatable({functions = {}, tween = Timer.tween}, Timer) +end + +-- default instance +local default = Timer.new() + +-- module forwards calls to default instance +local module = {} +for k in pairs(Timer) do + if k ~= "__index" then + module[k] = function(...) return default[k](default, ...) end + end +end +module.tween = setmetatable({}, { + __index = Timer.tween, + __newindex = function(k,v) Timer.tween[k] = v end, + __call = function(t, ...) return default:tween(...) end, +}) + +return setmetatable(module, {__call = Timer.new}) diff --git a/src/lib/log.lua b/src/lib/log.lua new file mode 100644 index 0000000..12bfbf8 --- /dev/null +++ b/src/lib/log.lua @@ -0,0 +1,89 @@ +-- +-- log.lua +-- +-- Copyright (c) 2016 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 log = { _version = "0.1.0" } + +log.usecolor = true +log.outfile = nil +log.level = "trace" + + +local modes = { + { name = "trace", color = "\27[34m", }, + { name = "debug", color = "\27[36m", }, + { name = "info", color = "\27[32m", }, + { name = "warn", color = "\27[33m", }, + { name = "error", color = "\27[31m", }, + { name = "fatal", color = "\27[35m", }, +} + + +local levels = {} +for i, v in ipairs(modes) do + levels[v.name] = i +end + + +local round = function(x, increment) + increment = increment or 1 + x = x / increment + return (x > 0 and math.floor(x + .5) or math.ceil(x - .5)) * increment +end + + +local _tostring = tostring + +local tostring = function(...) + local t = {} + for i = 1, select('#', ...) do + local x = select(i, ...) + if type(x) == "number" then + x = round(x, .01) + end + t[#t + 1] = _tostring(x) + end + return table.concat(t, " ") +end + +local dateFormat = "%H:%M:%S %d/%m/%Y" + +for i, x in ipairs(modes) do + local nameupper = x.name:upper() + log[x.name] = function(...) + + -- Return early if we're below the log level + if i < levels[log.level] then + return + end + + local msg = tostring(...) + -- local info = debug.getinfo(2, "Sl") + -- local lineinfo = info.short_src .. ":" .. info.currentline + + -- Output to console + print(string.format("%s[%-6s%s]%s %s", + log.usecolor and x.color or "", + nameupper, + os.date(dateFormat), + log.usecolor and "\27[0m" or "", + msg)) + + -- Output to log file + if log.outfile then + local fp = io.open(log.outfile, "a") + local str = string.format("[%-6s%s] %s\n", nameupper, os.date(dateFormat), msg) + fp:write(str) + fp:close() + end + + end +end + + +return log diff --git a/src/lib/lume.lua b/src/lib/lume.lua new file mode 100644 index 0000000..8a78078 --- /dev/null +++ b/src/lib/lume.lua @@ -0,0 +1,688 @@ +-- +-- lume +-- +-- Copyright (c) 2018 rxi +-- +-- 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. +-- +local lume = {_version = "2.3.0"} + +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_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.vector(angle, magnitude) + return math.cos(angle) * magnitude, math.sin(angle) * magnitude +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 +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.unique(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 ~= nil 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 + +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/src/lib/nata.lua b/src/lib/nata.lua new file mode 100644 index 0000000..4d778b2 --- /dev/null +++ b/src/lib/nata.lua @@ -0,0 +1,546 @@ +--- Entity management for Lua. +-- @module nata + +local nata = { + _VERSION = 'Nata v0.3.3', + _DESCRIPTION = 'Entity management for Lua.', + _URL = 'https://github.com/tesselode/nata', + _LICENSE = [[ + MIT License + + Copyright (c) 2020 Andrew Minnich + + 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. + ]] +} + +-- gets the error level needed to make an error appear +-- in the user's code, not the library code +local function getUserErrorLevel() + local source = debug.getinfo(1).source + local level = 1 + while debug.getinfo(level).source == source do + level = level + 1 + end + --[[ + we return level - 1 here and not just level + because the level was calculated one function + deeper than the function that will actually + use this value. if we produced an error *inside* + this function, level would be correct, but + for the function calling this function, level - 1 + is correct. + ]] + return level - 1 +end + +-- gets the name of the function that the user called +-- that eventually caused an error +local function getUserCalledFunctionName() + return debug.getinfo(getUserErrorLevel() - 1).name +end + +local function checkCondition(condition, message) + if condition then return end + error(message, getUserErrorLevel()) +end + +-- changes a list of types into a human-readable phrase +-- i.e. string, table, number -> "string, table, or number" +local function getAllowedTypesText(...) + local numberOfArguments = select('#', ...) + if numberOfArguments >= 3 then + local text = '' + for i = 1, numberOfArguments - 1 do + text = text .. string.format('%s, ', select(i, ...)) + end + text = text .. string.format('or %s', select(numberOfArguments, ...)) + return text + elseif numberOfArguments == 2 then + return string.format('%s or %s', select(1, ...), select(2, ...)) + end + return select(1, ...) +end + +-- checks if an argument is of the correct type, and if not, +-- throws a "bad argument" error consistent with the ones +-- lua and love produce +local function checkArgument(argumentIndex, argument, ...) + for i = 1, select('#', ...) do + -- allow tables with the __call metamethod to be treated like functions + if select(i, ...) == 'function' then + if type(argument) == 'table' and getmetatable(argument).__call then + return + end + end + if type(argument) == select(i, ...) then return end + end + error( + string.format( + "bad argument #%i to '%s' (expected %s, got %s)", + argumentIndex, + getUserCalledFunctionName(), + getAllowedTypesText(...), + type(argument) + ), + getUserErrorLevel() + ) +end + +local function checkOptionalArgument(argumentIndex, argument, ...) + if argument == nil then return end + checkArgument(argumentIndex, argument, ...) +end + +local function removeByValue(t, v) + for i = #t, 1, -1 do + if t[i] == v then table.remove(t, i) end + end +end + +local function entityHasKeys(entity, keys) + for _, key in ipairs(keys) do + if not entity[key] then return false end + end + return true +end + +local function filterEntity(entity, filter) + if type(filter) == 'table' then + return entityHasKeys(entity, filter) + elseif type(filter) == 'function' then + return filter(entity) + end + return true +end + +--- Defines the behaviors of a system. +-- +-- There's no constructor for SystemDefinitions. Rather, you simply +-- define a table with functions that correspond to events. These +-- events can be named anything you like. Below are the built-in events +-- that the pool will automatically call. +-- @type SystemDefinition + +--- Called when the pool is first created. +-- @function SystemDefinition:init +-- @param ... additional arguments that were passed to `nata.new`. + +--- Called when an entity is added to the pool. +-- @function SystemDefinition:add +-- @tparam table e the entity that was added + +--- Called when an entity is removed from the pool. +-- @function SystemDefinition:remove +-- @tparam table e the entity that was removed + +--- Called when an entity is added to a group. +-- @function SystemDefinition:addToGroup +-- @string groupName the name of the group that the entity was added to +-- @tparam table e the entity that was added + +--- Called when an entity is removed from a group. +-- @function SystemDefinition:removeFromGroup +-- @string groupName the name of the group that the entity was removed from +-- @tparam table e the entity that was removed + +--- Responds to events in a pool. +-- +-- Systems are not created directly. They're created by the @{Pool} +-- according to the @{SystemDefinition}s passed to `nata.new`. +-- Each system instance inherits all of the functions of its +-- @{SystemDefinition}. +-- @type System + +--- The @{Pool} that this system is running on. +-- @tfield Pool pool + +--- Manages a subset of entities. +-- @type Group + +--- The filter that defines which entities are added to this group. +-- Can be either: +-- +-- - A list of required keys +-- - A function that takes the entity as the first argument +-- and returns true if the entity should be added to the group +-- @tfield[opt] table|function filter + +--- A function that specifies how the entities in this group should be sorted. +-- Has the same requirements as the function argument to Lua's built-in `table.sort`. +-- @tfield[opt] function sort + +--- A list of all the entities in the group. +-- @tfield table entities + +--- A set of all the entities in the group. +-- @tfield table hasEntity +-- @usage +-- print(pool.groups.physical.hasEntity[e]) -- prints "true" if the entity is in the "physical" group, or "nil" if not + +--- Manages entities in a game world. +-- @type Pool +local Pool = {} +Pool.__index = Pool + +--- A list of all the entities in the pool. +-- @tfield table entities + +--- A set of all the entities in the pool. +-- @tfield table hasEntity +-- @usage +-- print(pool.hasEntity[e]) -- prints "true" if the entity is in the pool, or "nil" if not + +--- A dictionary of the @{Group}s in the pool. +-- @tfield table groups + +--- A field containing any data you want. +-- @field data + +--- + +function Pool:_validateOptions(options) + checkOptionalArgument(1, options, 'table') + if not options then return end + if options.groups then + checkCondition(type(options.groups) == 'table', "groups must be a table") + for groupName, groupOptions in pairs(options.groups) do + checkCondition(type(groupOptions) == 'table', + string.format("options for group '$s' must be a table", groupName)) + local filter = groupOptions.filter + if filter ~= nil then + checkCondition(type(filter) == 'table' or type(filter) == 'function', + string.format("filter for group '%s' must be a table or function", groupName)) + end + local sort = groupOptions.sort + if sort ~= nil then + checkCondition(type(sort) == 'function', + string.format("sort for group '%s' must be a function", groupName)) + end + end + end + if options.systems then + checkCondition(type(options.systems) == 'table', "systems must be a table") + for _, system in ipairs(options.systems) do + checkCondition(type(system) == 'table', "all systems must be tables") + end + end +end + +function Pool:_init(options, ...) + self:_validateOptions(options) + options = options or {} + -- entities that will be added to the pool on the next flush + self._queue = {} + -- a temporary table for entities that will be added to the pool + -- on the current flush (see Pool.flush for more details) + self._entitiesToFlush = {} + self.entities = {} + self.hasEntity = {} + self.groups = {} + self._systems = {} + self._events = {} + self.data = options.data or {} + local groups = options.groups or {} + local systems = options.systems or {nata.oop()} + for groupName, groupOptions in pairs(groups) do + self.groups[groupName] = { + filter = groupOptions.filter, + sort = groupOptions.sort, + entities = {}, + hasEntity = {}, + } + end + for _, systemDefinition in ipairs(systems) do + local system = setmetatable({ + pool = self, + }, {__index = systemDefinition}) + table.insert(self._systems, system) + end + self:emit('init', ...) +end + +--- Queues an entity to be added to the pool. +-- @tparam table entity the entity to add +-- @treturn table the queued entity +function Pool:queue(entity) + table.insert(self._queue, entity) + return entity +end + +--- Adds the queued entities to the pool. Entities are added +-- in the order they were queued. +function Pool:flush() + --[[ + Move the currently queued entities to a temporary + table. This way, if an add/addToGroup/removeToGroup + event emission leads to another entity being queued, + it will be saved for the next flush, rather than + adding entities to the table we're in the middle + of iterating over, which would lead to an array with + holes and screw everything up. + ]] + for i = 1, #self._queue do + local entity = self._queue[i] + self._entitiesToFlush[i] = entity + self._queue[i] = nil + end + for i = 1, #self._entitiesToFlush do + local entity = self._entitiesToFlush[i] + -- check if the entity belongs in each group and + -- add it to/remove it from the group as needed + for groupName, group in pairs(self.groups) do + if filterEntity(entity, group.filter) then + if not group.hasEntity[entity] then + table.insert(group.entities, entity) + group.hasEntity[entity] = true + self:emit('addToGroup', groupName, entity) + end + if group.sort then group._needsResort = true end + elseif group.hasEntity[entity] then + removeByValue(group.entities, entity) + group.hasEntity[entity] = nil + self:emit('removeFromGroup', groupName, entity) + end + end + -- add the entity to the pool if it hasn't been added already + if not self.hasEntity[entity] then + table.insert(self.entities, entity) + self.hasEntity[entity] = true + self:emit('add', entity) + end + self._entitiesToFlush[i] = nil + end + -- re-sort groups + for _, group in pairs(self.groups) do + if group._needsResort then + table.sort(group.entities, group.sort) + group._needsResort = nil + end + end +end + +--- Removes entities from the pool. +-- @tparam function f the condition upon which an entity should be +-- removed. The function should take an entity as the first argument +-- and return `true` if the entity should be removed. +function Pool:remove(f) + checkArgument(1, f, 'function') + for groupName, group in pairs(self.groups) do + for i = #group.entities, 1, -1 do + local entity = group.entities[i] + if f(entity) then + self:emit('removeFromGroup', groupName, entity) + table.remove(group.entities, i) + group.hasEntity[entity] = nil + end + end + end + for i = #self.entities, 1, -1 do + local entity = self.entities[i] + if f(entity) then + self:emit('remove', entity) + table.remove(self.entities, i) + self.hasEntity[entity] = nil + end + end +end + +--- Registers a function to be called when an event is emitted. +-- @string event the event to listen for +-- @tparam function f the function to call +-- @treturn function the function that was registered +function Pool:on(event, f) + checkCondition(event ~= nil, "event cannot be nil") + checkArgument(2, f, 'function') + self._events[event] = self._events[event] or {} + table.insert(self._events[event], f) + return f +end + +--- Unregisters a function from an event. +-- @string event the event to unregister the function from +-- @tparam function f the function to unregister +function Pool:off(event, f) + checkCondition(event ~= nil, "event cannot be nil") + checkArgument(2, f, 'function') + if self._events[event] then + removeByValue(self._events[event], f) + end +end + +--- Emits an event. The `system[event]` function will be called +-- for each system that has it, and functions registered +-- to the event will be called as well. +-- @string event the event to emit +-- @param ... additional arguments to pass to the functions that are called +function Pool:emit(event, ...) + checkCondition(event ~= nil, "event cannot be nil") + for _, system in ipairs(self._systems) do + if type(system[event]) == 'function' then + system[event](system, ...) + end + end + if self._events[event] then + for _, f in ipairs(self._events[event]) do + f(...) + end + end +end + +--- Gets this pool's instance of a system. +-- @tparam SystemDefinition systemDefinition the system class to get the instance of +-- @treturn System the instance of the system running in this pool +function Pool:getSystem(systemDefinition) + checkArgument(1, systemDefinition, 'table') + for _, system in ipairs(self._systems) do + if getmetatable(system).__index == systemDefinition then + return system + end + end +end + +--- +-- @section end + +local function validateOopOptions(options) + checkOptionalArgument(1, options, 'table') + if not options then return end + if options.include then + checkCondition(type(options.include) == 'table', "include must be a table") + end + if options.exclude then + checkCondition(type(options.exclude) == 'table', "exclude must be a table") + end +end + +--- Defines the behavior of an OOP system. +-- @type OopOptions +-- @see nata.oop + +--- A list of events to forward to entities. If not defined, +-- the system will forward all events to entities (except +-- for the ones in the exclude list). +-- @tfield table include + +--- A list of events *not* to forward to entities. +-- @tfield table exclude + +--- The name of the group of entities to forward events to. +-- If not defined, the system will forward events to all entities. +-- @tfield string group + +--- +-- @section end + +--- Creates a new OOP system definition. +-- An OOP system, upon receiving an event, will call +-- the function of the same name on each entity it monitors +-- (if it exists). This facilitates a more traditional, OOP-style +-- entity management, where you loop over a table of entities and +-- call update and draw functions on them. +-- @tparam[opt] OopOptions options how to set up the OOP system +-- @treturn SystemDefinition the new OOP system definition +function nata.oop(options) + validateOopOptions(options) + local group = options and options.group + local include, exclude + if options and options.include then + include = {} + for _, event in ipairs(options.include) do + include[event] = true + end + end + if options and options.exclude then + exclude = {} + for _, event in ipairs(options.exclude) do + exclude[event] = true + end + end + return setmetatable({_cache = {}}, { + __index = function(t, event) + t._cache[event] = t._cache[event] or function(self, ...) + local shouldCallEvent = true + if include and not include[event] then shouldCallEvent = false end + if exclude and exclude[event] then shouldCallEvent = false end + if shouldCallEvent then + local entities + -- not using ternary here because if the group doesn't exist, + -- i'd rather it cause an error than just silently falling back + -- to the main entity pool + if group then + entities = self.pool.groups[group].entities + else + entities = self.pool.entities + end + for _, entity in ipairs(entities) do + if type(entity[event]) == 'function' then + entity[event](entity, ...) + end + end + end + end + return t._cache[event] + end + }) +end + +--- Defines the filter and sort function for a @{Group}. +-- @type GroupOptions + +--- The filter that defines which entities are added to this group. +-- Can be either: +-- +-- - A list of required keys +-- - A function that takes the entity as the first argument +-- and returns true if the entity should be added to the group +-- @tfield[opt] table|function filter + +--- A function that specifies how the entities in this group should be sorted. +-- Has the same requirements as the function argument to Lua's built-in `table.sort`. +-- @tfield[opt] function sort + +--- Defines the groups and systems for a @{Pool}. +-- @type PoolOptions + +--- A dictionary of groups for the pool to have. +-- Each key is the name of the group, and each value +-- should be a @{GroupOptions} table. +-- @tfield[opt={}] table groups + +--- A list of @{SystemDefinition}s for the pool to use. +-- @tfield[opt={nata.oop()}] table systems + +--- An initial value to set @{Pool.data} to. +-- @field[opt={}] data + +--- +-- @section end + +--- Creates a new @{Pool}. +-- @tparam[opt] PoolOptions options how to set up the pool +-- @param[opt] ... additional arguments to pass to the pool's init event +-- @treturn Pool the new pool +function nata.new(options, ...) + local pool = setmetatable({}, Pool) + pool:_init(options, ...) + return pool +end + +return nata diff --git a/src/lib/pprint.lua b/src/lib/pprint.lua new file mode 100644 index 0000000..62bf562 --- /dev/null +++ b/src/lib/pprint.lua @@ -0,0 +1,475 @@ +local pprint = { VERSION = '0.1' } + +local depth = 1 + +pprint.defaults = { + -- If set to number N, then limit table recursion to N deep. + depth_limit = false, + -- type display trigger, hide not useful datatypes by default + -- custom types are treated as table + show_nil = false, + show_boolean = true, + show_number = true, + show_string = true, + show_table = true, + show_function = false, + show_thread = false, + show_userdata = false, + -- additional display trigger + show_metatable = false, -- show metatable + show_all = false, -- override other show settings and show everything + use_tostring = true, -- use __tostring to print table if available + filter_function = nil, -- called like callback(value[,key, parent]), return truty value to hide + object_cache = 'local', -- cache blob and table to give it a id, 'local' cache per print, 'global' cache + -- per process, falsy value to disable (might cause infinite loop) + -- format settings + indent_size = 2, -- indent for each nested table level + level_width = 80, -- max width per indent level + wrap_string = true, -- wrap string when it's longer than level_width + wrap_array = false, -- wrap every array elements + sort_keys = true, -- sort table keys +} + +local TYPES = { + ['nil'] = 1, ['boolean'] = 2, ['number'] = 3, ['string'] = 4, + ['table'] = 5, ['function'] = 6, ['thread'] = 7, ['userdata'] = 8 +} + +-- seems this is the only way to escape these, as lua don't know how to map char '\a' to 'a' +local ESCAPE_MAP = { + ['\a'] = '\\a', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', + ['\t'] = '\\t', ['\v'] = '\\v', ['\\'] = '\\\\', +} + +-- generic utilities +local function escape(s) + s = s:gsub('([%c\\])', ESCAPE_MAP) + local dq = s:find('"') + local sq = s:find("'") + if dq and sq then + return s:gsub('"', '\\"'), '"' + elseif sq then + return s, '"' + else + return s, "'" + end +end + +local function is_plain_key(key) + return type(key) == 'string' and key:match('^[%a_][%a%d_]*$') +end + +local CACHE_TYPES = { + ['table'] = true, ['function'] = true, ['thread'] = true, ['userdata'] = true +} + +-- cache would be populated to be like: +-- { +-- function = { `fun1` = 1, _cnt = 1 }, -- object id +-- table = { `table1` = 1, `table2` = 2, _cnt = 2 }, +-- visited_tables = { `table1` = 7, `table2` = 8 }, -- visit count +-- } +-- use weakrefs to avoid accidentall adding refcount +local function cache_apperance(obj, cache, option) + if not cache.visited_tables then + cache.visited_tables = setmetatable({}, {__mode = 'k'}) + end + local t = type(obj) + + -- TODO can't test filter_function here as we don't have the ix and key, + -- might cause different results? + -- respect show_xxx and filter_function to be consistent with print results + if (not TYPES[t] and not option.show_table) + or (TYPES[t] and not option['show_'..t]) then + return + end + + if CACHE_TYPES[t] or TYPES[t] == nil then + if not cache[t] then + cache[t] = setmetatable({}, {__mode = 'k'}) + cache[t]._cnt = 0 + end + if not cache[t][obj] then + cache[t]._cnt = cache[t]._cnt + 1 + cache[t][obj] = cache[t]._cnt + end + end + if t == 'table' or TYPES[t] == nil then + if cache.visited_tables[obj] == false then + -- already printed, no need to mark this and its children anymore + return + elseif cache.visited_tables[obj] == nil then + cache.visited_tables[obj] = 1 + else + -- visited already, increment and continue + cache.visited_tables[obj] = cache.visited_tables[obj] + 1 + return + end + for k, v in pairs(obj) do + cache_apperance(k, cache, option) + cache_apperance(v, cache, option) + end + local mt = getmetatable(obj) + if mt and option.show_metatable then + cache_apperance(mt, cache, option) + end + end +end + +-- makes 'foo2' < 'foo100000'. string.sub makes substring anyway, no need to use index based method +local function str_natural_cmp(lhs, rhs) + while #lhs > 0 and #rhs > 0 do + local lmid, lend = lhs:find('%d+') + local rmid, rend = rhs:find('%d+') + if not (lmid and rmid) then return lhs < rhs end + + local lsub = lhs:sub(1, lmid-1) + local rsub = rhs:sub(1, rmid-1) + if lsub ~= rsub then + return lsub < rsub + end + + local lnum = tonumber(lhs:sub(lmid, lend)) + local rnum = tonumber(rhs:sub(rmid, rend)) + if lnum ~= rnum then + return lnum < rnum + end + + lhs = lhs:sub(lend+1) + rhs = rhs:sub(rend+1) + end + return lhs < rhs +end + +local function cmp(lhs, rhs) + local tleft = type(lhs) + local tright = type(rhs) + if tleft == 'number' and tright == 'number' then return lhs < rhs end + if tleft == 'string' and tright == 'string' then return str_natural_cmp(lhs, rhs) end + if tleft == tright then return str_natural_cmp(tostring(lhs), tostring(rhs)) end + + -- allow custom types + local oleft = TYPES[tleft] or 9 + local oright = TYPES[tright] or 9 + return oleft < oright +end + +-- setup option with default +local function make_option(option) + if option == nil then + option = {} + end + for k, v in pairs(pprint.defaults) do + if option[k] == nil then + option[k] = v + end + if option.show_all then + for t, _ in pairs(TYPES) do + option['show_'..t] = true + end + option.show_metatable = true + end + end + return option +end + +-- override defaults and take effects for all following calls +function pprint.setup(option) + pprint.defaults = make_option(option) +end + +-- format lua object into a string +function pprint.pformat(obj, option, printer) + option = make_option(option) + local buf = {} + local function default_printer(s) + table.insert(buf, s) + end + printer = printer or default_printer + + local cache + if option.object_cache == 'global' then + -- steal the cache into a local var so it's not visible from _G or anywhere + -- still can't avoid user explicitly referentce pprint._cache but it shouldn't happen anyway + cache = pprint._cache or {} + pprint._cache = nil + elseif option.object_cache == 'local' then + cache = {} + end + + local last = '' -- used for look back and remove trailing comma + local status = { + indent = '', -- current indent + len = 0, -- current line length + } + + local wrapped_printer = function(s) + printer(last) + last = s + end + + local function _indent(d) + status.indent = string.rep(' ', d + #(status.indent)) + end + + local function _n(d) + wrapped_printer('\n') + wrapped_printer(status.indent) + if d then + _indent(d) + end + status.len = 0 + return true -- used to close bracket correctly + end + + local function _p(s, nowrap) + status.len = status.len + #s + if not nowrap and status.len > option.level_width then + _n() + wrapped_printer(s) + status.len = #s + else + wrapped_printer(s) + end + end + + local formatter = {} + local function format(v) + local f = formatter[type(v)] + f = f or formatter.table -- allow patched type() + if option.filter_function and option.filter_function(v, nil, nil) then + return '' + else + return f(v) + end + end + + local function tostring_formatter(v) + return tostring(v) + end + + local function number_formatter(n) + return n == math.huge and '[[math.huge]]' or tostring(n) + end + + local function nop_formatter(v) + return '' + end + + local function make_fixed_formatter(t, has_cache) + if has_cache then + return function (v) + return string.format('[[%s %d]]', t, cache[t][v]) + end + else + return function (v) + return '[['..t..']]' + end + end + end + + local function string_formatter(s, force_long_quote) + local s, quote = escape(s) + local quote_len = force_long_quote and 4 or 2 + if quote_len + #s + status.len > option.level_width then + _n() + -- only wrap string when is longer than level_width + if option.wrap_string and #s + quote_len > option.level_width then + -- keep the quotes together + _p('[[') + while #s + status.len >= option.level_width do + local seg = option.level_width - status.len + _p(string.sub(s, 1, seg), true) + _n() + s = string.sub(s, seg+1) + end + _p(s) -- print the remaining parts + return ']]' + end + end + + return force_long_quote and '[['..s..']]' or quote..s..quote + end + + local function table_formatter(t) + if option.use_tostring then + local mt = getmetatable(t) + if mt and mt.__tostring then + return string_formatter(tostring(t), true) + end + end + + local print_header_ix = nil + local ttype = type(t) + if option.object_cache then + local cache_state = cache.visited_tables[t] + local tix = cache[ttype][t] + -- FIXME should really handle `cache_state == nil` + -- as user might add things through filter_function + if cache_state == false then + -- already printed, just print the the number + return string_formatter(string.format('%s %d', ttype, tix), true) + elseif cache_state > 1 then + -- appeared more than once, print table header with number + print_header_ix = tix + cache.visited_tables[t] = false + else + -- appeared exactly once, print like a normal table + end + end + + local limit = tonumber(option.depth_limit) + if limit and depth > limit then + if print_header_ix then + return string.format('[[%s %d]]...', ttype, print_header_ix) + end + return string_formatter(tostring(t), true) + end + + local tlen = #t + local wrapped = false + _p('{') + _indent(option.indent_size) + _p(string.rep(' ', option.indent_size - 1)) + if print_header_ix then + _p(string.format('--[[%s %d]] ', ttype, print_header_ix)) + end + for ix = 1,tlen do + local v = t[ix] + if formatter[type(v)] == nop_formatter or + (option.filter_function and option.filter_function(v, ix, t)) then + -- pass + else + if option.wrap_array then + wrapped = _n() + end + depth = depth+1 + _p(format(v)..', ') + depth = depth-1 + end + end + + -- hashmap part of the table, in contrast to array part + local function is_hash_key(k) + if type(k) ~= 'number' then + return true + end + + local numkey = math.floor(tonumber(k)) + if numkey ~= k or numkey > tlen or numkey <= 0 then + return true + end + end + + local function print_kv(k, v, t) + -- can't use option.show_x as obj may contain custom type + if formatter[type(v)] == nop_formatter or + formatter[type(k)] == nop_formatter or + (option.filter_function and option.filter_function(v, k, t)) then + return + end + wrapped = _n() + if is_plain_key(k) then + _p(k, true) + else + _p('[') + -- [[]] type string in key is illegal, needs to add spaces inbetween + local k = format(k) + if string.match(k, '%[%[') then + _p(' '..k..' ', true) + else + _p(k, true) + end + _p(']') + end + _p(' = ', true) + depth = depth+1 + _p(format(v), true) + depth = depth-1 + _p(',', true) + end + + if option.sort_keys then + local keys = {} + for k, _ in pairs(t) do + if is_hash_key(k) then + table.insert(keys, k) + end + end + table.sort(keys, cmp) + for _, k in ipairs(keys) do + print_kv(k, t[k], t) + end + else + for k, v in pairs(t) do + if is_hash_key(k) then + print_kv(k, v, t) + end + end + end + + if option.show_metatable then + local mt = getmetatable(t) + if mt then + print_kv('__metatable', mt, t) + end + end + + _indent(-option.indent_size) + -- make { } into {} + last = string.gsub(last, '^ +$', '') + -- peek last to remove trailing comma + last = string.gsub(last, ',%s*$', ' ') + if wrapped then + _n() + end + _p('}') + + return '' + end + + -- set formatters + formatter['nil'] = option.show_nil and tostring_formatter or nop_formatter + formatter['boolean'] = option.show_boolean and tostring_formatter or nop_formatter + formatter['number'] = option.show_number and number_formatter or nop_formatter -- need to handle math.huge + formatter['function'] = option.show_function and make_fixed_formatter('function', option.object_cache) or nop_formatter + formatter['thread'] = option.show_thread and make_fixed_formatter('thread', option.object_cache) or nop_formatter + formatter['userdata'] = option.show_userdata and make_fixed_formatter('userdata', option.object_cache) or nop_formatter + formatter['string'] = option.show_string and string_formatter or nop_formatter + formatter['table'] = option.show_table and table_formatter or nop_formatter + + if option.object_cache then + -- needs to visit the table before start printing + cache_apperance(obj, cache, option) + end + + _p(format(obj)) + printer(last) -- close the buffered one + + -- put cache back if global + if option.object_cache == 'global' then + pprint._cache = cache + end + + return table.concat(buf) +end + +-- pprint all the arguments +function pprint.pprint( ... ) + local args = {...} + -- select will get an accurate count of array len, counting trailing nils + local len = select('#', ...) + for ix = 1,len do + pprint.pformat(args[ix], nil, io.write) + io.write('\n') + end +end + +setmetatable(pprint, { + __call = function (_, ...) + pprint.pprint(...) + end +}) + +return pprint + diff --git a/src/lib/ripple.lua b/src/lib/ripple.lua new file mode 100644 index 0000000..c955a7a --- /dev/null +++ b/src/lib/ripple.lua @@ -0,0 +1,522 @@ +local ripple = { + _VERSION = 'Ripple', + _DESCRIPTION = 'Audio helpers for LÖVE.', + _URL = 'https://github.com/tesselode/ripple', + _LICENSE = [[ + MIT License + + Copyright (c) 2019 Andrew Minnich + + 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. + ]] +} + +local unpack = unpack or table.unpack -- luacheck: ignore + +--[[ + Represents an object that: + - can have tags applied + - has a volume + - can have effects applied + + Tags, instances, and sounds are all taggable. + + Note that not all taggable objects have children - tags and sounds + do, but instances do not. +]] +local Taggable = {} + +--[[ + Gets the total volume of this object given its own volume + and the volume of each of its tags. +]] +function Taggable:_getTotalVolume() + local volume = self.volume + for tag, _ in pairs(self._tags) do + volume = volume * tag:_getTotalVolume() + end + return volume +end + +--[[ + Gets all the effects that should be applied to this object given + its own effects and the effects of each of its tags. The object's + own effects will override tag effects. + + Note: currently, if multiple tags define settings for the same effect, + the final result is undefined, as taggable objects use pairs to iterate + through the tags, which iterates in an undefined order. +]] +function Taggable:_getAllEffects() + local effects = {} + for tag, _ in pairs(self._tags) do + for name, filterSettings in pairs(tag:_getAllEffects()) do + effects[name] = filterSettings + end + end + for name, filterSettings in pairs(self._effects) do + effects[name] = filterSettings + end + return effects +end + +--[[ + A callback that is called when anything happens that could + lead to a change in the object's total volume. +]] +function Taggable:_onChangeVolume() end + +--[[ + A callback that is called when anything happens that could + change which effects are applied to the object. +]] +function Taggable:_onChangeEffects() end + +function Taggable:_setVolume(volume) + self._volume = volume + self:_onChangeVolume() +end + +--[[ + _tag, _untag, and _setEffect are analogous to the + similarly named public API functions (see below), but + they don't call the _onChangeVolume and _onChangeEffects + callbacks. This allows me to have finer control over when + to call those callbacks, so I can set multiple tags and + effects without needlessly calling the callbacks for each + one. +]] + +function Taggable:_tag(tag) + self._tags[tag] = true + tag._children[self] = true +end + +function Taggable:_untag(tag) + self._tags[tag] = nil + tag._children[self] = nil +end + +function Taggable:_setEffect(name, filterSettings) + if filterSettings == nil then filterSettings = true end + self._effects[name] = filterSettings +end + +--[[ + Given an options table, initializes the object's volume, + tags, and effects. +]] +function Taggable:_setOptions(options) + self.volume = options and options.volume or 1 + -- reset tags + for tag in pairs(self._tags) do + self:_untag(tag) + end + -- apply new tags + if options and options.tags then + for _, tag in ipairs(options.tags) do + self:_tag(tag) + end + end + -- reset effects + for name in pairs(self._effects) do + self._effects[name] = nil + end + -- apply new effects + if options and options.effects then + for name, filterSettings in pairs(options.effects) do + self:_setEffect(name, filterSettings) + end + end + -- update final volume and effects + self:_onChangeVolume() + self:_onChangeEffects() +end + +function Taggable:tag(...) + for i = 1, select('#', ...) do + local tag = select(i, ...) + self:_tag(tag) + end + self:_onChangeVolume() + self:_onChangeEffects() +end + +function Taggable:untag(...) + for i = 1, select('#', ...) do + local tag = select(i, ...) + self:_untag(tag) + end + self:_onChangeVolume() + self:_onChangeEffects() +end + +--[[ + Sets an effect for this object. filterSettings can be the following types: + - table - the effect will be enabled with the filter settings given in the table + - true/nil - the effect will be enabled with no filter + - false - the effect will be explicitly disabled, overriding effect settings + from a parent sound or tag +]] +function Taggable:setEffect(name, filterSettings) + self:_setEffect(name, filterSettings) + self:_onChangeEffects() +end + +function Taggable:removeEffect(name) + self._effects[name] = nil + self:_onChangeEffects() +end + +function Taggable:getEffect(name) + return self._effects[name] +end + +function Taggable:__index(key) + if key == 'volume' then + return self._volume + end + return Taggable[key] +end + +function Taggable:__newindex(key, value) + if key == 'volume' then + self:_setVolume(value) + else + rawset(self, key, value) + end +end + +--[[ + Represents a tag that can be applied to sounds, + instances of sounds, or other tags. +]] +local Tag = {__newindex = Taggable.__newindex} + +function Tag:__index(key) + if Tag[key] then return Tag[key] end + return Taggable.__index(self, key) +end + +function Tag:_onChangeVolume() + -- tell objects using this tag about a potential + -- volume change + for child, _ in pairs(self._children) do + child:_onChangeVolume() + end +end + +function Tag:_onChangeEffect() + -- tell objects using this tag about a potential + -- effect change + for child, _ in pairs(self._children) do + child:_onChangeEffect() + end +end + +-- Pauses all the sounds and instances tagged with this tag. +function Tag:pause(fadeDuration) + for child, _ in pairs(self._children) do + child:pause(fadeDuration) + end +end + +-- Resumes all the sounds and instances tagged with this tag. +function Tag:resume(fadeDuration) + for child, _ in pairs(self._children) do + child:resume(fadeDuration) + end +end + +-- Stops all the sounds and instances tagged with this tag. +function Tag:stop(fadeDuration) + for child, _ in pairs(self._children) do + child:stop(fadeDuration) + end +end + +function ripple.newTag(options) + local tag = setmetatable({ + _effects = {}, + _tags = {}, + _children = {}, + }, Tag) + tag:_setOptions(options) + return tag +end + +-- Represents a specific occurrence of a sound. +local Instance = {} + +function Instance:__index(key) + if key == 'pitch' then + return self._source:getPitch() + elseif key == 'loop' then + return self._source:isLooping() + elseif Instance[key] then + return Instance[key] + end + return Taggable.__index(self, key) +end + +function Instance:__newindex(key, value) + if key == 'pitch' then + self._source:setPitch(value) + elseif key == 'loop' then + self._source:setLooping(value) + else + Taggable.__newindex(self, key, value) + end +end + +function Instance:_getTotalVolume() + local volume = Taggable._getTotalVolume(self) + -- apply sound volume as well as tag/self volumes + volume = volume * self._sound:_getTotalVolume() + -- apply fade volume + volume = volume * self._fadeVolume + return volume +end + +function Instance:_getAllEffects() + local effects = {} + for tag, _ in pairs(self._tags) do + for name, filterSettings in pairs(tag:_getAllEffects()) do + effects[name] = filterSettings + end + end + -- apply sound effects as well as tag/self effects + for name, filterSettings in pairs(self._sound:_getAllEffects()) do + effects[name] = filterSettings + end + for name, filterSettings in pairs(self._effects) do + effects[name] = filterSettings + end + return effects +end + +function Instance:_onChangeVolume() + -- update the source's volume + self._source:setVolume(self:_getTotalVolume()) +end + +function Instance:_onChangeEffects() + -- get the list of effects that should be applied + local effects = self:_getAllEffects() + for name, filterSettings in pairs(effects) do + -- remember which effects are currently applied to the source + if filterSettings == false then + self._appliedEffects[name] = nil + else + self._appliedEffects[name] = true + end + if filterSettings == true then + self._source:setEffect(name) + else + self._source:setEffect(name, filterSettings) + end + end + -- remove effects that are currently applied but shouldn't be anymore + for name in pairs(self._appliedEffects) do + if not effects[name] then + self._source:setEffect(name, false) + self._appliedEffects[name] = nil + end + end +end + +function Instance:_play(options) + if options and options.fadeDuration then + self._fadeVolume = 0 + self._fadeSpeed = 1 / options.fadeDuration + else + self._fadeVolume = 1 + end + self._fadeDirection = 1 + self._afterFadingOut = false + self._paused = false + self:_setOptions(options) + self.pitch = options and options.pitch or 1 + if options and options.loop ~= nil then + self.loop = options.loop + end + self._source:seek(options and options.seek or 0) + self._source:play() +end + +function Instance:_update(dt) + -- fade in + if self._fadeDirection == 1 and self._fadeVolume < 1 then + self._fadeVolume = self._fadeVolume + self._fadeSpeed * dt + if self._fadeVolume > 1 then self._fadeVolume = 1 end + self:_onChangeVolume() + -- fade out + elseif self._fadeDirection == -1 and self._fadeVolume > 0 then + self._fadeVolume = self._fadeVolume - self._fadeSpeed * dt + if self._fadeVolume < 0 then + self._fadeVolume = 0 + -- pause or stop after fading out + if self._afterFadingOut == 'pause' then + self:pause() + elseif self._afterFadingOut == 'stop' then + self:stop() + end + end + self:_onChangeVolume() + end +end + +function Instance:setPosition(x, y, z) + self._source:setPosition(x, y, z or 0) +end + +function Instance:isStopped() + return (not self._source:isPlaying()) and (not self._paused) +end + +function Instance:pause(fadeDuration) + if fadeDuration and not self._paused then + self._fadeDirection = -1 + self._fadeSpeed = 1 / fadeDuration + self._afterFadingOut = 'pause' + else + self._source:pause() + self._paused = true + end +end + +function Instance:resume(fadeDuration) + if fadeDuration then + if self._paused then + self._fadeVolume = 0 + self:_onChangeVolume() + end + self._fadeDirection = 1 + self._fadeSpeed = 1 / fadeDuration + end + self._source:play() + self._paused = false +end + +function Instance:stop(fadeDuration) + if fadeDuration and not self._paused then + self._fadeDirection = -1 + self._fadeSpeed = 1 / fadeDuration + self._afterFadingOut = 'stop' + else + self._source:stop() + self._paused = false + end +end + +-- Represents a sound that can be played. +local Sound = {} + +function Sound:__index(key) + if key == 'loop' then + return self._source:isLooping() + elseif Sound[key] then + return Sound[key] + end + return Taggable.__index(self, key) +end + +function Sound:__newindex(key, value) + if key == 'loop' then + self._source:setLooping(value) + for _, instance in ipairs(self._instances) do + instance.loop = value + end + else + Taggable.__newindex(self, key, value) + end +end + +function Sound:_onChangeVolume() + -- tell instances about potential volume changes + for _, instance in ipairs(self._instances) do + instance:_onChangeVolume() + end +end + +function Sound:_onChangeEffects() + -- tell instances about potential effect changes + for _, instance in ipairs(self._instances) do + instance:_onChangeEffects() + end +end + +function Sound:play(options) + -- reuse a stopped instance if one is available + for _, instance in ipairs(self._instances) do + if instance:isStopped() then + instance:_play(options) + return instance + end + end + -- otherwise, create a brand new one + local instance = setmetatable({ + _sound = self, + _source = self._source:clone(), + _effects = {}, + _tags = {}, + _appliedEffects = {}, + }, Instance) + table.insert(self._instances, instance) + instance:_play(options) + return instance +end + +function Sound:pause(fadeDuration) + for _, instance in ipairs(self._instances) do + instance:pause(fadeDuration) + end +end + +function Sound:resume(fadeDuration) + for _, instance in ipairs(self._instances) do + instance:resume(fadeDuration) + end +end + +function Sound:stop(fadeDuration) + for _, instance in ipairs(self._instances) do + instance:stop(fadeDuration) + end +end + +function Sound:update(dt) + for _, instance in ipairs(self._instances) do + instance:_update(dt) + end +end + +function ripple.newSound(source, options) + local sound = setmetatable({ + _source = source, + _effects = {}, + _tags = {}, + _instances = {}, + }, Sound) + sound:_setOptions(options) + if options and options.loop then sound.loop = true end + return sound +end + +return ripple diff --git a/src/lib/vivid.lua b/src/lib/vivid.lua new file mode 100644 index 0000000..67d0ac7 --- /dev/null +++ b/src/lib/vivid.lua @@ -0,0 +1,503 @@ +-- The MIT License (MIT) +-- +-- Copyright (c) 2015 WetDesertRock +-- +-- 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. + +local vivid = {} + +local math_min = math.min +local math_max = math.max +local math_atan = math.atan +local math_pi = math.pi +local math_sqrt = math.sqrt +local math_atan2 = math.atan2 +local math_cos = math.cos +local math_sin = math.sin +local math_rad = math.rad +local math_abs = math.abs + +-- Helper function to mimic the LOVE API, allows you to pass a table or four arguments +local function getColorArgs(first, ...) + if type(first) == "table" then + return unpack(first) + end + return first, ... +end + +--RGB to `colorspace` +function vivid.RGBtoHSL(...) + local r,g,b,a = getColorArgs(...) + + local min = math_min(r,g,b) + local max = math_max(r,g,b) + local delta_max = max-min + + local h,s,l + + l = (max+min)/2 + + if (delta_max == 0) then + h,s = 0,0 + else + if l < 0.5 then + s = delta_max / (max+min) + else + s = delta_max / (2-max-min) + end + + local delta_r = (((max-r)/6) + (delta_max/2)) / delta_max + local delta_g = (((max-g)/6) + (delta_max/2)) / delta_max + local delta_b = (((max-b)/6) + (delta_max/2)) / delta_max + + if r == max then + h = delta_b - delta_g + elseif g == max then + h = (1/3) + delta_r - delta_b + elseif b == max then + h = (2/3) + delta_b - delta_r + end + + if h < 0 then h = h + 1 end + if h > 1 then h = h - 1 end + end + + return h,s,l,a +end + +function vivid.RGBtoHSV(...) + local r,g,b,a = getColorArgs(...) + + local min = math_min(r,g,b) + local max = math_max(r,g,b) + local delta_max = max-min + + local h,s,v + + v = max + + if (delta_max == 0) then + h,s = 0,0 + else + s = delta_max / max + + local delta_r = (((max-r)/6) + (delta_max/2)) / delta_max + local delta_g = (((max-g)/6) + (delta_max/2)) / delta_max + local delta_b = (((max-b)/6) + (delta_max/2)) / delta_max + + if r == max then + h = delta_b - delta_g + elseif g == max then + h = (1/3) + delta_r - delta_b + elseif b == max then + h = (2/3) + delta_b - delta_r + end + + if h < 0 then h = h + 1 end + if h > 1 then h = h - 1 end + end + + return h,s,v,a +end + +function vivid.RGBtoXYZ(...) + --(Observer = 2°, Illuminant = D65) + local r,g,b,a = getColorArgs(...) + + + if r > 0.04045 then + r = ((r+0.055)/1.055)^2.4 + else + r = r/12.92 + end + if g > 0.04045 then + g = ((g+0.055)/1.055)^2.4 + else + g = g/12.92 + end + if b > 0.04045 then + b = ((b+0.055)/1.055)^2.4 + else + b = b/12.92 + end + + r = r*100 + g = g*100 + b = b*100 + + local x = r * 0.4124 + g * 0.3576 + b * 0.1805 + local y = r * 0.2126 + g * 0.7152 + b * 0.0722 + local z = r * 0.0193 + g * 0.1192 + b * 0.9505 + + return x,y,z,a +end + +function vivid.RGBtoLab(...) + return vivid.XYZtoLab(vivid.RGBtoXYZ(...)) +end + +function vivid.RGBtoLCH(...) + return vivid.LabtoLCH(vivid.RGBtoLab(...)) +end + +function vivid.RGBtoLuv(...) + return vivid.XYZtoLuv(vivid.RGBtoXYZ(...)) +end + +--`colorspace` to RGB +function vivid.HSLtoRGB(...) + local h,s,l,a = getColorArgs(...) + h,s,l = h,s,l + local r,g,b + + if s == 0 then + r = l + g = l + b = l + else + local var1,var2 + + if l < 0.5 then + var2 = l*(1+s) + else + var2 = (l+s) - (s*l) + end + + var1 = 2*l-var2 + + local function huetorgb(v1,v2,vh) + if vh < 0 then vh = vh+1 end + if vh > 1 then vh = vh-1 end + if 6*vh < 1 then return v1 + (v2-v1) * 6 * vh end + if 2*vh < 1 then return v2 end + if 3*vh < 2 then return v1 + (v2-v1) * ((2/3)-vh) * 6 end + return v1 + end + + r = huetorgb(var1, var2, h + (1/3)) + g = huetorgb(var1, var2, h) + b = huetorgb(var1, var2, h - (1/3)) + end + + return r,g,b,a +end + +function vivid.HSVtoRGB(...) + local h,s,v,a = getColorArgs(...) + + local r,g,b + + if s == 0 then + r = v + g = v + b = v + else + local varh,vari,var1,var2,var3 + varh = h*6 + if varh == 6 then varh = 0 end + vari = math.floor(varh) + var1 = v*(1-s) + var2 = v*(1-s*(varh-vari)) + var3 = v*(1-s*(1-(varh-vari))) + + if vari == 0 then + r = v + g = var3 + b = var1 + elseif vari == 1 then + r = var2 + g = v + b = var1 + elseif vari == 2 then + r = var1 + g = v + b = var3 + elseif vari == 3 then + r = var1 + g = var2 + b = v + elseif vari == 4 then + r = var3 + g = var1 + b = v + else + r = v + g = var1 + b = var2 + end + end + + return r,g,b,a +end + +function vivid.XYZtoRGB(...) + --(Observer = 2°, Illuminant = D65) + local x,y,z,a = getColorArgs(...) + x,y,z = x/100,y/100,z/100 + local r,g,b + + r = x * 3.2406 + y * -1.5372 + z * -0.4986 + g = x * -0.9689 + y * 1.8758 + z * 0.0415 + b = x * 0.0557 + y * -0.2040 + z * 1.0570 + + if r > 0.0031308 then + r = 1.055*(r^(1/2.4))-0.055 + else + r = r*12.92 + end + if g > 0.0031308 then + g = 1.055*(g^(1/2.4))-0.055 + else + g = g*12.92 + end + if b > 0.0031308 then + b = 1.055*(b^(1/2.4))-0.055 + else + b = b*12.92 + end + + return r,g,b,a +end + +function vivid.LabtoRGB(...) + return vivid.XYZtoRGB(vivid.LabtoXYZ(...)) +end + +function vivid.LCHtoRGB(...) + return vivid.LabtoRGB(vivid.LCHtoLab(...)) +end + +function vivid.LuvtoRGB(...) + return vivid.XYZtoRGB(vivid.LuvtoXYZ(...)) +end + +--Other conversions +local refx,refy,refz = 95.047,100.000,108.883 +local refu = (4 * refx) / (refx + (15 * refy) + (3 * refz)) +local refv = (9 * refy) / (refx + (15 * refy) + (3 * refz)) + +function vivid.XYZtoLab(...) + local x,y,z,alpha = getColorArgs(...) + local L,a,b + x,y,z = x/refx,y/refy,z/refz + if x > 0.008856 then + x = x^(1/3) + else + x = (7.787*x) + (16/116) + end + if y > 0.008856 then + y = y^(1/3) + else + y = (7.787*y) + (16/116) + end + if z > 0.008856 then + z = z^(1/3) + else + z = (7.787*z) + (16/116) + end + + L = (116*y) - 16 + a = 500*(x-y) + b = 200*(y-z) + return L,a,b,alpha +end + +function vivid.LabtoXYZ(...) + local L,a,b,alpha = getColorArgs(...) + + local y = (L+16) / 116 + local x = a / 500 + y + local z = y - b / 200 + if x^3 > 0.008856 then + x = x^3 + else + x = (x - 16 / 116) / 7.787 + end + if y^3 > 0.008856 then + y = y^3 + else + y = (y - 16 / 116) / 7.787 + end + if z^3 > 0.008856 then + z = z^3 + else + z = (z - 16 / 116) / 7.787 + end + + return refx*x,refy*y,refz*z,alpha +end + +function vivid.LabtoLCH(...) + local L,a,b,alpha = getColorArgs(...) + local C,H + H = math_atan2(b,a) + + if H > 0 then + H = (H / math_pi) * 180 + else + H = 360 - ( math_abs(H) / math_pi) * 180 + end + + C = math_sqrt(a ^ 2 + b ^ 2) + + return L,C,H +end + +function vivid.LCHtoLab(...) + local L,C,H,alpha = getColorArgs(...) + + return L,math_cos(math_rad(H))*C,math_sin(math_rad(H))*C +end + +function vivid.XYZtoLuv(...) + local x,y,z,alpha = getColorArgs(...) + local L,u,v + u = (4 * x) / (x + (15 * y) + (3 * z)) + v = (9 * y) / (x + (15 * y) + (3 * z)) + y = y/100 + + if y > 0.008856 then + y = y ^ (1/3) + else + y = (7.787 * y) + (16 / 116) + end + + L = (116 * y) - 16 + u = 13 * L * (u - refu) + v = 13 * L * (v - refv) + + return L,u,v +end + +function vivid.LuvtoXYZ(...) + local L,u,v,alpha = getColorArgs(...) + local x,y,z + + y = (L + 16) / 116 + if y^3 > 0.008856 then + y = y^3 + else + y = (y - 16 / 116) / 7.787 + end + + u = u / (13 * L) + refu + v = v / (13 * L) + refv + + y = y*100 + x = -(9 * y * u) / ((u - 4 ) * v - u * v) + z = (9 * y - (15 * v * y) - (v * x)) / (3 * v) + + return x,y,z +end + +--Manipulations: +function vivid.lighten(amount, ...) + local h,s,l,a = vivid.RGBtoHSL(getColorArgs(...)) + return vivid.HSLtoRGB(h,s,l+amount,a) +end +function vivid.darken(amount, ...) + local h,s,l,a = vivid.RGBtoHSL(getColorArgs(...)) + return vivid.HSLtoRGB(h,s,l-amount,a) +end +function vivid.saturate(amount, ...) + local h,s,v,a = vivid.RGBtoHSV(getColorArgs(...)) + return vivid.HSVtoRGB(h,s+amount,v,a) +end +function vivid.desaturate(amount, ...) + local h,s,v,a = vivid.RGBtoHSV(getColorArgs(...)) + return vivid.HSVtoRGB(h,s-amount,v,a) +end +function vivid.hue(hue, ...) + local h,s,l,a = vivid.RGBtoHSL(getColorArgs(...)) + return vivid.HSLtoRGB(hue,s,l,a) +end +function vivid.invert(...) + local r,g,b,a = getColorArgs(...) + return 1-r, 1-g, 1-b, a +end +function vivid.invertHue(...) + local h,s,l,a = vivid.RGBtoHSL(getColorArgs(...)) + return vivid.HSLtoRGB(1-h,s,l,a) +end + +--Spread +function vivid.HSLSpread(count,hoffset,s,l,a) + local incval = 1/count + local colors = {} + + for i=0,count-1 do + table.insert(colors,{vivid.HSLtoRGB((i*incval+hoffset)%1,s,l,a)}) + end + + return colors +end +function vivid.HSVSpread(count,hoffset,s,v,a) + local incval = 1/count + local colors = {} + + for i=0,count-1 do + table.insert(colors,{vivid.HSVtoRGB((i*incval+hoffset)%1,s,v,a)}) + end + + return colors +end +function vivid.LCHSpread(count,l,c,hoffset,a) + local incval = 360/count + local colors = {} + + for i=0,count-1 do + table.insert(colors,{vivid.LCHtoRGB(l,c,(i*incval+hoffset)%360,a)}) + end + + return colors +end + +--Wrap functions: +function vivid.wrapHSV(fn) + return function(...) + return fn(vivid.RGBtoHSV(...)) + end +end +function vivid.wrapHSL(fn) + return function(...) + return fn(vivid.RGBtoHSL(...)) + end +end +function vivid.wrapXYZ(fn) + return function(...) + return fn(vivid.RGBtoXYZ(...)) + end +end +function vivid.wrapLab(fn) + return function(...) + return fn(vivid.RGBtoLab(...)) + end +end +function vivid.wrapLCH(fn) + return function(...) + return fn(vivid.RGBtoLCH(...)) + end +end +function vivid.wrapLuv(fn) + return function(...) + return fn(vivid.RGBtoLuv(...)) + end +end + +return vivid diff --git a/src/main.lua b/src/main.lua new file mode 100644 index 0000000..5f42abb --- /dev/null +++ b/src/main.lua @@ -0,0 +1,9 @@ +local Gamestate = require("lib.hump.gamestate") + +function love.load() + love.math.setRandomSeed(love.timer.getTime()) + math.randomseed(love.timer.getTime()) + + Gamestate.switch(require("states.main")) + Gamestate.registerEvents() +end diff --git a/src/states/main.lua b/src/states/main.lua new file mode 100644 index 0000000..e7e9c0f --- /dev/null +++ b/src/states/main.lua @@ -0,0 +1,7 @@ +local MainState = {} + +function MainState:draw() + love.graphics.rectangle("fill", 20, 20, 100, 100) +end + +return MainState