diff --git a/asset.lua b/asset.lua deleted file mode 100644 index 2610052..0000000 --- a/asset.lua +++ /dev/null @@ -1,91 +0,0 @@ --- ubiquitousse.asset - --- The asset cache. Each cached asset is indexed with a string key "type.assetName". -local cache = setmetatable({}, { __mode = "v" }) -- weak values - ---- Asset manager. Loads asset and cache them. --- This file has no dependicy to either ubiquitousse or a ubiquitousse backend. --- This only provides a streamlined way to handle asset, and doesn't handle the actual file loading/object creation itself; you are expected to provide your own asset loaders. --- See asset.load for more details. Hopefully this will allow you to use asset which are more game-specific than "image" or "audio". -local asset -asset = setmetatable({ - --- A prefix for asset names - -- @impl ubiquitousse - prefix = "", - - --- Load (and cache) an asset. - -- Asset name are similar to Lua module names (directory separator is the dot . and no extention should be specified). - -- To load an asset, ubiquitousse will, in this order: - -- * try to load the directory loader: a file named loader.lua in the same directory as the asset we are trying to load - -- * try to load the asset-specific loader: a file in the same directory and with the same name (except with the .lua extension) as the asset we are trying to load - -- Loaders should return either: - -- * the new asset - -- * nil, message if there was an error loading the asset - -- These loaders have acces to the following variables: - -- * directory: the asset directory (including prefix) - -- * name: the asset name (directory information removed) - -- * asset: the asset data. May be nil if this is the first loader to run. - -- @tparam assetName string the asset's full name - -- @return the asset - -- @impl ubiquitousse - load = function(assetName) - if not cache[assetName] then - -- Get directory and name - local path, name = assetName:match("^([^.]+)%.(.+)$") - if not path then - path, name = "", assetName - end - local dir = (asset.prefix..path):gsub("%.", "/") - - -- Setup env - local oName, oAsset, oDirectory = _G.name, _G.asset, _G.directory - _G.name, _G.asset, _G.directory = name, nil, dir - - -- Load - local err = ("couldn't load asset %q:"):format(assetName) - - -- Asset directory loader - local f = io.open(dir.."/loader.lua") - if f then - f:close() - local r, msg = dofile(dir.."/loader.lua") - if r ~= nil then - _G.asset = r - else - err = err .. ("\n\t* directory loader %q failed to load the asset: %s"):format(dir.."/loader.lua", msg) - end - else - err = err .. ("\n\t* no directory loader %q found"):format(dir.."/loader.lua") - end - - -- Asset specific loader - local f = io.open(dir.."/"..name..".lua") - if f then - f:close() - local r, msg = dofile(dir.."/"..name..".lua") - if r ~= nil then - _G.asset = r - else - err = err .. ("\n\t* asset specific loader %q failed to load the asset: %s"):format(dir.."/"..name..".lua", msg) - end - else - err = err .. ("\n\t* no asset specific loader %q found"):format(dir.."/"..name..".lua") - end - - -- Done - cache[assetName] = assert(_G.asset, err) - - -- Restore env - _G.name, _G.asset, _G.directory = oName, oAsset, oDirectory - end - - return cache[assetName] - end, -}, { - --- asset(...) is a shortcut for asset.load(...) - __call = function(self, ...) - return asset.load(...) - end -}) - -return asset diff --git a/asset/asset.lua b/asset/asset.lua new file mode 100644 index 0000000..2bc6163 --- /dev/null +++ b/asset/asset.lua @@ -0,0 +1,86 @@ +--- ubiquitousse.asset +-- No dependencies. + +--- Asset manager. Loads asset and cache them. +-- This file has no dependency to either ubiquitousse or a ubiquitousse backend. +-- This only provides a streamlined way to handle asset, and doesn't handle the actual file loading/object creation itself; you are expected to provide your own asset loaders. +-- See the __call method for more details on how assets are loaded. Hopefully this will allow you to use asset which are more game-specific than "image" or "audio". +local asset_mt = { + --- Load (and cache) an asset. + -- Asset name are similar to Lua module names (directory separator is the dot . and no extention should be specified). + -- To load an asset, ubiquitousse will try every loaders in the loader list with a name that prefix the asset name. + -- The first value returned will be used as the asset value. + -- Loaders are called with the arguments: + -- * path: the asset full path, except extension + -- * ...: other arguments given after the asset name. Can only be number and strings. + -- @tparam assetName string the asset's full name + -- @tparam ... number/string other arguments for the asset loader + -- @return the asset + -- @impl ubiquitousse + __call = function(self, assetName, ...) + local cache = self.cache + local hash = table.concat({assetName, ...}, ".") + + if not cache[hash] then + for prefix, fn in pairs(self.loaders) do + if assetName:match("^"..prefix) then + cache[hash] = fn((self.prefix..assetName):gsub("%.", "/"), ...) + if cache[hash] then + break + end + end + end + assert(cache[hash], ("couldn't load asset %q"):format(assetName)) + end + + return cache[hash] + end, + + --- Preload a list of assets. + -- @impl ubiquitousse + load = function(self, list) + for _, asset in ipairs(list) do + self(asset) + end + end, + + --- Allow loaded assets to be garbage collected. + -- Only useful if the caching mode is set to "manual" duritng creation. + -- @impl ubiquitousse + clear = function(self) + self.cache = {} + end +} +asset_mt.__index = asset_mt + +local asset = { + --- Create a new asset manager. + -- If the caching "mode" is set to auto (default), the asset manager will allow assets to be automaticaly garbage collected by Lua. + -- If set to "manual", the assets will not be garbage collected unless the clear method is called. + -- "manual" mode is useful if you have assets that are particularly slow to load and you want full control on when they are loaded and unloaded (typically a loading screen). + -- @tparam directory string the directory in which the assets will be loaded + -- @tparam loaders table loaders table: {prefix = function, ...} + -- @tparam mode string[opt="auto"] caching mode + -- @impl ubiquitousse + new = function(dir, loaders, mode) + local cache = {} + if mode == nil or mode == "auto" then + setmetatable(cache, { __mode = "v" }) + end + return setmetatable({ + --- A prefix for asset names + -- @impl ubiquitousse + prefix = dir..".", + + --- The asset cache. Each cached asset is indexed with a string key "type.assetName". + -- @impl ubiquitousse + cache = cache, + + --- The loaders table. + -- @impl ubiquitousse + loaders = loaders + }, asset_mt) + end +} + +return asset diff --git a/asset/init.lua b/asset/init.lua new file mode 100644 index 0000000..743aabd --- /dev/null +++ b/asset/init.lua @@ -0,0 +1 @@ +return require((...)..".asset") \ No newline at end of file diff --git a/audio.lua b/audio.lua deleted file mode 100644 index 439b82b..0000000 --- a/audio.lua +++ /dev/null @@ -1,12 +0,0 @@ --- ubiquitousse.audio - ---- Audio functions. -return { - --- Loads an audio file and returns the corresponding audio object. - -- TODO: audio object doc & API - -- @impl backend - load = function(filepath) end, - - -- TODO: doc - available = false -} diff --git a/backend/ctrulua.lua b/backend/ctrulua.lua index 3700043..3ca8e6b 100644 --- a/backend/ctrulua.lua +++ b/backend/ctrulua.lua @@ -1,334 +1,19 @@ ---- ctrµLua backend 0.0.1 for Ubiquitousse. --- Provides a partial Ubiquitousse API. Still a lot to implement. --- Made for some ctrµLua version and Ubiquitousse 0.0.1. --- See `ubiquitousse` for Ubiquitousse API. - --- General -local version = "0.0.1" - --- Require stuff local uqt = require((...):match("^(.-ubiquitousse)%.")) local ctr = require("ctr") local gfx = require("ctr.gfx") -local hid = require("ctr.hid") --- Version compatibility warning -do - local function checkCompat(stuffName, expectedVersion, actualVersion) - if actualVersion ~= expectedVersion then - local txt = ("Ubiquitousse ctrµLua backend version "..version.." was made for %s %s but %s is used!\nThings may not work as expected.") - :format(stuffName, expectedVersion, actualVersion) - print(txt) - for _=0,300 do - gfx.start(gfx.TOP) - gfx.wrappedText(0, 0, txt, gfx.TOP_WIDTH) - gfx.stop() - gfx.render() - end - end - end - checkCompat("ctrµLua", "v1.0", ctr.version) -- not really a version, just get the latest build - checkCompat("Ubiquitousse", "0.0.1", uqt.version) -end - --- Redefine all functions in tbl which also are in toAdd, so when used they call the old function (in tbl) and then the new (in toAdd). --- Functions with names prefixed by a exclamation mark will overwrite the old function. -local function add(tbl, toAdd) - for k,v in pairs(toAdd) do - local old = tbl[k] - if k:sub(1,1) == "!" then - tbl[k] = v - else - tbl[k] = function(...) - old(...) - return v(...) - end +local function checkCompat(stuffName, expectedVersion, actualVersion) + if actualVersion ~= expectedVersion then + local txt = ("Ubiquitousse ctrµLua backend was made for %s %s but %s is used!\nThings may not work as expected.") + :format(stuffName, expectedVersion, actualVersion) + print(txt) + for _=0,300 do + gfx.start(gfx.TOP) + gfx.wrappedText(0, 0, txt, gfx.TOP_WIDTH) + gfx.stop() + gfx.render() end end end - --- uqt -uqt.backend = "ctrulua" - --- uqt.event: TODO -if uqt.event then - error("uqt.event: NYI") -end - --- uqt.draw: TODO -if uqt.draw then - error("uqt.draw: NYI") -end - --- uqt.audio: TODO -if uqt.audio then - error("uqt.audio: NYI") -end - --- uqt.time -if uqt.time then -add(uqt.time, { - get = ctr.time -}) -end - --- uqt.input -if uqt.input then -local keys = {} -local touchX, touchY, dTouchX, dTouchY -add(uqt.input, { - update = function() - hid.read() - - keys = hid.keys() - - local nTouchX, nTouchY = hid.touch() - dTouchX, dTouchY = nTouchX - touchX, nTouchY - touchY - touchX, touchY = nTouchX, nTouchY - end, - - buttonDetector = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Keys - if id:match("^key%.") then - local key = id:match("^key%.(.+)$") - table.insert(ret, function() - return keys.held[key] - end) - else - error("Unknown button identifier: "..id) - end - end - return table.unpack(ret) - end, - - axisDetector = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Binary axis - if id:match(".+%,.+") then - local d1, d2 = uqt.input.buttonDetector(id:match("^(.+)%,(.+)$")) - table.insert(ret, function() - local b1, b2 = d1(), d2() - if b1 and b2 then return 0 - elseif b1 then return -1 - elseif b2 then return 1 - else return 0 end - end) - -- Touch movement - elseif id:match("^touch%.move%.") then - local axis, threshold = id:match("^touch%.move%.(.+)%%(.+)$") - if not axis then axis = id:match("^touch%.move%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local val, raw, max - if axis == "x" then - raw, max = dTouchX, gfx.BOTTOM_WIDTH - elseif axis == "y" then - raw, max = dTouchY, gfx.BOTTOM_HEIGHT - end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- Touch position - elseif id:match("^touch%.position%.") then - local axis, threshold = id:match("^touch%.position%.(.+)%%(.+)$") - if not axis then axis = id:match("^touch%.position%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local val, raw, max - if axis == "x" then - max = gfx.BOTTOM_WIDTH / 2 -- /2 because x=0,y=0 is the center of the screen (an axis value is in [-1,1]) - raw = touchX - max - elseif axis == "y" then - max = gfx.BOTTOM_HEIGHT / 2 - raw = touchY - max - end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- Circle pad axis - elseif id:match("^circle%.") then - local axis, threshold = id:match("^circle%.(.+)%%(.+)$") - if not axis then axis = id:match("^circle%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local x, y = hid.circle() - local val, raw, max = 0, 0, 156 - if axis == "x" then raw = x - elseif axis == "y" then raw = y end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- C-Stick axis - elseif id:match("^cstick%.") then - local axis, threshold = id:match("^cstick%.(.+)%%(.+)$") - if not axis then axis = id:match("^cstick%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local x, y = hid.cstick() - local val, raw, max = 0, 0, 146 - if axis == "x" then raw = x - elseif axis == "y" then raw = y end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- Accelerometer axis - elseif id:match("^accel%.") then - local axis, threshold = id:match("^accel%.(.+)%%(.+)$") - if not axis then axis = id:match("^accel%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local x, y, z = hid.accel() - local val, raw, max = 0, 0, 32768 -- no idea actually, but it's a s16 - if axis == "x" then raw = x - elseif axis == "y" then raw = y - elseif axis == "z" then raw = z end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- Gyroscope axis - elseif id:match("^gyro%.") then - local axis, threshold = id:match("^gyro%.(.+)%%(.+)$") - if not axis then axis = id:match("^gyro%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local roll, pitch, yaw = hid.gyro() - local val, raw, max = 0, 0, 32768 -- no idea actually, but it's a s16 - if axis == "roll" then raw = roll - elseif axis == "pitch" then raw = pitch - elseif axis == "yaw" then raw = yaw end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - else - error("Unknown axis identifier: "..id) - end - end - return table.unpack(ret) - end, - - buttonsInUse = function(threshold) - local r = {} - for key, held in pairs(keys.held) do - if held then table.insert(r, "key."..key) end - end - return r - end, - - axesInUse = function(threshold) - local r = {} - threshold = threshold or 0.5 - - if math.abs(touchX) / gfx.BOTTOM_WIDTH > threshold then table.insert(r, "touch.position.x%"..threshold) end - if math.abs(touchY) / gfx.BOTTOM_HEIGHT > threshold then table.insert(r, "touch.position.y%"..threshold) end - - if math.abs(dTouchX) / gfx.BOTTOM_WIDTH > threshold then table.insert(r, "touch.move.x%"..threshold) end - if math.abs(dTouchY) / gfx.BOTTOM_HEIGHT > threshold then table.insert(r, "touch.move.y%"..threshold) end - - local circleX, circleY = hid.circle() - if math.abs(circleX) / 156 > threshold then table.insert(r, "circle.x%"..threshold) end - if math.abs(circleY) / 156 > threshold then table.insert(r, "circle.y%"..threshold) end - - if ctr.apt.isNew3DS() then - local cstickX, cstickY = hid.cstick() - if math.abs(cstickY) / 146 > threshold then table.insert(r, "cstick.y%"..threshold) end - if math.abs(cstickX) / 146 > threshold then table.insert(r, "cstick.x%"..threshold) end - end - - local accelX, accelY, accelZ = hid.accel() - if math.abs(accelX) / 32768 > threshold then table.insert(r, "accel.x%"..threshold) end - if math.abs(accelY) / 32768 > threshold then table.insert(r, "accel.y%"..threshold) end - if math.abs(accelZ) / 32768 > threshold then table.insert(r, "accel.z%"..threshold) end - - -- no gyro, because it is always in use - - return r - end, - - buttonName = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Key - if id:match("^key%.") then - local key = id:match("^key%.(.+)$") - table.insert(ret, key:sub(1,1):upper()..key:sub(2).." key") - else - table.insert(ret, id) - end - end - return table.unpack(ret) - end, - - axisName = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Binary axis - if id:match(".+%,.+") then - local b1, b2 = uqt.input.buttonName(id:match("^(.+)%,(.+)$")) - table.insert(ret, b1.." / "..b2) - -- Touch movement - elseif id:match("^touch%.move%.") then - local axis, threshold = id:match("^touch%.move%.(.+)%%(.+)$") - if not axis then axis = id:match("^touch%.move%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, ("Touch %s movement (threshold %s%%)"):format(axis, math.abs(threshold*100))) - -- Touch position - elseif id:match("^touch%.position%.") then - local axis, threshold = id:match("^touch%.position%.(.+)%%(.+)$") - if not axis then axis = id:match("^touch%.position%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, ("Touch %s position (threshold %s%%)"):format(axis, math.abs(threshold*100))) - -- Circle pad axis - elseif id:match("^circle%.") then - local axis, threshold = id:match("^circle%.(.+)%%(.+)$") - if not axis then axis = id:match("^circle%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - if axis == "x" then - table.insert(ret, ("Circle pad horizontal axis (deadzone %s%%)"):format(math.abs(threshold*100))) - elseif axis == "y" then - table.insert(ret, ("Circle pad vertical axis (deadzone %s%%)"):format(math.abs(threshold*100))) - else - table.insert(ret, ("Circle pad %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) - end - -- C-Stick axis - elseif id:match("^cstick%.") then - local axis, threshold = id:match("^cstick%.(.+)%%(.+)$") - if not axis then axis = id:match("^cstick%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - if axis == "x" then - table.insert(ret, ("C-Stick horizontal axis (deadzone %s%%)"):format(math.abs(threshold*100))) - elseif axis == "y" then - table.insert(ret, ("C-Stick vertical axis (deadzone %s%%)"):format(math.abs(threshold*100))) - else - table.insert(ret, ("C-Stick %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) - end - -- Accelerometer axis - elseif id:match("^accel%.") then - local axis, threshold = id:match("^accel%.(.+)%%(.+)$") - if not axis then axis = id:match("^accel%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, ("Accelerometer %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) - -- Gyroscope axis - elseif id:match("^gyro%.") then - local axis, threshold = id:match("^gyro%.(.+)%%(.+)$") - if not axis then axis = id:match("^gyro%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, ("Gyroscope %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) - else - table.insert(ret, id) - end - end - return table.unpack(ret) - end -}) - --- Defaults -uqt.input.default.pointer:bind( - { "absolute", "key.left,key.right", "key.up,key.down" }, - { "absolute", "circle.x", "circle.y" } -) -uqt.input.default.confirm:bind("key.a") -uqt.input.default.cancel:bind("key.b") -end +checkCompat("ctrµLua", "v1.0", ctr.version) -- not really a version, just get the latest build +checkCompat("Ubiquitousse", "0.0.1", uqt.version) diff --git a/backend/love.lua b/backend/love.lua index 47949cf..8754772 100644 --- a/backend/love.lua +++ b/backend/love.lua @@ -1,518 +1,11 @@ ---- Löve backend 0.0.1 for Ubiquitousse. --- Provides all the Ubiquitousse API on a Löve environment. --- Made for Löve 11.1.0 and Ubiquitousse 0.0.1. --- See `ubiquitousse` for Ubiquitousse API. - --- Config -local useScancodes = true -- Use ScanCodes (layout independant input) instead of KeyConstants (layout dependant) for keyboard input -local displayKeyConstant = true -- If using ScanCodes, sets this to true so the backend returns the layout-dependant KeyConstant - -- instead of the raw ScanCode when getting the display name. If set to false and using ScanCodes, - -- the user will see keys that don't match what's actually written on his keyboard, which is confusing. - --- General -local version = "0.0.1" - --- Require stuff local uqt = require((...):match("^(.-ubiquitousse)%.")) -local m = uqt.module --- Version compatibility warning -do - local function checkCompat(stuffName, expectedVersion, actualVersion) - if actualVersion ~= expectedVersion then - local txt = ("Ubiquitousse Löve backend version "..version.." was made for %s %s but %s is used!\nThings may not work as expected.") - :format(stuffName, expectedVersion, actualVersion) - print(txt) - love.window.showMessageBox("Warning", txt, "warning") - end - end - checkCompat("Löve", "11.1.0", ("%s.%s.%s"):format(love.getVersion())) - checkCompat("Ubiquitousse", "0.0.1", uqt.version) -end - --- Redefine all functions in tbl which also are in toAdd, so when used they call the old function (in tbl) and then the new (in toAdd). --- Functions with names prefixed by a exclamation mark will overwrite the old function. -local function add(tbl, toAdd) - for k,v in pairs(toAdd) do - local old = tbl[k] - if k:sub(1,1) == "!" then - tbl[k] = v - else - tbl[k] = function(...) - old(...) - return v(...) - end - end +local function checkCompat(stuffName, expectedVersion, actualVersion) + if actualVersion ~= expectedVersion then + local txt = ("Ubiquitousse Löve backend was made for %s %s but %s is used!\nThings may not work as expected.") + :format(stuffName, expectedVersion, actualVersion) + print(txt) end end - --- uqt -uqt.backend = "love" - --- uqt.event -if m.event then -local updateDefault = uqt.event.update -uqt.event.update = function() end -function love.update(dt) - -- Stuff defined in ubiquitousse.lua - updateDefault(dt*1000) - - -- Callback - uqt.event.update(dt) -end - -local drawDefault = uqt.event.draw -uqt.event.draw = function() end -function love.draw() - if m.draw then - love.graphics.push() - - -- Resize type - local winW, winH = love.graphics.getWidth(), love.graphics.getHeight() - local gameW, gameH = uqt.draw.params.width, uqt.draw.params.height - if uqt.draw.params.resizeType == "auto" then - love.graphics.scale(winW/gameW, winH/gameH) - elseif uqt.draw.params.resizeType == "center" then - love.graphics.translate(math.floor(winW/2-gameW/2), math.floor(winH/2-gameH/2)) - end - end - - -- Stuff defined in ubiquitousse.lua - drawDefault() - - -- Callback - uqt.event.draw() - - if m.draw then - love.graphics.pop() - end -end -end - --- uqt.draw -if m.draw then -local defaultFont = love.graphics.getFont() -add(uqt.draw, { - init = function(params) - local p = uqt.draw.params - love.window.setTitle(p.title) - love.window.setMode(p.width, p.height, { - resizable = p.resizable - }) - end, - fps = function() - return love.timer.getFPS() - end, - color = function(r, g, b, a) - love.graphics.setColor(r, g, b, a) - end, - text = function(x, y, text) - love.graphics.setFont(defaultFont) - love.graphics.print(text, x, y) - end, - point = function(x, y, ...) - love.graphics.points(x, y, ...) - end, - lineWidth = function(width) - love.graphics.setLineWidth(width) - end, - line = function(x1, y1, x2, y2, ...) - love.graphics.line(x1, y1, x2, y2, ...) - end, - polygon = function(...) - love.graphics.polygon("fill", ...) - end, - linedPolygon = function(...) - love.graphics.polygon("line", ...) - end, - ["!rectangle"] = function(x, y, width, height) - love.graphics.rectangle("fill", x, y, width, height) - end, - ["!linedRectangle"] = function(x, y, width, height) - love.graphics.rectangle("line", x, y, width, height) - end, - circle = function(x, y, radius) - love.graphics.circle("fill", x, y, radius) - end, - linedCircle = function(x, y, radius) - love.graphics.circle("line", x, y, radius) - end, - scissor = function(x, y, width, height) - love.graphics.setScissor(x, y, width, height) - end, - -- TODO: cf draw.lua - image = function(filename) - local img = love.graphics.newImage(filename) - return { - width = img:getWidth(), - height = img:getHeight(), - draw = function(self, x, y, r, sx, sy, ox, oy) - love.graphics.draw(img, x, y, r, sx, sy, ox, oy) - end - } - end, - font = function(filename, size) - local fnt = love.graphics.newFont(filename, size) - return { - width = function(self, text) - return fnt:getWidth(text) - end, - draw = function(self, text, x, y, r, sx, sy, ox, oy) - love.graphics.setFont(fnt) - love.graphics.print(text, x, y, r, sx, sy, ox, oy) - end - } - end, -}) -function love.resize(width, height) - if uqt.draw.params.resizeType == "none" then - uqt.draw.width = width - uqt.draw.height = height - end -end -elseif m.input then -- fields required by uqt.input - uqt.draw = { - width = love.graphics.getWidth(), - height = love.graphics.getHeight() - } -end - --- uqt.audio -if m.audio then -add(uqt.audio, { - -- TODO: cf audio.lua - load = function(filepath) - local audio = love.audio.newSource(filepath) - return { - play = function(self) - audio:play() - end - } - end -}) -end - --- uqt.time -if m.time then -add(uqt.time, { - get = function() - return love.timer.getTime() * 1000 - end -}) -end - --- uqt.input -if m.input then -local buttonsInUse = {} -local axesInUse = {} -function love.keypressed(key, scancode, isrepeat) - if useScancodes then key = scancode end - buttonsInUse["keyboard."..key] = true -end -function love.keyreleased(key, scancode) - if useScancodes then key = scancode end - buttonsInUse["keyboard."..key] = nil -end -function love.mousepressed(x, y, button, istouch) - buttonsInUse["mouse."..button] = true -end -function love.mousereleased(x, y, button, istouch) - buttonsInUse["mouse."..button] = nil -end -function love.wheelmoved(x, y) - if y > 0 then - buttonsInUse["mouse.wheel.up"] = true - elseif y < 0 then - buttonsInUse["mouse.wheel.down"] = true - end - if x > 0 then - buttonsInUse["mouse.wheel.right"] = true - elseif x < 0 then - buttonsInUse["mouse.wheel.left"] = true - end -end -function love.mousemoved(x, y, dx, dy) - if dx ~= 0 then axesInUse["mouse.move.x"] = dx/love.graphics.getWidth() end - if dy ~= 0 then axesInUse["mouse.move.y"] = dy/love.graphics.getHeight() end -end -function love.gamepadpressed(joystick, button) - buttonsInUse["gamepad.button."..joystick:getID().."."..button] = true -end -function love.gamepadreleased(joystick, button) - buttonsInUse["gamepad.button."..joystick:getID().."."..button] = nil -end -function love.gamepadaxis(joystick, axis, value) - if value ~= 0 then - axesInUse["gamepad.axis."..joystick:getID().."."..axis] = value - else - axesInUse["gamepad.axis."..joystick:getID().."."..axis] = nil - end -end - -love.mouse.setVisible(false) - -add(uqt.input, { - -- love.wheelmoved doesn't trigger when the wheel stop moving, so we need to clear up our stuff at each update - update = function() - buttonsInUse["mouse.wheel.up"] = nil - buttonsInUse["mouse.wheel.down"] = nil - buttonsInUse["mouse.wheel.right"] = nil - buttonsInUse["mouse.wheel.left"] = nil - -- Same for mouse axis - axesInUse["mouse.move.x"] = nil - axesInUse["mouse.move.y"] = nil - end, - - buttonDetector = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Keyboard - if id:match("^keyboard%.") then - local key = id:match("^keyboard%.(.+)$") - table.insert(ret, function() - return useScancodes and love.keyboard.isScancodeDown(key) or love.keyboard.isDown(key) - end) - -- Mouse wheel - elseif id:match("^mouse%.wheel%.") then - local key = id:match("^mouse%.wheel%.(.+)$") - table.insert(ret, function() - return buttonsInUse["mouse.wheel."..key] - end) - -- Mouse - elseif id:match("^mouse%.") then - local key = id:match("^mouse%.(.+)$") - table.insert(ret, function() - return love.mouse.isDown(key) - end) - -- Gamepad button - elseif id:match("^gamepad%.button%.") then - local gid, key = id:match("^gamepad%.button%.(.+)%.(.+)$") - gid = tonumber(gid) - table.insert(ret, function() - local gamepad - for _,j in ipairs(love.joystick.getJoysticks()) do - if j:getID() == gid then gamepad = j end - end - return gamepad and gamepad:isGamepadDown(key) - end) - -- Gamepad axis - elseif id:match("^gamepad%.axis%.") then - local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") - if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) - gid = tonumber(gid) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local gamepad - for _,j in ipairs(love.joystick.getJoysticks()) do - if j:getID() == gid then gamepad = j end - end - if not gamepad then - return false - else - local val = gamepad:getGamepadAxis(axis) - return (math.abs(val) > math.abs(threshold)) and ((val < 0) == (threshold < 0)) - end - end) - else - error("Unknown button identifier: "..id) - end - end - return unpack(ret) - end, - - axisDetector = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Binary axis - if id:match(".+%,.+") then - local d1, d2 = uqt.input.buttonDetector(id:match("^(.+)%,(.+)$")) - table.insert(ret, function() - local b1, b2 = d1(), d2() - if b1 and b2 then return 0 - elseif b1 then return -1 - elseif b2 then return 1 - else return 0 end - end) - -- Mouse movement - elseif id:match("^mouse%.move%.") then - local axis, threshold = id:match("^mouse%.move%.(.+)%%(.+)$") - if not axis then axis = id:match("^mouse%.move%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local val, raw, max = axesInUse["mouse.move."..axis] or 0, 0, 1 - if axis == "x" then - raw, max = val * love.graphics.getWidth(), love.graphics.getWidth() - elseif axis == "y" then - raw, max = val * love.graphics.getHeight(), love.graphics.getHeight() - end - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- Mouse position - elseif id:match("^mouse%.position%.") then - local axis, threshold = id:match("^mouse%.position%.(.+)%%(.+)$") - if not axis then axis = id:match("^mouse%.position%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local val, raw, max = 0, 0, 1 - if axis == "x" then - max = love.graphics.getWidth() / 2 -- /2 because x=0,y=0 is the center of the screen (an axis value is in [-1,1]) - raw = love.mouse.getX() - max - elseif axis == "y" then - max = love.graphics.getHeight() / 2 - raw = love.mouse.getY() - max - end - val = raw / max - return math.abs(val) > math.abs(threshold) and val or 0, raw, max - end) - -- Gamepad axis - elseif id:match("^gamepad%.axis%.") then - local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") - if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) - gid = tonumber(gid) - threshold = tonumber(threshold) or 0 - table.insert(ret, function() - local gamepad - for _,j in ipairs(love.joystick.getJoysticks()) do - if j:getID() == gid then gamepad = j end - end - if not gamepad then - return 0 - else - local val = gamepad:getGamepadAxis(axis) - return math.abs(val) > math.abs(threshold) and val or 0 - end - end) - else - error("Unknown axis identifier: "..id) - end - end - return unpack(ret) - end, - - buttonsInUse = function(threshold) - local r = {} - threshold = threshold or 0.5 - for b in pairs(buttonsInUse) do - table.insert(r, b) - end - for b,v in pairs(axesInUse) do - if math.abs(v) > threshold then - table.insert(r, b.."%"..(v < 0 and -threshold or threshold)) - end - end - return r - end, - - axesInUse = function(threshold) - local r = {} - threshold = threshold or 0.5 - for b,v in pairs(axesInUse) do - if math.abs(v) > threshold then - table.insert(r, b.."%"..threshold) - end - end - return r - end, - - buttonName = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Keyboard - if id:match("^keyboard%.") then - local key = id:match("^keyboard%.(.+)$") - if useScancodes and displayKeyConstant then key = love.keyboard.getKeyFromScancode(key) end - table.insert(ret, key:sub(1,1):upper()..key:sub(2).." key") - -- Mouse wheel - elseif id:match("^mouse%.wheel%.") then - local key = id:match("^mouse%.wheel%.(.+)$") - table.insert(ret, "Mouse wheel "..key) - -- Mouse - elseif id:match("^mouse%.") then - local key = id:match("^mouse%.(.+)$") - table.insert(ret, "Mouse "..key) - -- Gamepad button - elseif id:match("^gamepad%.button%.") then - local gid, key = id:match("^gamepad%.button%.(.+)%.(.+)$") - table.insert(ret, "Gamepad "..gid.." button "..key) - -- Gamepad axis - elseif id:match("^gamepad%.axis%.") then - local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") - if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - if axis == "rightx" then - table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) - elseif axis == "righty" then - table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) - elseif axis == "leftx" then - table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) - elseif axis == "lefty" then - table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) - else - table.insert(ret, ("Gamepad %s axis %s (deadzone %s%%)"):format(gid, axis, math.abs(threshold*100))) - end - else - table.insert(ret, id) - end - end - return unpack(ret) - end, - - axisName = function(...) - local ret = {} - for _,id in ipairs({...}) do - -- Binary axis - if id:match(".+%,.+") then - local b1, b2 = uqt.input.buttonName(id:match("^(.+)%,(.+)$")) - table.insert(ret, b1.." / "..b2) - -- Mouse movement - elseif id:match("^mouse%.move%.") then - local axis, threshold = id:match("^mouse%.move%.(.+)%%(.+)$") - if not axis then axis = id:match("^mouse%.move%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, ("Mouse %s movement (threshold %s%%)"):format(axis, math.abs(threshold*100))) - -- Mouse position - elseif id:match("^mouse%.position%.") then - local axis, threshold = id:match("^mouse%.position%.(.+)%%(.+)$") - if not axis then axis = id:match("^mouse%.position%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - table.insert(ret, ("Mouse %s position (threshold %s%%)"):format(axis, math.abs(threshold*100))) - -- Gamepad axis - elseif id:match("^gamepad%.axis%.") then - local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") - if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) - threshold = tonumber(threshold) or 0 - if axis == "rightx" then - table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) - elseif axis == "righty" then - table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) - elseif axis == "leftx" then - table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) - elseif axis == "lefty" then - table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) - else - table.insert(ret, ("Gamepad %s axis %s (deadzone %s%%)"):format(gid, axis, math.abs(threshold*100))) - end - else - table.insert(ret, id) - end - end - return unpack(ret) - end -}) - --- Defaults -uqt.input.default.pointer:bind( - { "absolute", "keyboard.left,keyboard.right", "keyboard.up,keyboard.down" }, - { "absolute", "keyboard.a,keyboard.d", "keyboard.w,keyboard.s" }, - { "absolute", "gamepad.axis.1.leftx", "gamepad.axis.1.lefty" }, - { "absolute", "gamepad.button.1.dpleft,gamepad.button.1.dpright", "gamepad.button.1.dpup,gamepad.button.1.dpdown"} -) -uqt.input.default.confirm:bind( - "keyboard.enter", "keyboard.space", "keyboard.lshift", "keyboard.e", - "gamepad.button.1.a" -) -uqt.input.default.cancel:bind( - "keyboard.escape", "keyboard.backspace", - "gamepad.button.1.b" -) -end +checkCompat("Löve", "11.3.0", ("%s.%s.%s"):format(love.getVersion())) +checkCompat("Ubiquitousse", "0.0.1", uqt.version) diff --git a/draw.lua b/draw.lua deleted file mode 100644 index 3753152..0000000 --- a/draw.lua +++ /dev/null @@ -1,169 +0,0 @@ --- ubiquitousse.draw - ---- The drawing functions: everything that affect the display/window. --- The coordinate system used is: --- --- (0,0) +---> (x) --- | --- | --- v (y) --- --- x and y values can be float, so make sure to perform math.floor if your engine only support --- integer coordinates. --- --- Mostly plagiarized from the Löve API, with some parts from ctrµLua. -local draw -draw = { - --- Initial game view paramters (some defaults). - -- @impl ubiquitousse - params = { - title = "Ubiquitousse Game", - width = 800, - height = 600, - resizable = false, - resizeType = "auto" - }, - - --- Setup the intial game view parameters. - -- If a parmeter is not set, a default value will be used. - -- This function is expected to be only called once, before doing any drawing operation. - -- @tparam table params the game parameters - -- @usage -- Default values: - -- ubiquitousse.init { - -- title = "Ubiquitousse Game", -- usually window title - -- width = 800, -- in px - -- height = 600, -- in px - -- resizable = false, -- can the game be resized? - -- resizeType = "auto" -- how to act on resize: "none" to do nothing (0,0 will be top-left) - -- "center" to autocenter (0,0 will be at windowWidth/2-gameWidth/2,windowHeight/2-gameHeight/2) - -- "auto" to automatically resize to the window size (coordinate system won't change) - -- } - -- @impl mixed - init = function(params) - for k, v in pairs(params) do - draw.params[k] = v - end - draw.width = params.width - draw.height = params.height - end, - - --- Return the number of frames per second. - -- @treturn number the current FPS - -- @impl backend - fps = function() end, - - --- Sets the drawing color - -- @tparam number r the red component (0-1) - -- @tparam number g the green component (0-1) - -- @tparam number b the blue component (0-1) - -- @tparam[opt=1] number a the alpha (opacity) component (0-1) - -- @impl backend - color = function(r, g, b, a) end, - - --- Draws some text. - -- @tparam number x x top-left coordinate of the text - -- @tparam number y y top-left coordinate of the text - -- @tparam string text the text to draw. UTF-8 format, convert if needed. - -- @impl backend - text = function(x, y, text) end, - - --- Draws a point. - -- @tparam number x point x coordinate - -- @tparam number y point y coordinate - -- @tparam number ... other vertices to draw other points - -- @impl backend - point = function(x, y, ...) end, - - --- Sets the width. - -- @tparam number width the line width - -- @impl backend - lineWidth = function(width) end, - - --- Draws a line. - -- @tparam number x1 line start x coordinate - -- @tparam number y1 line start y coordinate - -- @tparam number x2 line end x coordinate - -- @tparam number y2 line end y coordinate - -- @tparam number ... other vertices to continue drawing a polyline - -- @impl backend - line = function(x1, y1, x2, y2, ...) end, - - --- Draws a filled polygon. - -- @tparam number x1,y1,x2,y2... the vertices of the polygon - -- @impl backend - polygon = function(...) end, - - --- Draws a polygon outline. - -- @tparam number x1,y1,x2,y2... the vertices of the polygon - -- @impl backend - linedPolygon = function(...) end, - - --- Draws a filled rectangle. - -- @tparam number x rectangle top-left x coordinate - -- @tparam number y rectangle top-left x coordinate - -- @tparam number width rectangle width - -- @tparam number height rectangle height - -- @impl ubiquitousse - rectangle = function(x, y, width, height) - draw.polygon(x, y, x + width, y, x + width, y + height, x, y + height) - end, - - --- Draws a rectangle outline. - -- @tparam number x rectangle top-left x coordinate - -- @tparam number y rectangle top-left x coordinate - -- @tparam number width rectangle width - -- @tparam number height rectangle height - -- @impl ubiquitousse - linedRectangle = function(x, y, width, height) - draw.linedPolygon(x, y, x + width, y, x + width, y + height, x, y + height) - end, - - --- Draws a filled circle. - -- @tparam number x center x coordinate - -- @tparam number y center x coordinate - -- @tparam number radius circle radius - -- @impl backend - circle = function(x, y, radius) end, - - --- Draws a circle outline. - -- @tparam number x center x coordinate - -- @tparam number y center x coordinate - -- @tparam number radius circle radius - -- @impl backend - linedCircle = function(x, y, radius) end, - - --- Enables the scissor test. - -- When enabled, every pixel drawn outside of the scissor rectangle is discarded. - -- When called withou arguments, it disables the scissor test. - -- @tparam number x rectangle top-left x coordinate - -- @tparam number y rectangle top-left x coordinate - -- @tparam number width rectangle width - -- @tparam number height rectangle height - -- @impl backend - scissor = function(x, y, width, height) end, - - --- The drawing area width, in pixels. - -- @requiredby input - -- @impl backend - width = 800, - - --- The drawing area height, in pixels. - -- @requiredby input - -- @impl backend - height = 600, - - -- TODO: doc & api - push = function() end, - pop = function() end, - translate = function(x, y) end, - rotate = function(angle) end, - scale = function(sx, sy) end, - font = function(filename) end, - image = function(filename) end, -} - --- TODO: canvas stuff ; also make everything here actually be shortcut to draw to the game's framebuffer. --- TODO: add software implementations of everything. --- TODO: add function to draw a message (used eg for the error message when there is a version mismatch) - -return draw diff --git a/ecs/ecs.can b/ecs/ecs.can new file mode 100644 index 0000000..555d4a9 --- /dev/null +++ b/ecs/ecs.can @@ -0,0 +1,308 @@ +--- ubiquitousse.ecs +-- No dependency. + +--- Entity Component System library, inspired by the excellent tiny-ecs. Main differences include: +-- * ability to nest systems; +-- * instanciation of systems for each world; +-- * adding and removing entities is done instantaneously. + +-- TODO: Implement a skip list for faster search. + +--- Recursively remove subsystems from a system. +let recDestroySystems = (system) + for i=#system.systems, 1, -1 do + let s = system.systems[i] + recDestroySystems(s) + system.systems[i] = nil + if s.name then + system.world.s[s.name] = nil + end + end +end +--- Recursively call :clear and :onRemoveFromWorld to a list of systems in a world. +let recCallOnRemoveFromWorld = (world, systems) + for _, s in ipairs(systems) do + s:clear() + recCallOnRemoveFromWorld(world, s.systems) + s:onRemoveFromWorld(world) + end +end + +--- Iterate through the next entity, based on state s: { previousLinkedListItem } +let nextEntity = (s) + if s[1] then + let var = s[1][1] + s[1] = s[1][2] + return var + else + return nil + end +end + +--- System fields and methods. +-- When they are added to a world, a new, per-world self table is created and used for every method call (which we call "instancied system"). +-- Instancied systems can be retrieved in system.s or system.systems. +-- Oh, the "world" is just the top-level system. +let system_mt = { + --- Read-only after creation system options --- + -- I mean, you can try to change them afterwards. But, heh. + + --- Name of the system (optional). + -- Used to create a field with the system's name in world.system. + name = nil, + + --- List of subsystems. + -- On a instancied system, this is a list of the same subsystems, but instancied for this world. + systems = nil, + + --- Returns true if the entity should be added to this system (and therefore its subsystems). + -- By default, rejects everything. + filter = :(e) return false end, + --- Returns true if e1 <= e2. + compare = :(e1, e2) return true end, + + --- Modifiable system options --- + + --- Called when adding an entity to the system. + onAdd = :(e) end, + --- Called when removing an entity from the system. + onRemove = :(e) end, + --- Called when the system is added to a world. + onAddToWorld = :(world) end, + --- Called when the system is removed from a world (i.e., the world is destroyed). + onRemoveFromWorld = :(world) end, + --- Called when updating the system. + onUpdate = :(dt) end, + --- Called when drawing the system. + onDraw = :() end, + --- Called when updating the system, for every entity the system contains. + process = :(e, dt) end, + --- Called when drawing the system, for every entity the system contains. + render = :(e) end, + + --- If set, the system will only update every interval seconds. + interval = nil, + --- The system and its susbsystems will only update if this is true. + active = true, + --- The system and its subsystems will only draw if this is true. + visible = true, + + --- Read-only system options --- + + --- The world the system belongs to. + world = nil, + --- Number of entities in the system. + entityCount = 0, + --- Map of named systems in the world (not only subsystems). + s = nil, + + --- Private fields --- + + --- First element of the linked list of entities. + _first = nil, + --- Amount of time waited since last update (if interval is set). + _waited = 0, + + --- Methods --- + + --- Add entities to the system and its subsystems. + -- If this is called on a subsystem instead of the world, be warned that this will bypass all the parent's systems filters. + -- Since :remove will not search for entities in systems where they should have been filtered out, the added entities will not be removed + -- when calling :remove on a parent system or the world. The entity can only be removed by calling :remove on the system :add was called on. + add = :(...) + for _, e in ipairs({...}) do + if @filter(e) then + if @_first == nil then + @_first = { e, nil } + elseif @compare(e, @_first[1]) then + @_first = { e, @_first } + else + let entity = @_first + while entity[2] ~= nil do + if @compare(e, entity[2][1]) then + entity[2] = { e, entity[2] } + break + end + entity = entity[2] + end + if entity[2] == nil then + entity[2] = { e, nil } + end + end + for _, s in ipairs(@systems) do + s:add(e) + end + @entityCount += 1 + @onAdd(e) + end + end + return ... + end, + --- Remove entities to the system and its subsystems. + -- If you intend to call this on a subsystem instead of the world, please read the warning in :add. + remove = :(...) + for _, e in ipairs({...}) do + if @filter(e) then + let found = false + if @_first == nil then + return + elseif @_first[1] == e then + @_first = @_first[2] + found = true + else + let entity = @_first + while entity[2] ~= nil do + if entity[2][1] == e then + entity[2] = entity[2][2] + found = true + break + end + entity = entity[2] + end + end + if found then + for _, s in ipairs(@systems) do + s:remove(e) + end + @entityCount -= 1 + @onRemove(e) + end + end + end + end, + --- Returns an iterator that iterate through the entties in this system. + iter = :() + return nextEntity, { @_first } + end, + --- Remove every entity from the system and its subsystems. + clear = :() + for e in @iter() do + @remove(e) + end + for _, s in ipairs(@systems) do + s:clear() + end + end, + --- Try to update the system and its subsystems. Should be called on every game update. + update = :(dt) + if @active then + if @interval then + @_waited += dt + if @_waited < @interval then + return + end + end + for _, s in ipairs(@systems) do + s:update(dt) + end + if @process ~= system_mt.process then + for e in @iter() do + @process(e, dt) + end + end + @onUpdate(dt) + if @interval then + @_waited = 0 + end + end + end, + --- Try to draw the system and its subsystems. Should be called on every game draw. + draw = :() + if @visible then + for _, s in ipairs(@systems) do + s:draw() + end + if @render ~= system_mt.render then + for e in @iter() do + @render(e) + end + end + @onDraw() + end + end, + --- Remove all the entities and subsystems in this system. + destroy = :() + recCallOnRemoveFromWorld(@world, { @ }) + recDestroySystems({ systems = { @ } }) + end +} + +--- Recursively instanciate a list of systems for a world: +-- * create their self table with instance fields set +-- * create a field with their name in world.s (if name defined) +let recInstanciateSystems = (world, systems) + let t = {} + for _, s in ipairs(systems) do + table.insert(t, setmetatable({ + systems = recInstanciateSystems(world, s.systems or {}), + world = world, + s = world.s + }, { + __index = :(k) + if s[k] ~= nil then + return s[k] + else + return system_mt[k] + end + end + })) + let system = t[#t] + if s.name then + world.s[s.name] = system + end + end + return t +end +--- Recursively call :onAddToWorld to a list of systems in a world. +let recCallOnAddToWorld = (world, systems) + for _, s in ipairs(systems) do + recCallOnAddToWorld(world, s.systems) + s:onAddToWorld(world) + end +end + +--- Create and returns a world system based on a list of systems. +-- The systems will be instancied for this world. +let world = (...) + let world = setmetatable({ + filter = (e) return true end, + s = {} + }, { __index = system_mt }) + world.world = world + world.systems = recInstanciateSystems(world, {...}) + recCallOnAddToWorld(world, world.systems) + return world +end + +--- Returns a filter that returns true if, for every argument, a field with the same name exists in the entity. +let all = (...) + let l = {...} + return function(s, e) + for _, k in ipairs(l) do + if e[k] == nil then + return false + end + end + return true + end +end + +--- Returns a filter that returns true if one of the arguments if the name of a field in the entity. +let any = (...) + let l = {...} + return function(s, e) + for _, k in ipairs(l) do + if e[k] ~= nil then + return true + end + end + return false + end +end + +--- ECS module. +return { + world = world, + all = all, + any = any +} diff --git a/ecs/init.lua b/ecs/init.lua new file mode 100644 index 0000000..5d1e2a3 --- /dev/null +++ b/ecs/init.lua @@ -0,0 +1 @@ +return require((...)..".ecs") \ No newline at end of file diff --git a/event.lua b/event.lua deleted file mode 100644 index fe294eb..0000000 --- a/event.lua +++ /dev/null @@ -1,29 +0,0 @@ --- ubiquitousse.event -local uqt = require((...):match("^(.-ubiquitousse)%.")) -local m = uqt.module - ---- The events: callback functions that will be called when something interesting occurs. --- Theses are expected to be redefined in the game. --- For backend writers: if they already contain code, then this code has to be called on each call, even --- if the user manually redefines them. --- @usage -- in the game's code --- ubiquitousse.event.draw = function() --- ubiquitousse.draw.text(5, 5, "Hello world") --- end -return { - --- Called each time the game loop is ran. Don't draw here. - -- @tparam number dt time since last call, in miliseconds - -- @impl mixed - update = function(dt) - if m.input then uqt.input.update(dt) end - if m.time then uqt.time.update(dt) end - if m.scene then uqt.scene.update(dt) end - end, - - --- Called each time the game expect a new frame to be drawn. - -- The screen is expected to be cleared since last frame. - -- @impl backend - draw = function() - if m.scene then uqt.scene.draw() end - end -} diff --git a/init.lua b/init.lua index f45354b..46e354d 100644 --- a/init.lua +++ b/init.lua @@ -1,13 +1,45 @@ -- ubiquitousse ---- Ubiquitousse Game Engine. --- Main module, containing the main things. --- The API exposed here is the Ubiquitousse API. --- It is as the name does not imply anymore abstract, and must be implemented in a backend, such as ubiquitousse.love. --- When required, this file will try to autodetect the engine it is running on, and load a correct backend. +--- Ubiquitousse Game Framework. +-- Main module, which will try to load every other Ubiquitousse module when required and provide a few convenience functions. -- --- Ubiquitousse may or may not be used as a full game engine. You can delete the modules files you don't need and Ubiquitousse --- should adapt accordingly. +-- Ubiquitousse may or may not be used in its totality. You can delete the modules directories you don't need and Ubiquitousse +-- should adapt accordingly. You can also simply copy the modules directories you need and use them directly, without using this +-- file at all. +-- However, some modules may provide more feature when other modules are available. +-- These dependencies are written at the top of every main module file. +-- +-- Ubiquitousse's goal is to run everywhere with the least porting effort possible, so Ubiquitousse tries to only use features that +-- are almost sure to be available everywhere. +-- +-- Some Ubiquitousse modules require functions that are not in the Lua standard library, and must therefore be implemented in a backend, +-- such as ubiquitousse.love. When required, modules will try to autodetect the engine it is running on, and load a correct backend. +-- +-- Most Ubiquitousse module backends require a few things to be fully implemented: +-- * The backend needs to have access to some kind of main loop, or at least a function called very often (may or may not be the +-- same as the redraw screen callback). +-- * Some way of measuring time (preferably with millisecond-precision). +-- * Some kind of filesystem. +-- * Lua 5.1, 5.2, 5.3 or LuaJit. +-- * Other requirement for specific modules should be described in the module's documentation. +-- +-- Units used in the API documentation: +-- * All distances are expressed in pixels (px) +-- * All durations are expressed in milliseconds (ms) +-- These units are only used to make writing documentation easier; you can use other units if you want, as long as you're consistent. +-- +-- Style: +-- * tabs for indentation, spaces for esthetic whitespace (notably in comments) +-- * no globals +-- * UPPERCASE for constants (or maybe not). +-- * CamelCase for class names. +-- * lowerCamelCase is expected for everything else. +-- +-- Implementation levels: +-- * backend: nothing defined in Ubiquitousse, must be implemented in backend +-- * mixed: partly implemented in Ubiquitousse but must be complemeted in backend. +-- * ubiquitousse: fully-working version in Ubiquitousse, may or may not be redefined in backend +-- The implementation level is indicated using the "@impl level" annotation. -- -- For backend writers: -- If a function defined here already contains some code, this means this code is mandatory and you must put/call @@ -20,50 +52,8 @@ -- between the different versions, so it's up to you to handle that in your game (or ignore the problem and sticks to your -- main's backend Lua version). -- --- Ubiquitousse's goal is to run everywhere with the least porting effort possible. --- To achieve this, the engine needs to stay simple, and only provide features that are almost sure to be --- available everywhere, so writing a backend should be straighforward. --- --- However, a full Ubiquitousse backend still have a few requirement about the destination platform: --- * The backend needs to have access to some kind of main loop, or at least a function called very often (may or may not be the --- same as the redraw screen callback). --- * A 2D matrix graphic output with 32bit RGB color depth. --- * Inputs which match ubiquitousse.input.default (a pointing/4 direction input, a confirm button, and a cancel button). --- * Some way of measuring time with millisecond-precision. --- * Some kind of filesystem. --- * An available audio output would be preferable but optional. --- * Lua 5.1, 5.2, 5.3 or LuaJit. --- --- Regarding data formats, Ubiquitousse implementations expect and recommend: --- * For images, PNG support is expected. --- * For audio files, OGG Vorbis support is expected. --- * For fonts, TTF support is expected. --- Theses formats are respected for the reference implementations, but Ubiquitousse may provide a script to --- automatically convert data formats from a project at some point. --- --- Units used in the API: --- * All distances are expressed in pixels (px) --- * All durations are expressed in milliseconds (ms) --- --- Style: --- * tabs for indentation, spaces for esthetic whitespace (notably in comments) --- * no globals --- * UPPERCASE for constants (or maybe not). --- * CamelCase for class names. --- * lowerCamelCase is expected for everything else. --- --- Implementation levels: --- * backend: nothing defined in Ubiquitousse, must be implemented in backend --- * mixed: partly implemented in Ubiquitousse but must be complemeted in backend --- * ubiquitousse: fully-working version in Ubiquitousse, may or may not be redefined in backend --- The implementation level is indicated using the "@impl level" annotation. --- --- Some Ubiquitousse modules require parts of other modules to work. Because every module should work when all the others are --- disabled, the backend may need to provide defaults values for a few fields in disabled modules required by an enabled one. --- Thoses fields are indicated with "@requiredby module" annotations. --- -- Regarding the documentation: Ubiquitousse used LDoc/LuaDoc styled-comments, but since LDoc hates me and my code, the --- generated result is complete garbage, so please read the documentation directly in the comments here. +-- generated result is complete garbage, so please read the documentation directly in the comments here until fix this. -- Stuff you're interested in starts with triple - (e.g., "--- This functions saves the world"). -- -- @usage local ubiquitousse = require("ubiquitousse") @@ -76,34 +66,31 @@ ubiquitousse = { -- @impl ubiquitousse version = "0.0.1", - --- Table of enabled modules. - -- @impl ubiquitousse - module = { - time = false, - draw = false, - audio = false, - input = false, - scene = false, - event = false, - asset = false, - util = false - }, + --- Should be called each time the game loop is ran; will update every loaded Ubiquitousse module that needs it. + -- @tparam number dt time since last call, in miliseconds + -- @impl mixed + update = function(dt) + if ubiquitousse.time then ubiquitousse.time.update(dt) end + if ubiquitousse.scene then ubiquitousse.scene.update(dt) end + if ubiquitousse.input then ubiquitousse.input.update(dt) end + end, - --- Backend name. - -- For consistency, only use lowercase letters [a-z] (no special char) - -- @impl backend - backend = "unknown" + --- Should be called each time the game expect a new frame to be drawn; will draw every loaded Ubiquitousse module that needs it + -- The screen is expected to be cleared since last frame. + -- @impl mixed + draw = function() + if ubiquitousse.scene then ubiquitousse.scene.draw() end + end } -- We're going to require modules requiring Ubiquitousse, so to avoid stack overflows we already register the ubiquitousse package package.loaded[p] = ubiquitousse -- Require external submodules -for m in pairs(ubiquitousse.module) do +for _, m in ipairs{"asset", "ecs", "input", "scene", "time", "util"} do local s, t = pcall(require, p.."."..m) if s then ubiquitousse[m] = t - ubiquitousse.module[m] = true end end diff --git a/input.lua b/input.lua deleted file mode 100644 index eb23060..0000000 --- a/input.lua +++ /dev/null @@ -1,489 +0,0 @@ --- ubiquitousse.input -local uqt = require((...):match("^(.-ubiquitousse)%.")) - ---- Used to store inputs which were updated this frame --- { Input: true, ... } --- This table is for internal use and shouldn't be used from an external script. -local updated = {} - ---- Input stuff --- Inspired by Tactile by Andrew Minnich (https://github.com/tesselode/tactile). --- I don't think I need to include the license since it's just inspiration, but for the sake of information, Tactile is under the MIT license. --- Ubiquitousse considers two input methods, called buttons (binary input) and axes (analog input). -local input -input = { - --------------------------------- - --- Detectors (input sources) --- - --------------------------------- - - -- Buttons detectors -- - -- A button detector is a function which returns true (pressed) or false (unpressed). - -- Any fuction which returns a boolean can be used as a button detector, but you will probably want to get the data from an HID. - -- Ubiquitousse being platform-agnostic, it doesn't suppose there is a keyboard and mouse available for example, and all HID buttons are identified using - -- an identifier string, which depends of the backend. Identifier strings should be of the format "source1.source2.[...].button", for example "keyboard.up" - -- or "gamepad.button.1.a" for the A-button of the first gamepad. - -- If the button is actually an axis (ie, the button is pressed if the axis value passes a certain threshold), the threshold should be in the end of the - -- identifier, preceded by a % : for example "gamepad.axis.1.leftx%-0.5" should return true when the left-stick of the first gamepad is moved to the right - -- by more of 50%. The negative threshold value means that the button will be pressed only when the axis has a negative value (in the example, it won't be - -- pressed when the axis is moved to the right). - - --- Makes a new button detector(s) from the identifier(s) string. - -- The function may error if the identifier is incorrect. - -- @tparam string button identifier, depends on the platform Ubiquitousse is running on (multiple parameters) - -- @treturn each button detector (multiple-returns) - -- @impl backend - buttonDetector = function(...) end, - - -- Axis detectors -- - -- Similar to buttons detectors, but returns a number between -1 and 1. - -- Threshold value can be used similarly with %. - -- Axis detectors should support "binary axis", ie an axis defined by two buttons: if the 1rst button is pressed, value will be -1, if the 2nd is pressed it will be 1 - -- and if none or the both are pressed, the value will be 0. This kind of axis identifier should have an identifier like "button1,button2" (comma-separated). - -- Axis detectors may also optionally return after the number between -1 and 1 the raw value and max value. The raw value is between -max and +max. - - --- Makes a new axis detector(s) from the identifier(s) string. - -- @tparam string axis identifier, depends on the platform Ubiquitousse is running on (multiple parameters) - -- @treturn each axis detector (multiple-returns) - -- @impl backend - axisDetector = function(...) end, - - ------------------------------------------ - --- Inputs (the thing you want to use) --- - ------------------------------------------ - - -- Buttons inputs -- - -- Button input is a container for buttons detector. A button will be pressed when one of its detectors returns true. - -- Inputs also knows if the button was just pressed or released. - -- @tparam ButtonDetectors ... all the buttons detectors or buttons identifiers - -- @tretrun ButtonInput the object - -- @impl ubiquitousse - button = function(...) - local r -- object - local detectors = {} -- detectors list - local state = "none" -- current state (none, pressed, down, released) - local function update() -- update button state - if not updated[r] then - local down = false - for _,d in pairs(detectors) do - if d() then - down = true - break - end - end - if down then - if state == "none" or state == "released" then - state = "pressed" - else - state = "down" - end - else - if state == "down" or state == "pressed" then - state = "released" - else - state = "none" - end - end - updated[r] = true - end - end - -- Object - r = { - --- Returns a new ButtonInput with the same properties. - -- @treturn ButtonInput the cloned object - clone = function(self) - local clone = input.button() - for name, detector in pairs(detectors) do - if type(name) == "string" then - clone:bind(name) - else - clone:bind(detector) - end - end - return clone - end, - - --- Bind new ButtonDetector(s) to this input. - -- @tparam ButtonDetectors ... buttons detectors or buttons identifiers to add - -- @treturn ButtonInput this ButtonInput object - bind = function(self, ...) - for _,d in ipairs({...}) do - if type(d) == "string" then - detectors[d] = input.buttonDetector(d) - elseif type(d) == "function" then - detectors[d] = d - else - error("Not a valid button detector") - end - end - return self - end, - --- Unbind ButtonDetector(s). - -- @tparam ButtonDetectors ... buttons detectors or buttons identifiers to remove - -- @treturn ButtonInput this ButtonInput object - unbind = function(self, ...) - for _,d in ipairs({...}) do - detectors[d] = nil - end - return self - end, - - --- Returns true if the input was just pressed. - -- @treturn boolean true if the input was pressed, false otherwise - pressed = function(self) - update() - return state == "pressed" - end, - --- Returns true if the input is down. - -- @treturn boolean true if the input is currently down, false otherwise - down = function(self) - update() - return state == "down" or state == "pressed" - end, - --- Returns true if the input was just released. - -- @treturn boolean true if the input was released, false otherwise - released = function(self) - update() - return state == "released" - end, - } - r:bind(...) - return r - end, - - -- Axis inputs -- - -- Axis input is a container for axes detector. An axis input will return the value of the axis detector the most far away from their center (0). - -- Axis input provide a threshold setting ; every axis which has a distance to the center below th threshold will be ignored. - -- @tparam AxisDetectors ... all the axis detectors or axis identifiers - -- @tretrun AxisInput the object - -- @impl ubiquitousse - axis = function(...) - local r -- object - local detectors = {} -- detectors list - local value, raw, max = 0, 0, 1 -- current value between -1 and 1, raw value between -max and +max and maximum for raw values - local threshold = 0.5 -- ie., the deadzone - local function update() -- update axis state - if not updated[r] then - value = 0 - for _,d in pairs(detectors) do - local v, r, m = d() -- v[-1,1], r[-m,+m] - if math.abs(v) > math.abs(value) then - value, raw, max = v, r or v, m or 1 - end - end - updated[r] = true - end - end - -- Object - r = { - --- Returns a new AxisInput with the same properties. - -- @treturn AxisInput the cloned object - clone = function(self) - local clone = input.axis() - for name, detector in pairs(detectors) do - if type(name) == "string" then - clone:bind(name) - else - clone:bind(detector) - end - end - clone:threshold(threshold) - return clone - end, - - --- Bind new AxisDetector(s) to this input. - -- @tparam AxisDetectors ... axis detectors or axis identifiers to add - -- @treturn AxisInput this AxisInput object - bind = function(self, ...) - for _,d in ipairs({...}) do - if type(d) == "string" then - detectors[d] = input.axisDetector(d) - elseif type(d) == "function" then - detectors[d] = d - else - error("Not a valid axis detector") - end - end - return self - end, - --- Unbind AxisDetector(s). - -- @tparam AxisDetectors ... axis detectors or axis identifiers to remove - -- @treturn AxisInput this AxisInput object - unbind = function(self, ...) - for _,d in ipairs({...}) do - detectors[d] = nil - end - return self - end, - - --- Sets the default detection threshold (deadzone). - -- @tparam number new the new detection threshold - -- @treturn AxisInput this AxisInput object - threshold = function(self, new) - threshold = tonumber(new) - return self - end, - - --- Returns the value of the input (between -1 and 1). - -- @tparam[opt=default threshold] number threshold value to use - -- @treturn number the input value - value = function(self, curThreshold) - update() - return math.abs(value) > math.abs(curThreshold or threshold) and value or 0 - end, - --- Returns the raw value of the input (between -max and +max). - -- @tparam[opt=default threshold*max] number raw threshold value to use - -- @treturn number the input raw value - raw = function(self, rawThreshold) - update() - return math.abs(raw) > math.abs(rawThreshold or threshold*max) and raw or 0 - end, - --- Return the raw max of the input. - -- @treturn number the input raw max - max = function(self) - update() - return max - end, - - --- The associated button pressed when the axis reaches a positive value. - positive = input.button(function() return r:value() > 0 end), - --- The associated button pressed when the axis reaches a negative value. - negative = input.button(function() return r:value() < 0 end) - } - r:bind(...) - return r - end, - - -- Pointer inputs -- - -- Pointer inputs are container for two axes input, in order to represent a two-dimensionnal pointing device, e.g. a mouse or a stick. - -- Each pointer detector is a table with 3 fields: mode(string), XAxis(axis), YAxis(axis). mode can either be "relative" or "absolute". - -- In relative mode, the pointer will return the movement since last update (for example to move a mouse pointer with a stick). - -- In absolute mode, the pointer will return the pointer position directly deduced of the current axes position. - -- @tparam table{mode,XAxis,YAxis} ... couples of axis detectors, axis identifiers or axis input to add and in which mode - -- @tretrun PointerInput the object - -- @impl ubiquitousse - pointer = function(...) - local draw = uqt.draw -- requires width and height - local pointers = {} -- pointers list - local x, y = 0, 0 -- pointer position - local width, height = 1, 1 -- half-dimensions of the movement area - local offsetX, offsetY = 0, 0 -- offsets - local xSpeed, ySpeed = 1, 1 -- speed (pixels/milisecond); for relative mode - local r -- object - local function update() - if not updated[r] then - local width, height = width or draw.width/2, height or draw.height/2 - local newX, newY = x, y - local maxMovX, maxMovY = 0, 0 -- the maxium axis movement in a direction (used to determine which axes have the priority) (absolute value) - for _, pointer in ipairs(pointers) do - local mode, xAxis, yAxis = unpack(pointer) - if mode == "relative" then - local movX, movY = math.abs(xAxis:value()), math.abs(yAxis:value()) - if movX > maxMovX then - newX = x + (xSpeed and (xAxis:value() * xSpeed * uqt.time.dt) or xAxis:raw()) - maxMovX = movX - end - if movY > maxMovY then - newY = y + (ySpeed and (yAxis:value() * ySpeed * uqt.time.dt) or yAxis:raw()) - maxMovY = movY - end - elseif mode == "absolute" then - if not pointer.previous then pointer.previous = { x = xAxis:value(), y = yAxis:value() } end -- last frame position (to calculate movement/delta) - local movX, movY = math.abs(xAxis:value() - pointer.previous.x), math.abs(yAxis:value() - pointer.previous.y) - pointer.previous = { x = xAxis:value(), y = yAxis:value() } - if movX > maxMovX then - newX = xAxis:value() * width - maxMovX = movX - end - if movY > maxMovY then - newY = yAxis:value() * height - maxMovY = movY - end - end - end - x, y = math.min(math.abs(newX), width) * (newX < 0 and -1 or 1), math.min(math.abs(newY), height) * (newY < 0 and -1 or 1) - updated[r] = true - end - end - r = { - --- Returns a new PointerInput with the same properties. - -- @treturn PointerInput the cloned object - clone = function(self) - return input.pointer(unpack(pointers)) - :dimensions(width, height) - :offset(offsetX, offsetY) - :speed(xSpeed, ySpeed) - end, - - --- Bind new axis couples to this input. - -- @tparam table{mode,XAxis,YAxis} ... couples of axis detectors, axis identifiers or axis input to add and in which mode - -- @treturn PointerInput this PointerInput object - bind = function(self, ...) - for _,p in ipairs({...}) do - if type(p) == "table" then - if type(p[2]) == "string" then p[2] = input.axis(input.axisDetector(p[2])) end - if type(p[3]) == "string" then p[3] = input.axis(input.axisDetector(p[3])) end - table.insert(pointers, p) - else - error("Pointer must be a table") - end - end - return self - end, - --- Unbind axis couples. - -- @tparam table{mode,XAxis,YAxis} ... couples of axis detectors, axis identifiers or axis input to remove - -- @treturn PointerInput this PointerInput object - unbind = function(self, ...) - for _,p in ipairs({...}) do - for i,pointer in ipairs(pointers) do - if pointer == p then - table.remove(pointers, i) - break - end - end - end - return self - end, - - --- Set the moving area half-dimensions. - -- Call without argument to use half the window dimensions. - -- It's the half dimensions because axes values goes from -1 to 1, so theses dimensions only - -- covers values from x=0,y=0 to x=1,y=1. The full moving area will be 4*newWidth*newHeight. - -- @tparam number newWidth new width - -- @tparam number newHeight new height - -- @treturn PointerInput this PointerInput object - dimensions = function(self, newWidth, newHeight) - width, height = newWidth, newHeight - return self - end, - --- Set the moving area coordinates offset. - -- The offset is a value automatically added to the x and y values when using the x() and y() methods. - -- Call without argument to automatically offset so 0,0 <= x(),y() <= width,height, i.e. offset to width,height. - -- @tparam number newOffX new X offset - -- @tparam number newOffY new Y offset - -- @treturn PointerInput this PointerInput object - offset = function(self, newOffX, newOffY) - offsetX, offsetY = newOffX, newOffY - return self - end, - --- Set maximal speed (pixels-per-milisecond) - -- Only used in relative mode. - -- Calls without argument to use the raw data and don't apply a speed modifier. - -- @tparam number newXSpeed new X speed - -- @tparam number newYSpeed new Y speed - -- @treturn PointerInput this PointerInput object - speed = function(self, newXSpeed, newYSpeed) - xSpeed, ySpeed = newXSpeed, newYSpeed or newXSpeed - return self - end, - - --- Returns the current X value of the pointer. - -- @treturn number X value - x = function() - update() - return x + (offsetX or width or draw.width/2) - end, - --- Returns the current Y value of the pointer. - -- @treturn number Y value - y = function() - update() - return y + (offsetY or height or draw.height/2) - end, - - --- The associated horizontal axis. - horizontal = input.axis(function() - local h = r:x() - return h/width, h, width - end), - --- The associated vertical axis. - vertical = input.axis(function() - local v = r:y() - return v/height, v, height - end), - - --- The associated button pressed when the pointer goes to the right. - right = nil, - --- The associated button pressed when the pointer goes to the left. - left = nil, - --- The associated button pressed when the pointer points up. - up = nil, - --- The associated button pressed when the pointer points down. - down = nil - } - r.right, r.left = r.horizontal.positive, r.horizontal.negative - r.up, r.down = r.vertical.negative, r.vertical.positive - r:bind(...) - return r - end, - - ------------------------------ - --- Input detection helpers -- - ------------------------------ - - --- Returns a list of the buttons currently in use, identified by their string button identifier. - -- This may also returns "axis threshold" buttons if an axis passes the threshold. - -- @treturn table buttons identifiers list - -- @treturn[opt=0.5] number threshold the threshold to detect axes as button - -- @impl backend - buttonsInUse = function(threshold) end, - - --- Returns a list of the axes currently in use, identified by their string axis identifier - -- @treturn table axes identifiers list - -- @treturn[opt=0.5] number threshold the threshold to detect axes - -- @impl backend - axesInUse = function(threshold) end, - - --- Returns a nice name for the button identifier. - -- Can be locale-depedant and stuff, it's only for display. - -- May returns the raw identifier if you're lazy. - -- @tparam string... button identifier string(s) - -- @treturn string... the displayable names - -- @impl backend - buttonName = function(...) end, - - --- Returns a nice name for the axis identifier. - -- Can be locale-depedant and stuff, it's only for display. - -- May returns the raw identifier if you're lazy. - -- @tparam string... axis identifier string(s) - -- @treturn string... the displayable names - -- @impl backend - axisName = function(...) end, - - ------------------- - --- Other stuff --- - ------------------- - - --- Some default inputs. - -- The backend should bind detectors to thoses inputs. - -- These are used to provide some common input default detectors to allow to start a game quickly on - -- any platform without having to configure the keys. - -- If some key function in your game match one of theses defaults, using it instead of creating a new - -- input would be a good idea. - -- @impl mixed - default = { - pointer = nil, -- Pointer: used to move and select. Example binds: arrow keys, WASD, stick. - confirm = nil, -- Button: used to confirm something. Example binds: Enter, A button. - cancel = nil -- Button: used to cancel something. Example binds: Escape, B button. - }, - - --- Update all the Inputs. - -- Supposed to be called in ubiquitousse.event.update. - -- The backend can hook into this function to to its input-related updates. - -- @tparam numder dt the delta-time - -- @impl ubiquitousse - update = function(dt) - updated = {} - end -} - --- Create default inputs -input.default.pointer = input.pointer() -input.default.confirm = input.button() -input.default.cancel = input.button() - --- TODO: some key selection helper? Will be backend-implemented, to account for all the possible input methods. --- TODO: some way to list all possible input / outputs, or make the *inUse make some separation between inputs indiscutitably in use and those who are incertain. --- TODO: outputs ! (rumble, lights, I don't know) --- TODO: other, optional, default/generic inputs, and a way to know if they are binded. --- TODO: multiplayer input helpers? something like getting the same input for different players, or default inputs for different players - -return input diff --git a/input/backend/ctrulua.lua b/input/backend/ctrulua.lua new file mode 100644 index 0000000..6829663 --- /dev/null +++ b/input/backend/ctrulua.lua @@ -0,0 +1,269 @@ +local input = require((...):match("^(.-%.)backend").."input") + +local gfx = require("ctr.gfx") +local hid = require("ctr.hid") + +local keys = {} +local touchX, touchY, dTouchX, dTouchY + +local oUpdate = input.update +input.update = function(dt) + hid.read() + + keys = hid.keys() + + local nTouchX, nTouchY = hid.touch() + dTouchX, dTouchY = nTouchX - touchX, nTouchY - touchY + touchX, touchY = nTouchX, nTouchY + + oUpdate(dt) +end + +input.buttonDetector = function(...) + local ret = {} + for _,id in ipairs({...}) do + -- Keys + if id:match("^key%.") then + local key = id:match("^key%.(.+)$") + table.insert(ret, function() + return keys.held[key] + end) + else + error("Unknown button identifier: "..id) + end + end + return table.unpack(ret) +end + +input.axisDetector = function(...) + local ret = {} + for _,id in ipairs({...}) do + -- Binary axis + if id:match(".+%,.+") then + local d1, d2 = input.buttonDetector(id:match("^(.+)%,(.+)$")) + table.insert(ret, function() + local b1, b2 = d1(), d2() + if b1 and b2 then return 0 + elseif b1 then return -1 + elseif b2 then return 1 + else return 0 end + end) + -- Touch movement + elseif id:match("^touch%.move%.") then + local axis, threshold = id:match("^touch%.move%.(.+)%%(.+)$") + if not axis then axis = id:match("^touch%.move%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, function() + local val, raw, max + if axis == "x" then + raw, max = dTouchX, gfx.BOTTOM_WIDTH + elseif axis == "y" then + raw, max = dTouchY, gfx.BOTTOM_HEIGHT + end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end) + -- Touch position + elseif id:match("^touch%.position%.") then + local axis, threshold = id:match("^touch%.position%.(.+)%%(.+)$") + if not axis then axis = id:match("^touch%.position%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, function() + local val, raw, max + if axis == "x" then + max = gfx.BOTTOM_WIDTH / 2 -- /2 because x=0,y=0 is the center of the screen (an axis value is in [-1,1]) + raw = touchX - max + elseif axis == "y" then + max = gfx.BOTTOM_HEIGHT / 2 + raw = touchY - max + end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end) + -- Circle pad axis + elseif id:match("^circle%.") then + local axis, threshold = id:match("^circle%.(.+)%%(.+)$") + if not axis then axis = id:match("^circle%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, function() + local x, y = hid.circle() + local val, raw, max = 0, 0, 156 + if axis == "x" then raw = x + elseif axis == "y" then raw = y end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end) + -- C-Stick axis + elseif id:match("^cstick%.") then + local axis, threshold = id:match("^cstick%.(.+)%%(.+)$") + if not axis then axis = id:match("^cstick%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, function() + local x, y = hid.cstick() + local val, raw, max = 0, 0, 146 + if axis == "x" then raw = x + elseif axis == "y" then raw = y end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end) + -- Accelerometer axis + elseif id:match("^accel%.") then + local axis, threshold = id:match("^accel%.(.+)%%(.+)$") + if not axis then axis = id:match("^accel%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, function() + local x, y, z = hid.accel() + local val, raw, max = 0, 0, 32768 -- no idea actually, but it's a s16 + if axis == "x" then raw = x + elseif axis == "y" then raw = y + elseif axis == "z" then raw = z end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end) + -- Gyroscope axis + elseif id:match("^gyro%.") then + local axis, threshold = id:match("^gyro%.(.+)%%(.+)$") + if not axis then axis = id:match("^gyro%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, function() + local roll, pitch, yaw = hid.gyro() + local val, raw, max = 0, 0, 32768 -- no idea actually, but it's a s16 + if axis == "roll" then raw = roll + elseif axis == "pitch" then raw = pitch + elseif axis == "yaw" then raw = yaw end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end) + else + error("Unknown axis identifier: "..id) + end + end + return table.unpack(ret) +end + +input.buttonsInUse = function(threshold) + local r = {} + for key, held in pairs(keys.held) do + if held then table.insert(r, "key."..key) end + end + return r +end + +input.axesInUse = function(threshold) + local r = {} + threshold = threshold or 0.5 + + if math.abs(touchX) / gfx.BOTTOM_WIDTH > threshold then table.insert(r, "touch.position.x%"..threshold) end + if math.abs(touchY) / gfx.BOTTOM_HEIGHT > threshold then table.insert(r, "touch.position.y%"..threshold) end + + if math.abs(dTouchX) / gfx.BOTTOM_WIDTH > threshold then table.insert(r, "touch.move.x%"..threshold) end + if math.abs(dTouchY) / gfx.BOTTOM_HEIGHT > threshold then table.insert(r, "touch.move.y%"..threshold) end + + local circleX, circleY = hid.circle() + if math.abs(circleX) / 156 > threshold then table.insert(r, "circle.x%"..threshold) end + if math.abs(circleY) / 156 > threshold then table.insert(r, "circle.y%"..threshold) end + + if ctr.apt.isNew3DS() then + local cstickX, cstickY = hid.cstick() + if math.abs(cstickY) / 146 > threshold then table.insert(r, "cstick.y%"..threshold) end + if math.abs(cstickX) / 146 > threshold then table.insert(r, "cstick.x%"..threshold) end + end + + local accelX, accelY, accelZ = hid.accel() + if math.abs(accelX) / 32768 > threshold then table.insert(r, "accel.x%"..threshold) end + if math.abs(accelY) / 32768 > threshold then table.insert(r, "accel.y%"..threshold) end + if math.abs(accelZ) / 32768 > threshold then table.insert(r, "accel.z%"..threshold) end + + -- no gyro, because it is always in use + + return r +end + +input.buttonName = function(...) + local ret = {} + for _,id in ipairs({...}) do + -- Key + if id:match("^key%.") then + local key = id:match("^key%.(.+)$") + table.insert(ret, key:sub(1,1):upper()..key:sub(2).." key") + else + table.insert(ret, id) + end + end + return table.unpack(ret) +end + +input.axisName = function(...) + local ret = {} + for _,id in ipairs({...}) do + -- Binary axis + if id:match(".+%,.+") then + local b1, b2 = input.buttonName(id:match("^(.+)%,(.+)$")) + table.insert(ret, b1.." / "..b2) + -- Touch movement + elseif id:match("^touch%.move%.") then + local axis, threshold = id:match("^touch%.move%.(.+)%%(.+)$") + if not axis then axis = id:match("^touch%.move%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, ("Touch %s movement (threshold %s%%)"):format(axis, math.abs(threshold*100))) + -- Touch position + elseif id:match("^touch%.position%.") then + local axis, threshold = id:match("^touch%.position%.(.+)%%(.+)$") + if not axis then axis = id:match("^touch%.position%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, ("Touch %s position (threshold %s%%)"):format(axis, math.abs(threshold*100))) + -- Circle pad axis + elseif id:match("^circle%.") then + local axis, threshold = id:match("^circle%.(.+)%%(.+)$") + if not axis then axis = id:match("^circle%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + if axis == "x" then + table.insert(ret, ("Circle pad horizontal axis (deadzone %s%%)"):format(math.abs(threshold*100))) + elseif axis == "y" then + table.insert(ret, ("Circle pad vertical axis (deadzone %s%%)"):format(math.abs(threshold*100))) + else + table.insert(ret, ("Circle pad %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) + end + -- C-Stick axis + elseif id:match("^cstick%.") then + local axis, threshold = id:match("^cstick%.(.+)%%(.+)$") + if not axis then axis = id:match("^cstick%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + if axis == "x" then + table.insert(ret, ("C-Stick horizontal axis (deadzone %s%%)"):format(math.abs(threshold*100))) + elseif axis == "y" then + table.insert(ret, ("C-Stick vertical axis (deadzone %s%%)"):format(math.abs(threshold*100))) + else + table.insert(ret, ("C-Stick %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) + end + -- Accelerometer axis + elseif id:match("^accel%.") then + local axis, threshold = id:match("^accel%.(.+)%%(.+)$") + if not axis then axis = id:match("^accel%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, ("Accelerometer %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) + -- Gyroscope axis + elseif id:match("^gyro%.") then + local axis, threshold = id:match("^gyro%.(.+)%%(.+)$") + if not axis then axis = id:match("^gyro%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, ("Gyroscope %s axis (deadzone %s%%)"):format(axis, math.abs(threshold*100))) + else + table.insert(ret, id) + end + end + return table.unpack(ret) +end + +-- Size +input.screenWidth, input.screenHeight = gfx.TOP_WIDTH, gfx.TOP_HEIGHT + +-- Defaults +input.default.pointer:bind( + { "absolute", "key.left,key.right", "key.up,key.down" }, + { "absolute", "circle.x", "circle.y" } +) +input.default.confirm:bind("key.a") +input.default.cancel:bind("key.b") + +return input diff --git a/input/backend/love.lua b/input/backend/love.lua new file mode 100644 index 0000000..158547e --- /dev/null +++ b/input/backend/love.lua @@ -0,0 +1,319 @@ +local input = require((...):match("^(.-%.)backend").."input") + +-- Config -- + +-- Use ScanCodes (layout independant input) instead of KeyConstants (layout dependant) for keyboard input +local useScancodes = true +-- If using ScanCodes, sets this to true so the backend returns the layout-dependant KeyConstant +-- instead of the raw ScanCode when getting the display name. If set to false and using ScanCodes, +-- the user will see keys that don't match what's actually written on his keyboard, which is confusing. +local displayKeyConstant = true + +-- Setup +love.mouse.setVisible(false) + +-- Button detection +-- FIXME love callbacks do something cleaner +local buttonsInUse = {} +local axesInUse = {} +function love.keypressed(key, scancode, isrepeat) + if useScancodes then key = scancode end + buttonsInUse["keyboard."..key] = true +end +function love.keyreleased(key, scancode) + if useScancodes then key = scancode end + buttonsInUse["keyboard."..key] = nil +end +function love.mousepressed(x, y, button, istouch) + buttonsInUse["mouse."..button] = true +end +function love.mousereleased(x, y, button, istouch) + buttonsInUse["mouse."..button] = nil +end +function love.wheelmoved(x, y) + if y > 0 then + buttonsInUse["mouse.wheel.up"] = true + elseif y < 0 then + buttonsInUse["mouse.wheel.down"] = true + end + if x > 0 then + buttonsInUse["mouse.wheel.right"] = true + elseif x < 0 then + buttonsInUse["mouse.wheel.left"] = true + end +end +function love.mousemoved(x, y, dx, dy) + if dx ~= 0 then axesInUse["mouse.move.x"] = dx/love.graphics.getWidth() end + if dy ~= 0 then axesInUse["mouse.move.y"] = dy/love.graphics.getHeight() end +end +function love.gamepadpressed(joystick, button) + buttonsInUse["gamepad.button."..joystick:getID().."."..button] = true +end +function love.gamepadreleased(joystick, button) + buttonsInUse["gamepad.button."..joystick:getID().."."..button] = nil +end +function love.gamepadaxis(joystick, axis, value) + if value ~= 0 then + axesInUse["gamepad.axis."..joystick:getID().."."..axis] = value + else + axesInUse["gamepad.axis."..joystick:getID().."."..axis] = nil + end +end + +-- Windows size +input.drawWidth, input.drawHeight = love.graphics.getWidth(), love.graphics.getHeight() +function love.resize(width, height) + input.drawWidth = width + input.drawHeight = height +end + +-- Update +local oUpdate = input.update +input.update = function(dt) + -- love.wheelmoved doesn't trigger when the wheel stop moving, so we need to clear up our stuff at each update + buttonsInUse["mouse.wheel.up"] = nil + buttonsInUse["mouse.wheel.down"] = nil + buttonsInUse["mouse.wheel.right"] = nil + buttonsInUse["mouse.wheel.left"] = nil + -- Same for mouse axis + axesInUse["mouse.move.x"] = nil + axesInUse["mouse.move.y"] = nil + + oUpdate(dt) +end + +input.basicButtonDetector = function(id) + -- Keyboard + if id:match("^keyboard%.") then + local key = id:match("^keyboard%.(.+)$") + return function() + return useScancodes and love.keyboard.isScancodeDown(key) or love.keyboard.isDown(key) + end + -- Mouse wheel + elseif id:match("^mouse%.wheel%.") then + local key = id:match("^mouse%.wheel%.(.+)$") + return function() + return buttonsInUse["mouse.wheel."..key] + end + -- Mouse + elseif id:match("^mouse%.") then + local key = id:match("^mouse%.(.+)$") + return function() + return love.mouse.isDown(key) + end + -- Gamepad button + elseif id:match("^gamepad%.button%.") then + local gid, key = id:match("^gamepad%.button%.(.+)%.(.+)$") + gid = tonumber(gid) + return function() + local gamepad + for _,j in ipairs(love.joystick.getJoysticks()) do + if j:getID() == gid then gamepad = j end + end + return gamepad and gamepad:isGamepadDown(key) + end + -- Gamepad axis + elseif id:match("^gamepad%.axis%.") then + local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") + if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) + gid = tonumber(gid) + threshold = tonumber(threshold) or 0.1 + return function() + local gamepad + for _,j in ipairs(love.joystick.getJoysticks()) do + if j:getID() == gid then gamepad = j end + end + if not gamepad then + return false + else + local val = gamepad:getGamepadAxis(axis) + return (math.abs(val) > math.abs(threshold)) and ((val < 0) == (threshold < 0)) + end + end + else + error("Unknown button identifier: "..id) + end +end + +input.basicAxisDetector = function(id) + -- Mouse movement + if id:match("^mouse%.move%.") then + local axis, threshold = id:match("^mouse%.move%.(.+)%%(.+)$") + if not axis then axis = id:match("^mouse%.move%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + return function() + local val, raw, max = axesInUse["mouse.move."..axis] or 0, 0, 1 + if axis == "x" then + raw, max = val * love.graphics.getWidth(), love.graphics.getWidth() + elseif axis == "y" then + raw, max = val * love.graphics.getHeight(), love.graphics.getHeight() + end + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end + -- Mouse position + elseif id:match("^mouse%.position%.") then + local axis, threshold = id:match("^mouse%.position%.(.+)%%(.+)$") + if not axis then axis = id:match("^mouse%.position%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + return function() + local val, raw, max = 0, 0, 1 + if axis == "x" then + max = love.graphics.getWidth() / 2 -- /2 because x=0,y=0 is the center of the screen (an axis value is in [-1,1]) + raw = love.mouse.getX() - max + elseif axis == "y" then + max = love.graphics.getHeight() / 2 + raw = love.mouse.getY() - max + end + val = raw / max + return math.abs(val) > math.abs(threshold) and val or 0, raw, max + end + -- Gamepad axis + elseif id:match("^gamepad%.axis%.") then + local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") + if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) + gid = tonumber(gid) + threshold = tonumber(threshold) or 0.1 + return function() + local gamepad + for _,j in ipairs(love.joystick.getJoysticks()) do + if j:getID() == gid then gamepad = j end + end + if not gamepad then + return 0 + else + local val = gamepad:getGamepadAxis(axis) + return math.abs(val) > math.abs(threshold) and val or 0 + end + end + else + error("Unknown axis identifier: "..id) + end +end + +input.buttonsInUse = function(threshold) + local r = {} + threshold = threshold or 0.5 + for b in pairs(buttonsInUse) do + table.insert(r, b) + end + for b,v in pairs(axesInUse) do + if math.abs(v) > threshold then + table.insert(r, b.."%"..(v < 0 and -threshold or threshold)) + end + end + return r +end + +input.axesInUse = function(threshold) + local r = {} + threshold = threshold or 0.5 + for b,v in pairs(axesInUse) do + if math.abs(v) > threshold then + table.insert(r, b.."%"..threshold) + end + end + return r +end + +input.buttonName = function(...) + local ret = {} + for _,id in ipairs({...}) do + -- Keyboard + if id:match("^keyboard%.") then + local key = id:match("^keyboard%.(.+)$") + if useScancodes and displayKeyConstant then key = love.keyboard.getKeyFromScancode(key) end + table.insert(ret, key:sub(1,1):upper()..key:sub(2).." key") + -- Mouse wheel + elseif id:match("^mouse%.wheel%.") then + local key = id:match("^mouse%.wheel%.(.+)$") + table.insert(ret, "Mouse wheel "..key) + -- Mouse + elseif id:match("^mouse%.") then + local key = id:match("^mouse%.(.+)$") + table.insert(ret, "Mouse "..key) + -- Gamepad button + elseif id:match("^gamepad%.button%.") then + local gid, key = id:match("^gamepad%.button%.(.+)%.(.+)$") + table.insert(ret, "Gamepad "..gid.." button "..key) + -- Gamepad axis + elseif id:match("^gamepad%.axis%.") then + local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") + if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0.1 + if axis == "rightx" then + table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) + elseif axis == "righty" then + table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) + elseif axis == "leftx" then + table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) + elseif axis == "lefty" then + table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) + else + table.insert(ret, ("Gamepad %s axis %s (deadzone %s%%)"):format(gid, axis, math.abs(threshold*100))) + end + else + table.insert(ret, id) + end + end + return unpack(ret) +end + +input.axisName = function(...) + local ret = {} + for _,id in ipairs({...}) do + -- Binary axis + if id:match(".+%,.+") then + local b1, b2 = input.buttonName(id:match("^(.+)%,(.+)$")) + table.insert(ret, b1.." / "..b2) + -- Mouse movement + elseif id:match("^mouse%.move%.") then + local axis, threshold = id:match("^mouse%.move%.(.+)%%(.+)$") + if not axis then axis = id:match("^mouse%.move%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, ("Mouse %s movement (threshold %s%%)"):format(axis, math.abs(threshold*100))) + -- Mouse position + elseif id:match("^mouse%.position%.") then + local axis, threshold = id:match("^mouse%.position%.(.+)%%(.+)$") + if not axis then axis = id:match("^mouse%.position%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0 + table.insert(ret, ("Mouse %s position (threshold %s%%)"):format(axis, math.abs(threshold*100))) + -- Gamepad axis + elseif id:match("^gamepad%.axis%.") then + local gid, axis, threshold = id:match("^gamepad%.axis%.(.+)%.(.+)%%(.+)$") + if not gid then gid, axis = id:match("^gamepad%.axis%.(.+)%.(.+)$") end -- no threshold (=0) + threshold = tonumber(threshold) or 0.1 + if axis == "rightx" then + table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) + elseif axis == "righty" then + table.insert(ret, ("Gamepad %s right stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) + elseif axis == "leftx" then + table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "right" or "left", math.abs(threshold*100))) + elseif axis == "lefty" then + table.insert(ret, ("Gamepad %s left stick %s (deadzone %s%%)"):format(gid, threshold >= 0 and "down" or "up", math.abs(threshold*100))) + else + table.insert(ret, ("Gamepad %s axis %s (deadzone %s%%)"):format(gid, axis, math.abs(threshold*100))) + end + else + table.insert(ret, id) + end + end + return unpack(ret) +end + +-- Default inputs. +input.default.pointer:bind( + { "absolute", { "keyboard.left", "keyboard.right" }, { "keyboard.up", "keyboard.down" } }, + { "absolute", { "keyboard.a", "keyboard.d" }, { "keyboard.w", "keyboard.s" } }, + { "absolute", "gamepad.axis.1.leftx", "gamepad.axis.1.lefty" }, + { "absolute", { "gamepad.button.1.dpleft", "gamepad.button.1.dpright" }, { "gamepad.button.1.dpup", "gamepad.button.1.dpdown" } } +) +input.default.confirm:bind( + "keyboard.return", "keyboard.space", "keyboard.lshift", "keyboard.e", + "gamepad.button.1.a" +) +input.default.cancel:bind( + "keyboard.escape", "keyboard.backspace", + "gamepad.button.1.b" +) + +return input diff --git a/input/init.lua b/input/init.lua new file mode 100644 index 0000000..a4bf493 --- /dev/null +++ b/input/init.lua @@ -0,0 +1,14 @@ +local input + +local p = ... +if love then + input = require(p..".backend.love") +elseif package.loaded["ctr"] then + input = require(p..".backend.ctrulua") +elseif package.loaded["libretro"] then + error("NYI") +else + error("no backend for ubiquitousse.input") +end + +return input diff --git a/input/input.lua b/input/input.lua new file mode 100644 index 0000000..0d8a988 --- /dev/null +++ b/input/input.lua @@ -0,0 +1,711 @@ +--- ubiquitousse.input +-- Depends on a backend. + +-- TODO: some key selection helper? Will be backend-implemented, to account for all the possible input methods. +-- TODO: some way to list all possible input / outputs, or make the *inUse make some separation between inputs indiscutitably in use and those who are incertain. +-- TODO: outputs! (rumble, lights, I don't know) +-- TODO: other, optional, default/generic inputs, and a way to know if they are binded. +-- TODO: multiplayer input helpers? something like getting the same input for different players, or default inputs for different players + +local input +local sqrt = math.sqrt +local unpack = table.unpack or unpack +local dt = 0 + +--- Used to store inputs which were updated this frame +-- { Input: true, ... } +-- This table is for internal use and shouldn't be used from an external script. +local updated = {} + +--- ButtonInput methods +-- @impl ubiquitousse +local button_mt = { + --- Returns a new ButtonInput with the same properties. + -- @treturn ButtonInput the cloned object + clone = function(self) + return input.button(unpack(self.detectors)) + end, + + --- Bind new ButtonDetector(s) to this input. + -- @tparam ButtonDetectors ... buttons detectors or buttons identifiers to add + -- @treturn ButtonInput this ButtonInput object + bind = function(self, ...) + for _, d in ipairs({...}) do + table.insert(self.detectors, input.buttonDetector(d)) + end + return self + end, + --- Unbind ButtonDetector(s). + -- @tparam ButtonDetectors ... buttons detectors or buttons identifiers to remove + -- @treturn ButtonInput this ButtonInput object + unbind = function(self, ...) + for _, d in ipairs({...}) do + for i, detector in ipairs(self.detectors) do + if detector == d then + table.remove(self.detectors, i) + break + end + end + end + return self + end, + --- Unbind all ButtonDetector(s). + -- @treturn ButtonInput this ButtonInput object + clear = function(self) + self.detectors = {} + return self + end, + + --- Grabs the input. + -- This function returns a new input object which mirrors the current object, except it will grab every new input. + -- This means any new button press/down/release will only be visible to the new object; the button will always appear unpressed for the initial object. + -- This is useful for contextual input, for example if you want to display a menu without pausing the game: the menu + -- can grab relevant inputs while it is open, so they don't trigger any action in the rest of the game. + -- An input can be grabbed several times; the one which grabbed it last will be the active one. + -- @treturn ButtonInput the new input object which is grabbing the input + grab = function(self) + local grabbed = setmetatable({}, { __index = self, __newindex = self }) + table.insert(self.grabStack, grabbed) + self.grabbing = grabbed + return grabbed + end, + --- Release the input that was grabbed by this object. + -- Input will be given back to the previous object. + -- @treturn ButtonInput this ButtonInput object + release = function(self) + local grabStack = self.grabStack + for i, v in ipairs(grabStack) do + if v == self then + table.remove(grabStack, i) + self.grabbing = grabStack[#grabStack] + return self + end + end + error("This object is currently not grabbing this input") + end, + + --- Returns true if the input was just pressed. + -- @treturn boolean true if the input was pressed, false otherwise + pressed = function(self) + if self.grabbing == self then + self:update() + return self.state == "pressed" + else + return false + end + end, + --- Returns true if the input was just released. + -- @treturn boolean true if the input was released, false otherwise + released = function(self) + if self.grabbing == self then + self:update() + return self.state == "released" + else + return false + end + end, + --- Returns true if the input is down. + -- @treturn boolean true if the input is currently down, false otherwise + down = function(self) + if self.grabbing == self then + self:update() + local state = self.state + return state == "down" or state == "pressed" + else + return false + end + end, + --- Returns true if the input is up. + -- @treturn boolean true if the input is currently up, false otherwise + up = function(self) + return not self:down() + end, + + --- Update button state. + -- Automatically called, don't call unless you know what you're doing. + -- @impl ubiquitousse + update = function(self) + if not updated[self] then + local down = false + for _, d in ipairs(self.detectors) do + if d() then + down = true + break + end + end + local state = self.state + if down then + if state == "none" or state == "released" then + self.state = "pressed" + else + self.state = "down" + end + else + if state == "down" or state == "pressed" then + self.state = "released" + else + self.state = "none" + end + end + updated[self] = true + end + end +} +button_mt.__index = button_mt + +--- AxisInput methods +-- @impl ubiquitousse +local axis_mt = { + --- Returns a new AxisInput with the same properties. + -- @treturn AxisInput the cloned object + clone = function(self) + return input.axis(unpack(self.detectors)) + :threshold(self.threshold) + end, + + --- Bind new AxisDetector(s) to this input. + -- @tparam AxisDetectors ... axis detectors or axis identifiers to add + -- @treturn AxisInput this AxisInput object + bind = function(self, ...) + for _,d in ipairs({...}) do + table.insert(self.detectors, input.axisDetector(d)) + end + return self + end, + --- Unbind AxisDetector(s). + -- @tparam AxisDetectors ... axis detectors or axis identifiers to remove + -- @treturn AxisInput this AxisInput object + unbind = button_mt.unbind, + --- Unbind all AxisDetector(s). + -- @treturn AxisInput this AxisInput object + clear = button_mt.clear, + + --- Grabs the input. + -- This function returns a new input object which mirrors the current object, except it will grab every new input. + -- This means any value change will only be visible to the new object; the axis will always appear to be at 0 for the initial object. + -- An input can be grabbed several times; the one which grabbed it last will be the active one. + -- @treturn AxisInput the new input object which is grabbing the input + grab = function(self) + local grabbed + grabbed = setmetatable({ + positive = input.button(function() return grabbed:value() > 0 end), + negative = input.button(function() return grabbed:value() < 0 end) + }, { __index = self, __newindex = self }) + table.insert(self.grabStack, grabbed) + self.grabbing = grabbed + return grabbed + end, + --- Release the input that was grabbed by this object. + -- Input will be given back to the previous object. + -- @treturn AxisInput this AxisInput object + release = button_mt.release, + + --- Sets the default detection threshold (deadzone). + -- @tparam number new the new detection threshold + -- @treturn AxisInput this AxisInput object + threshold = function(self, new) + self.threshold = tonumber(new) + return self + end, + + --- Returns the value of the input (between -1 and 1). + -- @tparam[opt=default threshold] number threshold value to use + -- @treturn number the input value + value = function(self, curThreshold) + if self.grabbing == self then + self:update() + local val = self.val + return math.abs(val) > math.abs(curThreshold or self.threshold) and val or 0 + else + return 0 + end + end, + --- Returns the change in value of the input since last update (between -2 and 2). + -- @treturn number the value delta + delta = function(self) + if self.grabbing == self then + self:update() + return self.dval + else + return 0 + end + end, + --- Returns the raw value of the input (between -max and +max). + -- @tparam[opt=default threshold*max] number raw threshold value to use + -- @treturn number the input raw value + raw = function(self, rawThreshold) + if self.grabbing == self then + self:update() + local raw = self.raw + return math.abs(raw) > math.abs(rawThreshold or self.threshold*self.max) and raw or 0 + else + return 0 + end + end, + --- Return the raw max of the input. + -- @treturn number the input raw max + max = function(self) + self:update() + return self.max + end, + + --- The associated button pressed when the axis reaches a positive value. + positive = nil, + --- The associated button pressed when the axis reaches a negative value. + negative = nil, + + --- Update axis state. + -- Automatically called, don't call unless you know what you're doing. + -- @impl ubiquitousse + update = function(self) + if not updated[self] then + local val, raw, max = 0, 0, 1 + for _, d in ipairs(self.detectors) do + local v, r, m = d() -- v[-1,1], r[-m,+m] + if math.abs(v) > math.abs(val) then + val, raw, max = v, r or v, m or 1 + end + end + self.dval = val - self.val + self.val, self.raw, self.max = val, raw, max + updated[self] = true + end + end +} +axis_mt.__index = axis_mt + +--- PointerInput methods +-- @impl ubiquitousse +local pointer_mt = { + --- Returns a new PointerInput with the same properties. + -- @treturn PointerInput the cloned object + clone = function(self) + return input.pointer(unpack(self.detectors)) + :dimensions(self.width, self.height) + :offset(self.offsetX, self.offsetY) + :speed(self.xSpeed, self.ySpeed) + end, + + --- Bind new axis couples to this input. + -- @tparam table{mode,XAxis,YAxis} ... couples of axis detectors, axis identifiers or axis input to add and in which mode + -- @treturn PointerInput this PointerInput object + bind = function(self, ...) + for _, p in ipairs({...}) do + if type(p) == "table" then + local h, v = p[2], p[3] + if getmetatable(h) ~= axis_mt then + h = input.axis(h) + end + if getmetatable(v) ~= axis_mt then + v = input.axis(v) + end + table.insert(self.detectors, { p[1], h, v }) + else + error("Pointer detector must be a table") + end + end + return self + end, + --- Unbind axis couple(s). + -- @tparam table{mode,XAxis,YAxis} ... couples of axis detectors, axis identifiers or axis input to remove + -- @treturn PointerInput this PointerInput object + unbind = button_mt.unbind, + --- Unbind all axis couple(s). + -- @treturn PointerInput this PointerInput object + clear = button_mt.clear, + + --- Grabs the input. + -- This function returns a new input object which mirrors the current object, except it will grab every new input. + -- This means any value change will only be visible to the new object; the pointer will always appear to be at offsetX,offsetY for the initial object. + -- An input can be grabbed several times; the one which grabbed it last will be the active one. + -- @treturn PointerInput the new input object which is grabbing the input + grab = function(self) + local grabbed + grabbed = { + horizontal = input.axis(function() + local h = grabbed:x() + local width = grabbed.width + return h/width, h, width + end), + vertical = input.axis(function() + local v = grabbed:y() + local height = grabbed.height + return v/height, v, height + end) + } + grabbed.right, grabbed.left = grabbed.horizontal.positive, grabbed.horizontal.negative + grabbed.up, grabbed.down = grabbed.vertical.negative, grabbed.vertical.positive + setmetatable(grabbed, { __index = self, __newindex = self }) + table.insert(self.grabStack, grabbed) + self.grabbing = grabbed + return grabbed + end, + --- Release the input that was grabbed by this object. + -- Input will be given back to the previous object. + -- @treturn PointerInput this PointerInput object + release = button_mt.release, + + --- Set the moving area half-dimensions. + -- Call without argument to use half the window dimensions. + -- It's the half dimensions because axes values goes from -1 to 1, so theses dimensions only + -- covers values from x=0,y=0 to x=1,y=1. The full moving area will be 4*newWidth*newHeight. + -- @tparam number newWidth new width + -- @tparam number newHeight new height + -- @treturn PointerInput this PointerInput object + dimensions = function(self, newWidth, newHeight) + self.width, self.height = newWidth, newHeight + return self + end, + --- Set the moving area coordinates offset. + -- The offset is a value automatically added to the x and y values when using the x() and y() methods. + -- Call without argument to automatically offset so 0,0 <= x(),y() <= width,height, i.e. offset to width,height. + -- @tparam number newOffX new X offset + -- @tparam number newOffY new Y offset + -- @treturn PointerInput this PointerInput object + offset = function(self, newOffX, newOffY) + self.offsetX, self.offsetY = newOffX, newOffY + return self + end, + --- Set maximal speed (pixels-per-milisecond) + -- Only used in relative mode. + -- Calls without argument to use the raw data and don't apply a speed modifier. + -- @tparam number newXSpeed new X speed + -- @tparam number newYSpeed new Y speed + -- @treturn PointerInput this PointerInput object + speed = function(self, newXSpeed, newYSpeed) + self.xSpeed, self.ySpeed = newXSpeed, newYSpeed or newXSpeed + return self + end, + + --- Returns the current X value of the pointer. + -- @treturn number X value + x = function(self) + if self.grabbing == self then + self:update() + return self.valX + (self.offsetX or self.width or input.drawWidth/2) + else + return self.offsetX or self.width or input.drawWidth/2 + end + end, + --- Returns the current Y value of the pointer. + -- @treturn number Y value + y = function(self) + if self.grabbing == self then + self:update() + return self.valY + (self.offsetY or self.height or input.drawHeight/2) + else + return self.offsetY or self.height or input.drawHeight/2 + end + end, + + --- Returns the X and Y value of the pointer, clamped. + -- They are clamped to stay in the ellipse touching all 4 sides of the dimension rectangle, i.e. the + -- (x,y) vector's magnitude reached its maximum either in (0,height) or (width,0). + -- Typically, this is used with square dimensions for player movements: when moving diagonally, the magnitude + -- will be the same as when moving horiontally or vertically, thus avoiding faster diagonal movement, A.K.A. "straferunning". + -- If you're not conviced by my overly complicated explanation: just use this to retrieve x and y for movement and everything + -- will be fine. + -- @treturn number X value + -- @treturn number Y value + clamped = function(self) + local width, height = self.width, self.height + if self.grabbing == self then + self:update() + local x, y = self.valX, self.valY + local cx, cy = x, y + local normalizedMagnitude = (x*x)/(width*width) + (y*y)/(height*height) -- go back to a unit circle + if normalizedMagnitude > 1 then + local magnitude = sqrt(x*x + y*y) + cx, cy = cx / magnitude * width, cy / magnitude * height + end + return cx + (self.offsetX or width or input.drawWidth/2), cy + (self.offsetY or height or input.drawHeight/2) + else + return self.offsetX or width or input.drawWidth/2, self.offsetY or height or input.drawHeight/2 + end + end, + + --- The associated horizontal axis. + horizontal = nil, + --- The associated vertical axis. + vertical = nil, + + --- The associated button pressed when the pointer goes to the right. + right = nil, + --- The associated button pressed when the pointer goes to the left. + left = nil, + --- The associated button pressed when the pointer points up. + up = nil, + --- The associated button pressed when the pointer points down. + down = nil, + + --- Update pointer state. + -- Automatically called, don't call unless you know what you're doing. + -- @impl ubiquitousse + update = function(self) + if not updated[self] then + local x, y = self.valX, self.valY + local xSpeed, ySpeed = self.xSpeed, self.ySpeed + local width, height = self.width or input.drawWidth/2, self.height or input.drawHeight/2 + local newX, newY = x, y + local maxMovX, maxMovY = 0, 0 -- the maxium axis movement in a direction (used to determine which axes have the priority) (absolute value) + for _, pointer in ipairs(self.detectors) do + local mode, xAxis, yAxis = unpack(pointer) + if mode == "relative" then + local movX, movY = math.abs(xAxis:value()), math.abs(yAxis:value()) + if movX > maxMovX then + newX = x + (xSpeed and (xAxis:value() * xSpeed * dt) or xAxis:raw()) + maxMovX = movX + end + if movY > maxMovY then + newY = y + (ySpeed and (yAxis:value() * ySpeed * dt) or yAxis:raw()) + maxMovY = movY + end + elseif mode == "absolute" then + local movX, movY = math.abs(xAxis:delta()), math.abs(yAxis:delta()) + if movX > maxMovX then + newX = xAxis:value() * width + maxMovX = movX + end + if movY > maxMovY then + newY = yAxis:value() * height + maxMovY = movY + end + end + end + self.valX, self.valY = math.min(math.abs(newX), width) * (newX < 0 and -1 or 1), math.min(math.abs(newY), height) * (newY < 0 and -1 or 1) + updated[self] = true + end + end +} +pointer_mt.__index = pointer_mt + +--- Input stuff +-- Inspired by Tactile by Andrew Minnich (https://github.com/tesselode/tactile), under the MIT license. +-- Ubiquitousse considers two basic input methods, called buttons (binary input) and axes (analog input). +input = { + --------------------------------- + --- Detectors (input sources) --- + --------------------------------- + + -- Buttons detectors -- + -- A button detector is a function which returns true (pressed) or false (unpressed). + -- All buttons are identified using an identifier string, which depends on the backend. The presence of eg., a mouse or keyboard is not assumed. + -- Some identifier strings conventions: (not used internally by Ubiquitousse, but it's nice to have some consistency between backends) + -- They should be in the format "source1.source2.[...].button", for example "keyboard.up" or "gamepad.button.1.a" for the A-button of the first gamepad. + -- If the button is actually an axis (ie, the button is pressed if the axis value passes a certain threshold), the threshold should be in the end of the + -- identifier, preceded by a % : for example "gamepad.axis.1.leftx%-0.5" should return true when the left-stick of the first gamepad is moved to the right + -- by more of 50%. The negative threshold value means that the button will be pressed only when the axis has a negative value (in the example, it won't be + -- pressed when the axis is moved to the right). + + --- Makes a new button detector from a identifier string. + -- The function may error if the identifier is incorrect. + -- @tparam string button identifier, depends on the platform Ubiquitousse is running on + -- @treturn the new button detector + -- @impl backend + basicButtonDetector = function(str) end, + + --- Make a new button detector from a detector function of string. + -- @tparam string, function button identifier + -- @impl ubiquitousse + buttonDetector = function(obj) + if type(obj) == "function" then + return obj + elseif type(obj) == "string" then + return input.basicButtonDetector(obj) + end + error(("Not a valid button detector: %s"):format(obj)) + end, + + -- Axis detectors -- + -- Similar to buttons detectors, but returns a number between -1 and 1. + -- Threshold value can be used similarly with %. + -- Axis detectors can also be defined by two buttons: if the 1rst button is pressed, value will be -1, if the 2nd is pressed it will be 1 + -- and if none or the both are pressed, the value will be 0. This kind of axis identifier is a table {"button1", "button2"}. + -- Axis detectors may also optionally return after the number between -1 and 1 the raw value and max value. The raw value is between -max and +max. + + --- Makes a new axis detector from a identifier string. + -- The function may error if the identifier is incorrect. + -- @tparam string axis identifier, depends on the platform Ubiquitousse is running on + -- @treturn the new axis detector + -- @impl backend + basicAxisDetector = function(str) end, + + --- Make a new axis detector from a detector function, string, or a couple of buttons. + -- @tparam string, function or table axis identifier + -- @impl ubiquitousse + axisDetector = function(obj) + if type(obj) == "function" then + return obj + elseif type(obj) == "string" then + return input.basicAxisDetector(obj) + elseif type(obj) == "table" then + local b1, b2 = input.buttonDetector(obj[1]), input.buttonDetector(obj[2]) + return function() + local d1, d2 = b1(), b2() + if d1 and d2 then return 0 + elseif d1 then return -1 + elseif d2 then return 1 + else return 0 end + end + end + error(("Not a valid axis detector: %s"):format(obj)) + end, + + ------------------------------------------ + --- Inputs (the thing you want to use) --- + ------------------------------------------ + + -- Buttons inputs -- + -- Button input is a container for buttons detector. A button will be pressed when one of its detectors returns true. + -- Inputs also knows if the button was just pressed or released. + -- @tparam ButtonDetectors ... all the buttons detectors or buttons identifiers + -- @tretrun ButtonInput the object + -- @impl ubiquitousse + button = function(...) + local r = setmetatable({ + grabStack = {}, -- grabbers stack, last element is the object currently grabbing this input + grabbing = nil, -- object currently grabbing this input + detectors = {}, -- detectors list + state = "none" -- current state (none, pressed, down, released) + }, button_mt) + table.insert(r.grabStack, r) + r.grabbing = r + r:bind(...) + return r + end, + + -- Axis inputs -- + -- Axis input is a container for axes detector. An axis input will return the value of the axis detector the most far away from their center (0). + -- Axis input provide a threshold setting ; every axis which has a distance to the center below the threshold (none by default) will be ignored. + -- @tparam AxisDetectors ... all the axis detectors or axis identifiers + -- @tretrun AxisInput the object + -- @impl ubiquitousse + axis = function(...) + local r = setmetatable({ + grabStack = {}, -- grabbers stack, last element is the object currently grabbing this input + grabbing = nil, -- object currently grabbing this input + detectors = {}, -- detectors list + val = 0, -- current value between -1 and 1 + dval = 0, -- change between -2 and 2 + raw = 0, -- raw value between -max and +max + max = 1, -- maximum for raw values + threshold = 0 -- ie., the deadzone + }, axis_mt) + table.insert(r.grabStack, r) + r.grabbing = r + r:bind(...) + r.positive = input.button(function() return r:value() > 0 end) + r.negative = input.button(function() return r:value() < 0 end) + return r + end, + + -- Pointer inputs -- + -- Pointer inputs are container for two axes input, in order to represent a two-dimensionnal pointing device, e.g. a mouse or a stick. + -- Each pointer detector is a table with 3 fields: mode(string), XAxis(axis), YAxis(axis). mode can either be "relative" or "absolute". + -- In relative mode, the pointer will return the movement since last update (for example to move a mouse pointer with a stick). + -- In absolute mode, the pointer will return the pointer position directly deduced of the current axes position. + -- @tparam table{mode,XAxis,YAxis} ... couples of axis detectors, axis identifiers or axis input to add and in which mode + -- @tretrun PointerInput the object + -- @impl ubiquitousse + pointer = function(...) + local r = setmetatable({ + grabStack = {}, -- grabbers stack, first element is the object currently grabbing this input + grabbing = nil, -- object currently grabbing this input + detectors = {}, -- pointers list (composite detectors) + valX = 0, valY = 0, -- pointer position + width = 1, height = 1, -- half-dimensions of the movement area + offsetX = 0, offsetY = 0, -- offsets + xSpeed = 1, ySpeed = 1, -- speed (pixels/milisecond); for relative mode + }, pointer_mt) + table.insert(r.grabStack, r) + r.grabbing = r + r:bind(...) + r.horizontal = input.axis(function() + local h = r:x() + local width = r.width + return h/width, h, width + end) + r.vertical = input.axis(function() + local v = r:y() + local height = r.height + return v/height, v, height + end) + r.right, r.left = r.horizontal.positive, r.horizontal.negative + r.up, r.down = r.vertical.negative, r.vertical.positive + return r + end, + + ------------------------------ + --- Input detection helpers -- + ------------------------------ + -- TODO: make this better + + --- Returns a list of the buttons currently in use, identified by their string button identifier. + -- This may also returns "axis threshold" buttons if an axis passes the threshold. + -- @treturn table buttons identifiers list + -- @treturn[opt=0.5] number threshold the threshold to detect axes as button + -- @impl backend + buttonsInUse = function(threshold) end, + + --- Returns a list of the axes currently in use, identified by their string axis identifier + -- @treturn table axes identifiers list + -- @treturn[opt=0.5] number threshold the threshold to detect axes + -- @impl backend + axesInUse = function(threshold) end, + + --- Returns a nice name for the button identifier. + -- Can be locale-depedant and stuff, it's only for display. + -- May returns the raw identifier if you're lazy. + -- @tparam string... button identifier string(s) + -- @treturn string... the displayable names + -- @impl backend + buttonName = function(...) end, + + --- Returns a nice name for the axis identifier. + -- Can be locale-depedant and stuff, it's only for display. + -- May returns the raw identifier if you're lazy. + -- @tparam string... axis identifier string(s) + -- @treturn string... the displayable names + -- @impl backend + axisName = function(...) end, + + ------------------- + --- Other stuff --- + ------------------- + + --- Some default inputs. + -- The backend should bind detectors to thoses inputs (don't recreate them). + -- These are used to provide some common input default detectors to allow to start a game quickly on + -- any platform without having to configure the keys. + -- If some key function in your game match one of theses defaults, using it instead of creating a new + -- input would be a good idea. + -- @impl mixed + default = { + pointer = nil, -- Pointer: used to move and select. Example binds: arrow keys, WASD, stick. + confirm = nil, -- Button: used to confirm something. Example binds: Enter, A button. + cancel = nil -- Button: used to cancel something. Example binds: Escape, B button. + }, + + --- Draw area dimensions. + -- Used for pointers. + -- @impl backend + drawWidth = 1, + drawHeight = 1, + + --- Update all the Inputs. + -- Should be called at every game update; called by ubiquitousse.update. + -- The backend can hook into this function to to its input-related updates. + -- @tparam numder dt the delta-time + -- @impl ubiquitousse + update = function(newDt) + dt = newDt + updated = {} + end +} + +-- Create default inputs +input.default.pointer = input.pointer() +input.default.confirm = input.button() +input.default.cancel = input.button() + +return input diff --git a/scene/init.lua b/scene/init.lua new file mode 100644 index 0000000..c81986e --- /dev/null +++ b/scene/init.lua @@ -0,0 +1 @@ +return require((...)..".scene") \ No newline at end of file diff --git a/scene.lua b/scene/scene.lua similarity index 70% rename from scene.lua rename to scene/scene.lua index b8df9e7..084b982 100644 --- a/scene.lua +++ b/scene/scene.lua @@ -1,6 +1,7 @@ --- ubiquitousse.scene -local uqt = require((...):match("^(.-ubiquitousse)%.")) -local m = uqt.module +--- ubiquitousse.scene +-- Optional dependencies: ubiquitousse.time (to provide each scene a time registry) +local loaded, time = pcall(require, (...):match("^.-ubiquitousse%.").."time") +if not loaded then time = nil end --- Scene management. -- You can use use scenes to seperate the different states of your game: for example, a menu scene and a game scene. @@ -14,6 +15,7 @@ local m = uqt.module -- of this is that you can load assets, libraries, etc. outside of the enter callback, so they can be cached and not reloaded each time -- the scene is entered, but all the other scene initialization should be done in the enter callback, since it won't be executed on -- each enter otherwise. +-- FIXME: actually more useful to never cache? -- The expected code-organisation is: -- * each scene is in a file, identified by its module name (same identifier used by Lua's require) -- * each scene file create a new scene table using ubiquitousse.scene.new and returns it at the end of the file @@ -26,26 +28,42 @@ scene = setmetatable({ -- @impl ubiquitousse current = nil, + --- Shortcut for scene.current.time. + -- @impl ubiquitousse + time = nil, + --- The scene stack: list of scene, from the farest one to the nearest. -- @impl ubiquitousse stack = {}, - --- A prefix for scene modules names + --- A prefix for scene modules names. + -- Will search in the "scene" directory by default. Redefine it to fit your own ridiculous filesystem. -- @impl ubiquitousse - prefix = "", + prefix = "scene.", - --- Function which load a scene file + --- Function which load a scene file. -- @impl ubiquitousse - load = function(scenePath) + load = function(sceneModule) + local scenePath = sceneModule:gsub("%.", "/") for path in package.path:gmatch("[^;]+") do - path = path:gsub("%?", (scenePath:gsub("%.", "/"))) + path = path:gsub("%?", scenePath) local f = io.open(path) if f then f:close() return dofile(path) end end - error("can't find scene "..tostring(scenePath)) + if package.loaded["candran"] then -- Candran support + for path in package.path:gsub("%.lua", ".can"):gmatch("[^;]+") do + path = path:gsub("%?", scenePath) + local f = io.open(path) + if f then + f:close() + return package.loaded["candran"].dofile(path) + end + end + end + error("can't find scene "..tostring(sceneModule)) end, --- Creates and returns a new Scene object. @@ -55,7 +73,7 @@ scene = setmetatable({ return { name = name or "unamed", -- The scene name. - time = m.time and uqt.time.new(), -- Scene-specific TimerRegistry, if uqt.time is enabled. + time = time and time.new(), -- Scene-specific TimerRegistry, if uqt.time is available. enter = function(self, ...) end, -- Called when entering a scene. exit = function(self) end, -- Called when exiting a scene, and not expecting to come back (scene may be unloaded). @@ -63,22 +81,24 @@ scene = setmetatable({ suspend = function(self) end, -- Called when suspending a scene, and expecting to come back (scene won't be unloaded). resume = function(self) end, -- Called when resuming a suspended scene (after calling suspend). - update = function(self, dt, ...) end, -- Called on each ubiquitousse.event.update on the current scene. - draw = function(self, ...) end -- Called on each ubiquitousse.event.draw on the current scene. + update = function(self, dt, ...) end, -- Called on each update on the current scene. + draw = function(self, ...) end -- Called on each draw on the current scene. } end, + -- TODO: handle love.quit / exit all scenes in stack --- Switch to a new scene. -- The new scene will be loaded and the current scene will be replaced by the new one, -- then the previous scene exit function will be called, then the enter callback is called on the new scence. -- Then the stack is changed to replace the old scene with the new one. - -- @tparam string scenePath the new scene module name + -- @tparam string/table scenePath the new scene module name, or the scene table directly -- @param ... arguments to pass to the scene's enter function -- @impl ubiquitousse switch = function(scenePath, ...) local previous = scene.current - scene.current = scene.load(scene.prefix..scenePath) - scene.current.name = scene.current.name or scenePath + scene.current = type(scenePath) == "string" and scene.load(scene.prefix..scenePath) or scenePath + scene.time = scene.current.time + scene.current.name = scene.current.name or tostring(scenePath) if previous then previous:exit() end scene.current:enter(...) scene.stack[math.max(#scene.stack, 1)] = scene.current @@ -88,13 +108,14 @@ scene = setmetatable({ -- Similar to ubiquitousse.scene.switch, except suspend is called on the current scene instead of exit, -- and the current scene is not replaced: when the new scene call ubiquitousse.scene.pop, the old scene -- will be reused. - -- @tparam string scenePath the new scene module name + -- @tparam string/table scenePath the new scene module name, or the scene table directly -- @param ... arguments to pass to the scene's enter function -- @impl ubiquitousse push = function(scenePath, ...) local previous = scene.current - scene.current = scene.load(scene.prefix..scenePath) - scene.current.name = scene.current.name or scenePath + scene.current = type(scenePath) == "string" and scene.load(scene.prefix..scenePath) or scenePath + scene.time = scene.current.time + scene.current.name = scene.current.name or tostring(scenePath) if previous then previous:suspend() end scene.current:enter(...) table.insert(scene.stack, scene.current) @@ -107,25 +128,26 @@ scene = setmetatable({ pop = function() local previous = scene.current scene.current = scene.stack[#scene.stack-1] + scene.time = scene.current.time if previous then previous:exit() end if scene.current then scene.current:resume() end table.remove(scene.stack) end, --- Update the current scene. - -- Should be called in ubiquitousse.event.update. + -- Should be called at every game update; called by ubiquitousse.update. -- @tparam number dt the delta-time (milisecond) -- @param ... arguments to pass to the scene's update function after dt -- @impl ubiquitousse update = function(dt, ...) if scene.current then - if m.time then scene.current.time.update(dt) end + if time then scene.current.time:update(dt) end scene.current:update(dt, ...) end end, --- Draw the current scene. - -- Should be called in ubiquitousse.event.draw. + -- Should be called every time the game is draw; called by ubiquitousse.draw. -- @param ... arguments to pass to the scene's draw function -- @impl ubiquitousse draw = function(...) diff --git a/time.lua b/time.lua deleted file mode 100644 index e41467a..0000000 --- a/time.lua +++ /dev/null @@ -1,333 +0,0 @@ --- ubiquitousse.time -local ease = require((...):match("^(.-ubiquitousse)%.")..".lib.easing") - ---- Returns true if all the values in the list are true ; functions in the list will be called and the test will be performed on their return value. --- Returns default if the list is empty. -local function all(list, default) - if #list == 0 then - return default - else - local r = true - for _,v in ipairs(list) do - if type(v) == "function" then - r = r and v() - else - r = r and v - end - end - return r - end -end - ---- Time related functions -local function newTimerRegistry() - --- Used to store all the functions delayed with ubiquitousse.time.delay - -- The default implementation use the structure { = , ...} - -- This table is for internal use and shouldn't be used from an external script. - local delayed = {} - - -- Used to calculate the deltatime - local lastTime - - local registry - registry = { - --- Creates and return a new TimerRegistry. - -- A TimerRegistry is a separate ubiquitousse.time instance: its TimedFunctions will be independant - -- from the one registered using ubiquitousse.time.run (the global TimerRegistry). If you use the scene - -- system, a scene-specific TimerRegistry is available at ubiquitousse.scene.current.time. - -- @impl ubiquitousse - new = function() - local new = newTimerRegistry() - new.get = registry.get - return new - end, - - --- Returns the number of miliseconds elapsed since some point in time. - -- This point is fixed but undetermined, so this function should only be used to calculate durations. - -- Should at least have millisecond-precision, but can be more precise if available. - -- @impl backend - get = function() end, - - --- Update all the TimedFunctions calls. - -- Supposed to be called in ubiquitousse.event.update. - -- @tparam[opt=calculate here] number dt the delta-time (time spent since last time the function was called) (miliseconds) - -- @impl ubiquitousse - update = function(dt) - if dt then - registry.dt = dt - else - if lastTime then - local newTime = registry.get() - registry.dt = newTime - lastTime - lastTime = newTime - else - lastTime = registry.get() - end - end - - local done = {} -- functions done running - - local d = delayed - for func, t in pairs(d) do - if t and all(t.initWhen, true) then - t.initWhen = {} - local co = t.coroutine - t.after = t.after - dt - if t.forceStart or (t.after <= 0 and all(t.startWhen, true)) then - t.startWhen = {} - d[func] = false -- niling here cause the next pair iteration to error - table.insert(done, func) - if not co then - co = coroutine.create(func) - t.coroutine = co - t.started = registry.get() - if t.times > 0 then t.times = t.times - 1 end - for _, f in ipairs(t.onStart) do f(t.object) end - end - assert(coroutine.resume(co, function(delay) - t.after = delay or 0 - d[func] = t - coroutine.yield() - end, dt)) - for _, f in ipairs(t.onUpdate) do f(t.object) end - if all(t.stopWhen, false) then t.forceStop = true end - if t.forceStop or coroutine.status(co) == "dead" then - if t.forceStop - or (t.during >= 0 and t.started + t.during < registry.get()) - or (t.times == 0) - or (not all(t.repeatWhile, true)) - or (t.every == -1 and t.times == -1 and t.during == -1 and #t.repeatWhile == 0) -- no repeat - then - for _, f in ipairs(t.onEnd) do f(t.object) end - else - if t.times > 0 then t.times = t.times - 1 end - t.after = t.every - t.coroutine = coroutine.create(func) - d[func] = t - end - end - end - end - end - - for _, func in ipairs(done) do - if not d[func] then - d[func] = nil - end - end - end, - - --- Schedule a function to run. - -- The function will receive as first parameter the wait(time) function, which will pause the function execution for time miliseconds. - -- @tparam[opt] function func the function to schedule - -- @treturn TimedFunction the object - -- @impl ubiquitousse - run = function(func) - -- Creates empty function (the TimedFunction may be used for time measure or stuff like that which doesn't need a specific function) - func = func or function() end - - -- Since delayed functions can end in any order, it doesn't really make sense to use a integer-keyed list. - -- Using the function as the key works and it's unique. - delayed[func] = { - object = nil, - coroutine = nil, - started = 0, - - after = -1, - every = -1, - times = -1, - during = -1, - - initWhen = {}, - startWhen = {}, - repeatWhile = {}, - stopWhen = {}, - - forceStart = false, - forceStop = false, - - onStart = {}, - onUpdate = {}, - onEnd = {} - } - - local t = delayed[func] -- internal data - local r -- external interface - r = { - --- Timed conditions --- - --- Wait time milliseconds before running the function. - after = function(_, time) - t.after = time - return r - end, - --- Run the function every time millisecond. - every = function(_, time) - t.every = time - return r - end, - --- The function will not execute more than count times. - times = function(_, count) - t.times = count - return r - end, - --- The TimedFunction will be active for a time duration. - during = function(_, time) - t.during = time - return r - end, - - --- Function conditions --- - --- Starts the function execution when func() returns true. Checked before the "after" condition, - -- meaning the "after" countdown starts when func() returns true. - -- If multiple init functions are added, init will trigger only when all of them returns true. - initWhen = function(_, func) - table.insert(t.initWhen, func) - return r - end, - --- Starts the function execution when func() returns true. Checked after the "after" condition. - -- If multiple start functions are added, start will trigger only when all of them returns true. - startWhen = function(_, func) - table.insert(t.startWhen, func) - return r - end, - --- When the functions ends, the execution won't stop and will repeat as long as func() returns true. - -- Will cancel timed repeat conditions if false but needs other timed repeat conditions to be true to create a new repeat. - -- If multiple repeat functions are added, a repeat will trigger only when all of them returns true. - repeatWhile = function(_, func) - table.insert(t.repeatWhile, func) - return r - end, - --- Stops the function execution when func() returns true. Checked before all timed conditions. - -- If multiple stop functions are added, stop will trigger only when all of them returns true. - stopWhen = function(_, func) - table.insert(t.stopWhen, func) - return r - end, - - --- Conditions override --- - --- Force the function to start its execution. - start = function(_) - t.forceStart = true - return r - end, - --- Force the function to stop its execution. - stop = function(_) - t.forceStop = true - return r - end, - - --- Callbacks functions --- - --- Will execute func(self) when the function execution start. - onStart = function(_, func) - table.insert(t.onStart, func) - return r - end, - --- Will execute func(self) each frame the main function is run.. - onUpdate = function(_, func) - table.insert(t.onUpdate, func) - return r - end, - --- Will execute func(self) when the function execution end. - onEnd = function(_, func) - table.insert(t.onEnd, func) - return r - end, - - --- Chaining --- - --- Creates another TimedFunction which will be initialized when the current one ends. - -- Returns the new TimedFunction. - chain = function(_, func) - local done = false - r:onEnd(function() done = true end) - return registry.run(func) - :initWhen(function() return done end) - end - } - t.object = r - return r - end, - - --- Tween some numeric values. - -- @tparam number duration tween duration (miliseconds) - -- @tparam table tbl the table containing the values to tween - -- @tparam table to the new values - -- @tparam[opt="linear"] string/function method tweening method (string name or the actual function(time, start, change, duration)) - -- @treturn TimedFunction the object. A duration is already defined, and the :chain methods takes the same arguments as tween (and creates a tween). - -- @impl ubiquitousse - tween = function(duration, tbl, to, method) - method = method or "linear" - method = type(method) == "string" and ease[method] or method - - local time = 0 -- tweening time elapsed - local from = {} -- initial state - - local function update(tbl_, from_, to_) -- apply the method to tbl_ recursively (doesn't handle cycles) - for k, v in pairs(to_) do - if type(v) == "table" then - update(tbl_[k], from_[k], to_[k]) - else - if time < duration then - tbl_[k] = method(time, from_[k], v - from_[k], duration) - else - tbl_[k] = v - end - end - end - end - - local r = registry.run(function(wait, dt) - time = time + dt - update(tbl, from, to) - end):during(duration) - :onStart(function() - local function copy(stencil, source, dest) -- copy initial state recursively - for k, v in pairs(stencil) do - if type(v) == "table" then - if not dest[k] then dest[k] = {} end - copy(stencil[k], source[k], dest[k]) - else - dest[k] = source[k] - end - end - end - copy(to, tbl, from) - end) - - --- Creates another tween which will be initialized when the current one ends. - -- If tbl_ and/or method_ are not specified, the values from the current tween will be used. - -- Returns the new tween. - r.chain = function(_, duration_, tbl_, to_, method_) - if not method_ and to_ then - if type(to_) == "string" then - tbl_, to_, method_ = tbl, tbl_, to_ - else - method_ = method - end - elseif not method_ and not to_ then - tbl_, to_, method_ = tbl, tbl_, method - end - - local done = false - r:onEnd(function() done = true end) - return registry.tween(duration_, tbl_, to_, method_) - :initWhen(function() return done end) - end - - return r - end, - - --- Cancels all the running TimedFunctions. - -- @impl ubiquitousse - clear = function() - delayed = {} - end, - - --- Time since last update (miliseconds). - -- @impl ubiquitousse - dt = 0 - } - - return registry -end - -return newTimerRegistry() diff --git a/time/backend/ctrulua.lua b/time/backend/ctrulua.lua new file mode 100644 index 0000000..35e86e5 --- /dev/null +++ b/time/backend/ctrulua.lua @@ -0,0 +1,6 @@ +local time = require((...):match("^(.-%.)backend").."time") +local ctr = require("ctr") + +time.get = ctr.time + +return time \ No newline at end of file diff --git a/time/backend/love.lua b/time/backend/love.lua new file mode 100644 index 0000000..2d46124 --- /dev/null +++ b/time/backend/love.lua @@ -0,0 +1,7 @@ +local time = require((...):match("^(.-%.)backend").."time") + +time.get = function() + return love.timer.getTime() * 1000 +end + +return time \ No newline at end of file diff --git a/lib/easing.lua b/time/easing.lua similarity index 100% rename from lib/easing.lua rename to time/easing.lua diff --git a/time/init.lua b/time/init.lua new file mode 100644 index 0000000..291b3ef --- /dev/null +++ b/time/init.lua @@ -0,0 +1,14 @@ +local time + +local p = ... +if love then + time = require(p..".backend.love") +elseif package.loaded["ctr"] then + time = require(p..".backend.ctrulua") +elseif package.loaded["libretro"] then + error("NYI") +else + error("no backend for ubiquitousse.time") +end + +return time diff --git a/time/time.lua b/time/time.lua new file mode 100644 index 0000000..e8074b6 --- /dev/null +++ b/time/time.lua @@ -0,0 +1,350 @@ +--- ubiquitousse.time +-- Depends on a backend. + +local ease = require((...):match("^.-time")..".easing") +local time + +--- Returns true if all the values in the list are true ; functions in the list will be called and the test will be performed on their return value. +-- Returns default if the list is empty. +local function all(list, default) + if #list == 0 then + return default + else + local r = true + for _,v in ipairs(list) do + if type(v) == "function" then + r = r and v() + else + r = r and v + end + end + return r + end +end + +--- Registry methods. +local registry_mt = { + --- Update all the TimedFunctions calls. + -- Should be called at every game update; called by ubiquitousse.update. + -- @tparam[opt=calculate here] number dt the delta-time (time spent since last time the function was called) (miliseconds) + -- @impl ubiquitousse + update = function(self, dt) + local currentTime = time.get() + + if not dt then + dt = currentTime - self.lastTime + self.lastTime = currentTime + end + self.dt = dt + + local done = {} -- functions done running + + local d = self.delayed + for func, t in pairs(d) do + if t and all(t.initWhen, true) then + t.initWhen = {} + local co = t.coroutine + t.after = t.after - dt + if t.forceStart or (t.after <= 0 and all(t.startWhen, true)) then + t.startWhen = {} + d[func] = false -- niling here cause the next pair iteration to error + table.insert(done, func) + if not co then + co = coroutine.create(func) + t.coroutine = co + t.started = currentTime + if t.times > 0 then t.times = t.times - 1 end + for _, f in ipairs(t.onStart) do f(t.object) end + end + assert(coroutine.resume(co, function(delay) + t.after = delay or 0 + d[func] = t + coroutine.yield() + end, dt)) + for _, f in ipairs(t.onUpdate) do f(t.object) end + if all(t.stopWhen, false) then t.forceStop = true end + if t.forceStop or coroutine.status(co) == "dead" then + if t.forceStop + or (t.during >= 0 and t.started + t.during < currentTime) + or (t.times == 0) + or (not all(t.repeatWhile, true)) + or (t.every == -1 and t.times == -1 and t.during == -1 and #t.repeatWhile == 0) -- no repeat + then + for _, f in ipairs(t.onEnd) do f(t.object) end + else + if t.times > 0 then t.times = t.times - 1 end + t.after = t.every + t.coroutine = coroutine.create(func) + d[func] = t + end + end + end + end + end + + for _, func in ipairs(done) do + if not d[func] then + d[func] = nil + end + end + end, + + --- Schedule a function to run. + -- The function will receive as first parameter the wait(time) function, which will pause the function execution for time miliseconds. + -- As a second parameter, the function will receive the delta time (dt). + -- @tparam[opt] function func the function to schedule + -- @treturn TimedFunction the object + -- @impl ubiquitousse + run = function(self, func) + -- Creates empty function (the TimedFunction may be used for time measure or stuff like that which doesn't need a specific function) + func = func or function() end + + -- Since delayed functions can end in any order, it doesn't really make sense to use a integer-keyed list. + -- Using the function as the key works and it's unique. + self.delayed[func] = { + object = nil, + coroutine = nil, + started = 0, + + after = -1, + every = -1, + times = -1, + during = -1, + + initWhen = {}, + startWhen = {}, + repeatWhile = {}, + stopWhen = {}, + + forceStart = false, + forceStop = false, + + onStart = {}, + onUpdate = {}, + onEnd = {} + } + + local t = self.delayed[func] -- internal data + local r -- external interface + r = { + --- Timed conditions --- + --- Wait time milliseconds before running the function. + after = function(_, time) + t.after = time + return r + end, + --- Run the function every time millisecond. + every = function(_, time) + t.every = time + return r + end, + --- The function will not execute more than count times. + times = function(_, count) + t.times = count + return r + end, + --- The TimedFunction will be active for a time duration. + during = function(_, time) + t.during = time + return r + end, + + --- Function conditions --- + --- Starts the function execution when func() returns true. Checked before the "after" condition, + -- meaning the "after" countdown starts when func() returns true. + -- If multiple init functions are added, init will trigger only when all of them returns true. + initWhen = function(_, func) + table.insert(t.initWhen, func) + return r + end, + --- Starts the function execution when func() returns true. Checked after the "after" condition. + -- If multiple start functions are added, start will trigger only when all of them returns true. + startWhen = function(_, func) + table.insert(t.startWhen, func) + return r + end, + --- When the functions ends, the execution won't stop and will repeat as long as func() returns true. + -- Will cancel timed repeat conditions if false but needs other timed repeat conditions to be true to create a new repeat. + -- If multiple repeat functions are added, a repeat will trigger only when all of them returns true. + repeatWhile = function(_, func) + table.insert(t.repeatWhile, func) + return r + end, + --- Stops the function execution when func() returns true. Checked before all timed conditions. + -- If multiple stop functions are added, stop will trigger only when all of them returns true. + stopWhen = function(_, func) + table.insert(t.stopWhen, func) + return r + end, + + --- Conditions override --- + --- Force the function to start its execution. + start = function(_) + t.forceStart = true + return r + end, + --- Force the function to stop its execution. + stop = function(_) + t.forceStop = true + return r + end, + + --- Callbacks functions --- + --- Will execute func(self) when the function execution start. + onStart = function(_, func) + table.insert(t.onStart, func) + return r + end, + --- Will execute func(self) each frame the main function is run. + onUpdate = function(_, func) + table.insert(t.onUpdate, func) + return r + end, + --- Will execute func(self) when the function execution end. + onEnd = function(_, func) + table.insert(t.onEnd, func) + return r + end, + + --- Chaining --- + --- Creates another TimedFunction which will be initialized when the current one ends. + -- Returns the new TimedFunction. + chain = function(_, func) + local done = false + r:onEnd(function() done = true end) + return self:run(func) + :initWhen(function() return done end) + end + } + t.object = r + return r + end, + + --- Tween some numeric values. + -- @tparam number duration tween duration (miliseconds) + -- @tparam table tbl the table containing the values to tween + -- @tparam table to the new values + -- @tparam[opt="linear"] string/function method tweening method (string name or the actual function(time, start, change, duration)) + -- @treturn TimedFunction the object. A duration is already defined, and the :chain methods takes the same arguments as tween (and creates a tween). + -- @impl ubiquitousse + tween = function(self, duration, tbl, to, method) + method = method or "linear" + method = type(method) == "string" and ease[method] or method + + local time = 0 -- tweening time elapsed + local from = {} -- initial state + + local function update(tbl_, from_, to_) -- apply the method to tbl_ recursively (doesn't handle cycles) + for k, v in pairs(to_) do + if type(v) == "table" then + update(tbl_[k], from_[k], to_[k]) + else + if time < duration then + tbl_[k] = method(time, from_[k], v - from_[k], duration) + else + tbl_[k] = v + end + end + end + end + + local r = self:run(function(wait, dt) + time = time + dt + update(tbl, from, to) + end):during(duration) + :onStart(function() + local function copy(stencil, source, dest) -- copy initial state recursively + for k, v in pairs(stencil) do + if type(v) == "table" then + if not dest[k] then dest[k] = {} end + copy(stencil[k], source[k], dest[k]) + else + dest[k] = source[k] + end + end + end + copy(to, tbl, from) + end) + + --- Creates another tween which will be initialized when the current one ends. + -- If tbl_ and/or method_ are not specified, the values from the current tween will be used. + -- Returns the new tween. + r.chain = function(_, duration_, tbl_, to_, method_) + if not method_ and to_ then + if type(to_) == "string" then + tbl_, to_, method_ = tbl, tbl_, to_ + else + method_ = method + end + elseif not method_ and not to_ then + tbl_, to_, method_ = tbl, tbl_, method + end + + local done = false + r:onEnd(function() done = true end) + return self:tween(duration_, tbl_, to_, method_) + :initWhen(function() return done end) + end + + return r + end, + + --- Cancels all the running TimedFunctions. + -- @impl ubiquitousse + clear = function(self) + self.delayed = {} + end +} +registry_mt.__index = registry_mt + +--- Time related functions +time = { + --- Creates and return a new TimerRegistry. + -- A TimerRegistry is a separate ubiquitousse.time instance: its TimedFunctions will be independant + -- from the one registered using ubiquitousse.time.run (the global TimerRegistry). If you use the scene + -- system, a scene-specific TimerRegistry is available at ubiquitousse.scene.current.time. + -- @impl ubiquitousse + new = function() + return setmetatable({ + --- Used to store all the functions delayed with ubiquitousse.time.delay + -- The default implementation use the structure { = , ...} + -- This table is for internal use and shouldn't be used from an external script. + delayed = {}, + + -- Used to calculate the deltatime + lastTime = time.get(), + + --- Time since last timer update (miliseconds). + dt = 0 + }, registry_mt) + end, + + --- Returns the number of miliseconds elapsed since some point in time. + -- This point is fixed but undetermined, so this function should only be used to calculate durations. + -- Should at least have millisecond-precision, but can be more precise if available. + -- @impl backend + get = function() end, + + --- Time since last update (miliseconds). + -- @impl ubiquitousse + dt = 0, + + --- Global TimerRegistry. + -- @impl ubiquitousse + delayed = {}, + lastTime = 0, + update = function(...) + return registry_mt.update(time, ...) + end, + run = function(...) + return registry_mt.run(time, ...) + end, + tween = function(...) + return registry_mt.tween(time, ...) + end, + clear = function(...) + return registry_mt.clear(time, ...) + end +} + +return time diff --git a/todo.txt b/todo.txt index 62ba984..8c9938b 100644 --- a/todo.txt +++ b/todo.txt @@ -1,9 +1,10 @@ -Ubiquitousse, also known as "The World's Best Video Game Engine Of All Time", despite its perfectness, is still not perfect. +Ubiquitousse, also known as "The World's Best Video Game Framework Of All Time", despite its perfectness, is still not perfect. More specifically, what is lacking to officially turn Ubiquitousse into a sacred text, is: - An i18n API. While some languages are clearly superior to others, the general consensus seems to think otherwise. Ubiquitousse should be able to get an ordered list of prefered languages and provide translation helpers. See The Pong. - Some API are still lacking an API and/or implementation. Search "TODO" for more information. -- A filesystem API, to access the game's filesystem. May also rewrite Lua's io functions, see the next item. +- A filesystem API, to access the game's filesystem. May also rewrite Lua's io functions, see the next item. Though the LÖVE API is fine. +- An audio and draw API. There were one but it was simply plagiarized from LÖVE, so, meh. Might just provide LÖVE API compat layers for other backends in the end. - A sandboxing system. Ubiquitousse should be able to run in a Ubiquitousse game safely. Since Ubiquitousse can run itself, it will then seems reasonable to claim Ubiquitousse run the universe. See World Domination for Dummies. - A libretro backend, so Ubiquitousse really become ubiquitous. If you didn't know, it's the goal. diff --git a/util.lua b/util.lua deleted file mode 100644 index f56ab77..0000000 --- a/util.lua +++ /dev/null @@ -1,52 +0,0 @@ --- ubiquitousse.util - ---- Various functions useful for game developement. --- No dependicy on either ubiquitousse or a ubiquitousse backend. -local util -util = { - --- AABB collision check. - -- @tparam number x1 first rectangle top-left x coordinate - -- @tparam number y1 first rectangle top-left y coordinate - -- @tparam number w1 first rectangle width - -- @tparam number h1 first rectangle height - -- @tparam number x2 second rectangle top-left x coordinate - -- @tparam number y2 second rectangle top-left y coordinate - -- @tparam number w2 second rectangle width - -- @tparam number h2 second rectangle height - -- @treturn true if the objects collide, false otherwise - aabb = function(x1, y1, w1, h1, x2, y2, w2, h2) - if w1 < 0 then x1 = x1 + w1; w1 = -w1 end - if h1 < 0 then y1 = y1 + h1; h1 = -h1 end - if w2 < 0 then x2 = x2 + w2; w2 = -w2 end - if h2 < 0 then y2 = y2 + h2; h2 = -h2 end - return x1 + w1 >= x2 and x1 <= x2 + w2 and - y1 + h1 >= y2 and y1 <= y2 + h2 - end, - - --- Remove the first occurence of an element in a table. - -- @tparam table t the table - -- @param x the element to remove - -- @return x - remove = function(t, x) - for i, v in ipairs(t) do - if v == x then - table.remove(t, i) - break - end - end - return x - end, - - --- Returns a new table where the keys and values have been inverted. - -- @tparam table t the table - -- @treturn table the inverted table - invert = function(t) - local r = {} - for k, v in pairs(t) do - r[v] = k - end - return r - end -} - -return util diff --git a/util/init.lua b/util/init.lua new file mode 100644 index 0000000..3ad5ea1 --- /dev/null +++ b/util/init.lua @@ -0,0 +1 @@ +return require((...)..".util") \ No newline at end of file diff --git a/util/util.lua b/util/util.lua new file mode 100644 index 0000000..64ab55b --- /dev/null +++ b/util/util.lua @@ -0,0 +1,336 @@ +--- ubiquitousse.util +-- No dependency. + +--- Various functions useful for game developement. +local util, group_mt +util = { + ------------------- + --- Basic maths --- + ------------------- + + --- AABB collision check. + -- @tparam number x1 first rectangle top-left x coordinate + -- @tparam number y1 first rectangle top-left y coordinate + -- @tparam number w1 first rectangle width + -- @tparam number h1 first rectangle height + -- @tparam number x2 second rectangle top-left x coordinate + -- @tparam number y2 second rectangle top-left y coordinate + -- @tparam number w2 second rectangle width + -- @tparam number h2 second rectangle height + -- @treturn true if the objects collide, false otherwise + aabb = function(x1, y1, w1, h1, x2, y2, w2, h2) + if w1 < 0 then x1 = x1 + w1; w1 = -w1 end + if h1 < 0 then y1 = y1 + h1; h1 = -h1 end + if w2 < 0 then x2 = x2 + w2; w2 = -w2 end + if h2 < 0 then y2 = y2 + h2; h2 = -h2 end + return x1 + w1 >= x2 and x1 <= x2 + w2 and + y1 + h1 >= y2 and y1 <= y2 + h2 + end, + + ----------------------- + --- List operations --- + ----------------------- + + --- Remove the first occurence of an element in a list. + -- @tparam table t the list + -- @param x the element to remove + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @return x + remove = function(t, x, n) + n = n or #t + for i=1, n do + if t[i] == x then + table.remove(t, i) + break + end + end + return x + end, + + --- Extract the list of elements with a specific key from a list of tables + -- @tparam table t the list of tables + -- @param k the chosen key + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @treturn the extracted table + extract = function(t, k, n) + n = n or #t + local r = {} + for i=1, n do + r[i] = t[i][k] + end + return r + end, + + --- Applies a function to every item in list t. + -- The function receive two argument: the value and then the key. + -- @tparam table t initial list + -- @tparam function fn the function to apply + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @treturn table the initial list + each = function(t, fn, n) + n = n or #t + for i=1, n do + fn(t[i], i) + end + return t + end, + + --- Applies a function to every item in list t and returns the associated new list. + -- The function receive two argument: the value and then the key. + -- @tparam table t initial list + -- @tparam function fn the function to apply + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @treturn table the new list + map = function(t, fn, n) + n = n or #t + local r = {} + for i=1, n do + r[i] = fn(t[i], i) + end + return r + end, + + --- Test if all the values in the list are true. Optionnaly applies a function to get the truthness. + -- The function receive two argument: the value and then the key. + -- @tparam table t initial list + -- @tparam function fn the function to apply + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @treturn boolean result + all = function(t, fn, n) + n = n or #t + fn = fn or function(v) return v end + local r = true + for i=1, n do + r = r and fn(t[i], i) + end + return r + end, + + --- Test if at least one value in the list is true. Optionnaly applies a function to get the truthness. + -- The function receive two argument: the value and then the key. + -- @tparam table t initial list + -- @tparam function fn the function to apply + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @treturn boolean result + any = function(t, fn, n) + n = n or #t + fn = fn or function(v) return v end + for i=1, n do + if fn(t[i], i) then + return true + end + end + return false + end, + + ----------------------------- + --- Dictionary operations --- + ----------------------------- + + --- Returns a new table where the keys and values have been inverted. + -- @tparam table t the table + -- @treturn table the inverted table + invert = function(t) + local r = {} + for k, v in pairs(t) do + r[v] = k + end + return r + end, + + --- Perform a deep copy of a table. + -- The copied table will keep the share the same metatable as the original table. + -- Note this uses pairs() to perform the copy, which will honor the __pairs methamethod if present. + -- @tparam table t the table + -- @treturn table the copied table + copy = function(t, cache) + if cache == nil then cache = {} end + local r = {} + cache[t] = r + for k, v in pairs(t) do + if type(v) == "table" then + r[k] = cache[v] or util.copy(v, cache) + else + r[k] = v + end + end + return setmetatable(r, getmetatable(t)) + end, + + ----------------------- + --- Random and UUID --- + ----------------------- + + --- Generate a UUID v4. + -- @treturn string the UUID in its canonical representation + uuid4 = function() + return ("xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx") -- version 4 + :gsub("N", math.random(0x8, 0xb)) -- variant 1 + :gsub("x", function() return ("%x"):format(math.random(0x0, 0xf)) end) -- random hexadecimal digit + end, + + ----------------------- + --- Object grouping --- + ----------------------- + + --- Groups objects in a meta-object-proxy-thingy. + -- Works great with Lua 5.2+. LuaJit requires to be built with Lua 5.2 compatibility enabled to support group comparaison. + -- @tparam table list of objects + -- @tparam[opt=#t] number n the number of expected elements in the list, including nil values + -- @tparam[opt=nil] table p list of parents. Used to find the first arguments of method calls. + -- @treturn group object + group = function(t, n, p) + n = n or #t + return setmetatable({ _n = n, _t = t, _p = p or false }, group_mt) + end +} + +group_mt = { + --- Everything but comparaison: returns a new group + __add = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v + other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self + v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v + other end, self._n), self._n) + end + end, + __sub = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v - other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self - v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v - other end, self._n), self._n) + end + end, + __mul = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v * other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self * v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v * other end, self._n), self._n) + end + end, + __div = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v / other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self / v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v / other end, self._n), self._n) + end + end, + __mod = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v % other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self % v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v % other end, self._n), self._n) + end + end, + __pow = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v ^ other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self ^ v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v ^ other end, self._n), self._n) + end + end, + __unm = function(self) + return util.group(util.map(self._t, function(v) return -v end, self._n), self._n) + end, + __concat = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.group(util.map(self._t, function(v, i) return v .. other._t[i] end, self._n), self._n) + else + return util.group(util.map(other._t, function(v) return self .. v end, self._n), self._n) + end + else + return util.group(util.map(self._t, function(v) return v .. other end, self._n), self._n) + end + end, + __len = function(self) + return util.group(util.map(self._t, function(v) return #v end, self._n), self._n) + end, + __index = function(self, k) + return util.group(util.extract(self._t, k, self._n), self._n, self._t) + end, + + --- Comparaison: returns true if true for every object of the group + __eq = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.all(self._t, function(v, i) return v == other._t[i] end, self._n) + else + return util.all(other._t, function(v) return self == v end, self._n) + end + else + return util.all(self._t, function(v) return v == other end, self._n) + end + end, + __lt = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.all(self._t, function(v, i) return v < other._t[i] end, self._n) + else + return util.all(other._t, function(v) return self < v end, self._n) + end + else + return util.all(self._t, function(v) return v < other end, self._n) + end + end, + __le = function(self, other) + if getmetatable(other) == group_mt then + if getmetatable(self) == group_mt then + return util.all(self._t, function(v, i) return v <= other._t[i] end, self._n) + else + return util.all(other._t, function(v) return self <= v end, self._n) + end + else + return util.all(self._t, function(v) return v <= other end, self._n) + end + end, + + --- Special cases + __newindex = function(self, k, v) + if getmetatable(v) == group_mt then -- unpack + util.each(self._t, function(t, i) t[k] = v._t[i] end, self._n) + else + util.each(self._t, function(t) t[k] = v end, self._n) + end + end, + __call = function(self, selfArg, ...) + if getmetatable(selfArg) == group_mt and self._p then -- method call + local a = {...} + return util.group(util.map(self._t, function(v, i) return v(self._p[i], unpack(a)) end, self._n), self._n) + else + local a = {selfArg, ...} + return util.group(util.map(self._t, function(v) return v(unpack(a)) end, self._n), self._n) + end + end, + + --- Full-blown debugger + __tostring = function(self) + return ("group{%s}"):format(table.concat(util.map(self._t, tostring, self._n), ", ")) + end +} + +return util