diff --git a/main.xml b/main.xml index c345f9f..08758e4 100644 --- a/main.xml +++ b/main.xml @@ -19,7 +19,7 @@ -- https://github.com/XeroOl/notitg-mirin/blob/0fbff2ee93d905feeb58c4aac4fe7f5f9ebc9647/template/std.lua#L17 oat.package = { -- uranium template loader path - path = 'src/?.lua;src/?/init.lua;template/?.lua', + path = 'src/?.lua;src/?/init.lua;template/?.lua;template/?/init.lua', preload = {}, loaded = {}, loaders = { @@ -106,12 +106,33 @@ oat.dw = DISPLAY:GetDisplayWidth() oat.dh = DISPLAY:GetDisplayHeight() + oat.useProfiler = false + oat.profilerInfo = {} + local uraniumFunc = {} + local debugCache = {} function uraniumFunc:call(event, ...) if self._callbacks[event] then + profilerInfo[event] = {} for _, callback in ipairs(self._callbacks[event]) do - callback(unpack(arg)) + local start = os.clock() + local res = callback(unpack(arg)) + local dur = os.clock() - start + + if oat.useProfiler then + if not debugCache[callback] then + debugCache[callback] = debug.getinfo(callback, 'Sl') -- cached cus debug.getinfo is EXPENSIVE + end + local finfo = debugCache[callback] + + table.insert(profilerInfo[event], { + src = finfo.short_src .. ':' .. finfo.linedefined, + t = dur + }) + end + + if res ~= nil then return res end end end end @@ -200,6 +221,7 @@ end end end + resetActor = reset -- runs once during ScreenReadyCommand, before the user code is loaded -- hides various actors that are placed by the theme diff --git a/stdlib/mirin/ease.lua b/stdlib/mirin/ease.lua new file mode 100644 index 0000000..c8b8471 --- /dev/null +++ b/stdlib/mirin/ease.lua @@ -0,0 +1,339 @@ +---@diagnostic disable: lowercase-global +-- Convenience shortcuts + +local sqrt = math.sqrt +local sin = math.sin +local asin = math.asin +local cos = math.cos +local pow = math.pow +local exp = math.exp +local pi = math.pi +local abs = math.abs + +xero() +-- ===================================================================== -- + +-- Utility functions + +--- Flip any easing function, making it go from 1 to 0 +-- Example use: +-- ```lua +-- ease {0, 20, flip(outQuad), 50, 'modname'} +-- ``` +flip = setmetatable({}, { + __call = function(self, fn) + self[fn] = self[fn] or function(x) return 1 - fn(x) end + return self[fn] + end +}) + +-- Mix two easing functions together into a new ease +-- the new ease starts by acting like the first argument, and then ends like the second argument +-- Example: ease {0, 20, blendease(inQuad, outQuad), 100, 'modname'} +blendease = setmetatable({}, { + __index = function(self, key) + self[key] = {} + return self[key] + end, + __call = function(self, fn1, fn2) + if not self[fn1][fn2] then + local transient1 = fn1(1) <= 0.5 + local transient2 = fn2(1) <= 0.5 + if transient1 and not transient2 then + error('blendease: the first argument is a transient ease, but the second argument doesn\'t match') + end + if transient2 and not transient1 then + error('blendease: the second argument is a transient ease, but the first argument doesn\'t match') + end + self[fn1][fn2] = function(x) + local mixFactor = 3*x^2-2*x^3 + return (1 - mixFactor) * fn1(x) + mixFactor * fn2(x) + end + end + return self[fn1][fn2] + end +}) + +local function param1cache(self, param1) + self.cache[param1] = self.cache[param1] or function(x) + return self.fn(x, param1) + end + return self.cache[param1] +end + +local param1mt = { + __call = function(self, x, param1) + return self.fn(x, param1 or self.dp1) + end, + __index = { + param = param1cache, + params = param1cache, + } +} + +-- Declare an easing function taking one custom parameter +function with1param(fn, defaultparam1) + return setmetatable({ + fn = fn, + dp1 = defaultparam1, + cache = {}, + }, param1mt) +end + +local function param2cache(self, param1, param2) + self.cache[param1] = self.cache[param1] or {} + self.cache[param1][param2] = self.cache[param1][param2] or function(x) + return self.fn(x, param1, param2) + end + return self.cache[param1][param2] +end + +local param2mt = { + __call = function(self, x, param1, param2) + return self.fn(x, param1 or self.dp1, param2 or self.dp2) + end, + __index = { + param=param2cache, + params=param2cache, + } +} + +-- Declare an easing function taking two custom parameters +function with2params(fn, defaultparam1, defaultparam2) + return setmetatable({ + fn = fn, + dp1 = defaultparam1, + dp2 = defaultparam2, + cache = {}, + }, param2mt) +end + +-- ===================================================================== -- + +-- Easing functions + +function bounce(t) return 4 * t * (1 - t) end +function tri(t) return 1 - abs(2 * t - 1) end +function bell(t) return inOutQuint(tri(t)) end +function pop(t) return 3.5 * (1 - t) * (1 - t) * sqrt(t) end +function tap(t) return 3.5 * t * t * sqrt(1 - t) end +function pulse(t) return t < .5 and tap(t * 2) or -pop(t * 2 - 1) end + +function spike(t) return exp(-10 * abs(2 * t - 1)) end +function inverse(t) return t * t * (1 - t) * (1 - t) / (0.5 - t) end + +local function popElasticInternal(t, damp, count) + return (1000 ^ -(t ^ damp) - 0.001) * sin(count * pi * t) +end + +local function tapElasticInternal(t, damp, count) + return (1000 ^ -((1 - t) ^ damp) - 0.001) * sin(count * pi * (1 - t)) +end + +local function pulseElasticInternal(t, damp, count) + if t < .5 then + return tapElasticInternal(t * 2, damp, count) + else + return -popElasticInternal(t * 2 - 1, damp, count) + end +end + +popElastic = with2params(popElasticInternal, 1.4, 6) +tapElastic = with2params(tapElasticInternal, 1.4, 6) +pulseElastic = with2params(pulseElasticInternal, 1.4, 6) + +impulse = with1param(function(t, damp) + t = t ^ damp + return t * (1000 ^ -t - 0.001) * 18.6 +end, 0.9) + +function instant() return 1 end +function linear(t) return t end +function inQuad(t) return t * t end +function outQuad(t) return -t * (t - 2) end +function inOutQuad(t) + t = t * 2 + if t < 1 then + return 0.5 * t ^ 2 + else + return 1 - 0.5 * (2 - t) ^ 2 + end +end +function outInQuad(t) + t = t * 2 + if t < 1 then + return 0.5 - 0.5 * (1 - t) ^ 2 + else + return 0.5 + 0.5 * (t - 1) ^ 2 + end +end +function inCubic(t) return t * t * t end +function outCubic(t) return 1 - (1 - t) ^ 3 end +function inOutCubic(t) + t = t * 2 + if t < 1 then + return 0.5 * t ^ 3 + else + return 1 - 0.5 * (2 - t) ^ 3 + end +end +function outInCubic(t) + t = t * 2 + if t < 1 then + return 0.5 - 0.5 * (1 - t) ^ 3 + else + return 0.5 + 0.5 * (t - 1) ^ 3 + end +end +function inQuart(t) return t * t * t * t end +function outQuart(t) return 1 - (1 - t) ^ 4 end +function inOutQuart(t) + t = t * 2 + if t < 1 then + return 0.5 * t ^ 4 + else + return 1 - 0.5 * (2 - t) ^ 4 + end +end +function outInQuart(t) + t = t * 2 + if t < 1 then + return 0.5 - 0.5 * (1 - t) ^ 4 + else + return 0.5 + 0.5 * (t - 1) ^ 4 + end +end +function inQuint(t) return t ^ 5 end +function outQuint(t) return 1 - (1 - t) ^ 5 end +function inOutQuint(t) + t = t * 2 + if t < 1 then + return 0.5 * t ^ 5 + else + return 1 - 0.5 * (2 - t) ^ 5 + end +end +function outInQuint(t) + t = t * 2 + if t < 1 then + return 0.5 - 0.5 * (1 - t) ^ 5 + else + return 0.5 + 0.5 * (t - 1) ^ 5 + end +end +function inExpo(t) return 1000 ^ (t - 1) - 0.001 end +function outExpo(t) return 1.001 - 1000 ^ -t end +function inOutExpo(t) + t = t * 2 + if t < 1 then + return 0.5 * 1000 ^ (t - 1) - 0.0005 + else + return 1.0005 - 0.5 * 1000 ^ (1 - t) + end +end +function outInExpo(t) + if t < 0.5 then + return outExpo(t * 2) * 0.5 + else + return inExpo(t * 2 - 1) * 0.5 + 0.5 + end +end +function inCirc(t) return 1 - sqrt(1 - t * t) end +function outCirc(t) return sqrt(-t * t + 2 * t) end +function inOutCirc(t) + t = t * 2 + if t < 1 then + return 0.5 - 0.5 * sqrt(1 - t * t) + else + t = t - 2 + return 0.5 + 0.5 * sqrt(1 - t * t) + end +end +function outInCirc(t) + if t < 0.5 then + return outCirc(t * 2) * 0.5 + else + return inCirc(t * 2 - 1) * 0.5 + 0.5 + end +end +function outBounce(t) + if t < 1 / 2.75 then + return 7.5625 * t * t + elseif t < 2 / 2.75 then + t = t - 1.5 / 2.75 + return 7.5625 * t * t + 0.75 + elseif t < 2.5 / 2.75 then + t = t - 2.25 / 2.75 + return 7.5625 * t * t + 0.9375 + else + t = t - 2.625 / 2.75 + return 7.5625 * t * t + 0.984375 + end +end +function inBounce(t) return 1 - outBounce(1 - t) end +function inOutBounce(t) + if t < 0.5 then + return inBounce(t * 2) * 0.5 + else + return outBounce(t * 2 - 1) * 0.5 + 0.5 + end +end +function outInBounce(t) + if t < 0.5 then + return outBounce(t * 2) * 0.5 + else + return inBounce(t * 2 - 1) * 0.5 + 0.5 + end +end +function inSine(x) return 1 - cos(x * (pi * 0.5)) end +function outSine(x) return sin(x * (pi * 0.5)) end +function inOutSine(x) + return 0.5 - 0.5 * cos(x * pi) +end +function outInSine(t) + if t < 0.5 then + return outSine(t * 2) * 0.5 + else + return inSine(t * 2 - 1) * 0.5 + 0.5 + end +end + +function outElasticInternal(t, a, p) + return a * pow(2, -10 * t) * sin((t - p / (2 * pi) * asin(1/a)) * 2 * pi / p) + 1 +end +local function inElasticInternal(t, a, p) + return 1 - outElasticInternal(1 - t, a, p) +end +function inOutElasticInternal(t, a, p) + return t < 0.5 + and 0.5 * inElasticInternal(t * 2, a, p) + or 0.5 + 0.5 * outElasticInternal(t * 2 - 1, a, p) +end +function outInElasticInternal(t, a, p) + return t < 0.5 + and 0.5 * outElasticInternal(t * 2, a, p) + or 0.5 + 0.5 * inElasticInternal(t * 2 - 1, a, p) +end + +inElastic = with2params(inElasticInternal, 1, 0.3) +outElastic = with2params(outElasticInternal, 1, 0.3) +inOutElastic = with2params(inOutElasticInternal, 1, 0.3) +outInElastic = with2params(outInElasticInternal, 1, 0.3) + +function inBackInternal(t, a) return t * t * (a * t + t - a) end +function outBackInternal(t, a) t = t - 1 return t * t * ((a + 1) * t + a) + 1 end +function inOutBackInternal(t, a) + return t < 0.5 + and 0.5 * inBackInternal(t * 2, a) + or 0.5 + 0.5 * outBackInternal(t * 2 - 1, a) +end +function outInBackInternal(t, a) + return t < 0.5 + and 0.5 * outBackInternal(t * 2, a) + or 0.5 + 0.5 * inBackInternal(t * 2 - 1, a) +end + +inBack = with1param(inBackInternal, 1.70158) +outBack = with1param(outBackInternal, 1.70158) +inOutBack = with1param(inOutBackInternal, 1.70158) +outInBack = with1param(outInBackInternal, 1.70158) diff --git a/stdlib/mirin/init.lua b/stdlib/mirin/init.lua new file mode 100644 index 0000000..47794b6 --- /dev/null +++ b/stdlib/mirin/init.lua @@ -0,0 +1,19 @@ +xero = oat +xero.foreground = oat._main +xero.MIRIN_VERSION = 'URANIUM-5.0.1' + + +-- Load all of the core .lua files +-- The order DOES matter here: +-- std.lua needs to be loaded first +-- template.lua needs to be last +require('stdlib.mirin.std') +require('stdlib.mirin.sort') +require('stdlib.mirin.ease') +require('stdlib.mirin.template') + +local xeroActorsAF = Quad() + +function uranium.init() + xero.init_command(xeroActorsAF) +end \ No newline at end of file diff --git a/stdlib/mirin/sort.lua b/stdlib/mirin/sort.lua new file mode 100644 index 0000000..65da792 --- /dev/null +++ b/stdlib/mirin/sort.lua @@ -0,0 +1,152 @@ +--[[ +this is based on code from Dirk Laurie and Steve Fisher, +used under license as follows: + + + Copyright © 2013 Dirk Laurie and Steve Fisher. + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the 'Software'), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + +(modifications by Max Cahill 2018, 2020) +(modifications by XeroOl 2021) + +Found at: https://github.com/1bardesign/batteries/blob/master/sort.lua +]] + +-- `sort` object, container of the following methods +local sort = {} +-- Tunable threshold, deciding between the insertion sort and merge sort +sort.max_chunk_size = 32 + + +-- ===================================================================== -- + +-- Internal implementations + + +-- Insertion sort on a section of an array +function sort._insertion_sort_impl(array, first, last, less) + for i = first + 1, last do + local k = first + local v = array[i] + for j = i, first + 1, -1 do + if less(v, array[j - 1]) then + array[j] = array[j - 1] + else + k = j + break + end + end + array[k] = v + end +end + +-- Merge sort on two sorted portions of an array +function sort._merge(array, workspace, low, middle, high, less) + local i, j, k + i = 1 + -- copy first half of array to auxiliary array + for j = low, middle do + workspace[i] = array[j] + i = i + 1 + end + -- sieve through + i = 1 + j = middle + 1 + k = low + while true do + if (k >= j) or (j > high) then + break + end + if less(array[j], workspace[i]) then + array[k] = array[j] + j = j + 1 + else + array[k] = workspace[i] + i = i + 1 + end + k = k + 1 + end + -- copy back any remaining elements of first half + for k = k, j - 1 do + array[k] = workspace[i] + i = i + 1 + end +end + +-- Recursive merge sort implementation +function sort._merge_sort_impl(array, workspace, low, high, less) + if high - low <= sort.max_chunk_size then + sort._insertion_sort_impl(array, low, high, less) + else + local middle = math.floor((low + high) / 2) + sort._merge_sort_impl(array, workspace, low, middle, less) + sort._merge_sort_impl(array, workspace, middle + 1, high, less) + sort._merge(array, workspace, low, middle, high, less) + end +end + +-- Default comparison function: sort from smallest to biggest +local function default_less(a, b) + return a < b +end + +-- Setup a sorting algorithm, check the validity of the comparator, +-- and determine if a case is trivial (no sorting needed) +function sort._sort_setup(array, less) + less = less or default_less + local n = #array + --trivial cases; empty or 1 element + local trivial = (n <= 1) + if not trivial then + --check less + if less(array[1], array[1]) then + error('invalid order function for sorting; less(v, v) should not be true for any v.') + end + end + --setup complete + return trivial, n, less +end + +-- Public method: merge sort on an array. If the array length is +-- less than `max_chunk_size`, an insertion sort will be done instead. +function sort.stable_sort(array, less) + --setup + local trivial, n, less = sort._sort_setup(array, less) + if not trivial then + --temp storage; allocate ahead of time + local workspace = {} + local middle = math.ceil(n / 2) + workspace[middle] = array[1] + --dive in + sort._merge_sort_impl( array, workspace, 1, n, less ) + end + return array +end + +-- Public method (currently not exposed): insertion sort +function sort.insertion_sort(array, less) + --setup + local trivial, n, less = sort._sort_setup(array, less) + if not trivial then + sort._insertion_sort_impl(array, 1, n, less) + end + return array +end + +-- Exports +xero.unstable_sort = table.sort +xero.stable_sort = sort.stable_sort diff --git a/stdlib/mirin/std.lua b/stdlib/mirin/std.lua new file mode 100644 index 0000000..3040d1b --- /dev/null +++ b/stdlib/mirin/std.lua @@ -0,0 +1,249 @@ +-- Environments +--[[ +-- Create the xero environment, in which everything is +setmetatable(xero, { + -- if something isn't found in the xero table, fall back to a _G lookup + __index = _G, + + -- Calling xero() sets the environment of the current function + -- Calling xero(func) returns `func` with the xero environment applied + __call = function(self, f) + setfenv(f or 2, self) + return f + end +}) + +-- make require work +xero.package = { + -- mirin template loader path + path = 'lua/?.lua;lua/?/init.lua;plugins/?.lua;plugins/?/init.lua', + preload = {}, + loaded = {}, + loaders = { + function(modname) + local preload = xero.package.preload[modname] + return preload or 'no field xero.package.preload[\''..modname..'\']' + end, + function(modname) + local errors = {} + -- get the filename + local filename = string.gsub(modname, '%.', '/') + for path in (string.gfind or string.gmatch)(xero.package.path, '[^;]+') do + -- get the file path + local filepath = xero.dir .. string.gsub(path, '%?', filename) + -- check if file exists + if not GAMESTATE:GetFileStructure(filepath) then + table.insert(errors, 'no file \''..filepath..'\'') + else + local loader, err = loadfile(filepath) + -- check if file loads properly + if err then + error(err, 3) + elseif loader then + return xero(loader) + end + end + end + return table.concat(errors, '\n') + end, + }, +} + +function xero.require(modname) + local loaded = xero.package.loaded + if not loaded[modname] then + local errors = {'module \''..modname..'\' not found:'} + local chunk + for _, loader in ipairs(xero.package.loaders) do + local result = loader(modname) + if type(result) == 'string' then + table.insert(errors, result) + elseif type(result) == 'function' then + chunk = result + break + end + end + if not chunk then + error(table.concat(errors, '\n'), 2) + end + loaded[modname] = chunk() + if loaded[modname] == nil then + loaded[modname] = true + end + end + return loaded[modname] +end + +-- Apply the environment. :) +xero() + +--- Create the strict environment, forbidding creating any variable +--- This environment is not related to the xero table, and won't fetch +--- values from it, unless explicitely prefixed with `xero.` +xero.strict = setmetatable({}, { + -- Allow access to _G elements, containing all game methods + __index = _G, + -- Prevent creating any variable + __newindex = function(s, t) + error(t) + end +}) + +]] +-- ===================================================================== -- + +-- Utility functions + + +--- Returns a shallow copy of the table `src` +function copy(src) + local dest = {} + for k, v in pairs(src) do + dest[k] = v + end + return dest +end + +-- Clear a table's contents, leaving it empty. +-- Useful for resetting a table containing metatables. +function clear(t) + for k, v in pairs(t) do + t[k] = nil + end + return t +end + +-- Clear a table's contents, when the table only contains 'logical' indexes +-- (as in: contiguous numerical indexes from 1 to #table) +function iclear(t) + for i = 1, #t do + table.remove(t) + end + return t +end + +-- Move global functions to the xero table, allowing for slightly faster +-- performance due to not having to go back and forth between xero and _G. +--[[ +xero.xero = _G.xero +xero.type = _G.type +xero.print = _G.print +xero.pairs = _G.pairs +xero.ipairs = _G.ipairs +xero.unpack = _G.unpack +xero.tonumber = _G.tonumber +xero.tostring = _G.tostring +xero.math = copy(_G.math) +xero.table = copy(_G.table) +xero.string = copy(_G.string) +]] + +-- ===================================================================== -- + +-- Data structure for all the `func` declarations. +-- This custom data structure smartly handles func priorities, so the order +-- they're declared in mods.xml is respected no matter what. +-- This data structure is generic enough to be used for any context, but +-- that is not the case for now. + + +local methods = {} + +function methods:add(obj) + local stage = self.stage + self.n = self.n + 1 + stage.n = stage.n + 1 + stage[stage.n] = obj +end + +function methods:remove() + local swap = self.swap + swap[swap.n] = nil + swap.n = swap.n - 1 + self.n = self.n - 1 +end + +function methods:next() + if self.n == 0 then return end + + local swap = self.swap + local stage = self.stage + local list = self.list + + if swap.n == 0 then + stable_sort(stage, self.reverse_comparator) + end + if stage.n == 0 then + if list.n == 0 then + while swap.n ~= 0 do + list.n = list.n + 1 + list[list.n] = swap[swap.n] + swap[swap.n] = nil + swap.n = swap.n - 1 + end + else + swap.n = swap.n + 1 + swap[swap.n] = list[list.n] + list[list.n] = nil + list.n = list.n - 1 + end + else + if list.n == 0 then + swap.n = swap.n + 1 + swap[swap.n] = stage[stage.n] + stage[stage.n] = nil + stage.n = stage.n - 1 + else + if self.comparator(list[list.n], stage[stage.n]) then + swap.n = swap.n + 1 + swap[swap.n] = list[list.n] + list[list.n] = nil + list.n = list.n - 1 + else + swap.n = swap.n + 1 + swap[swap.n] = stage[stage.n] + stage[stage.n] = nil + stage.n = stage.n - 1 + end + end + end + return swap[swap.n] +end + +local mt = {__index = methods} + +function perframe_data_structure(comparator) + return setmetatable({ + comparator = comparator, + reverse_comparator = function(a, b) return comparator(b, a) end, + stage = {n = 0}, + list = {n = 0}, + swap = {n = 0}, + n = 0, + }, mt) +end + +-- the behavior of a stringbuilder +local stringbuilder_mt = { + __index = { + -- :build() method converts a stringbuilder into a string, with optional delimiter + build = table.concat, + -- :clear() method empties the stringbuilder + clear = iclear, + }, + + -- calling a stringbuilder appends to it + __call = function(self, a) + table.insert(self, tostring(a)) + return self + end, + + -- stringbuilder can convert to a string + __tostring = table.concat, +} + +-- stringbuilder constructor +function stringbuilder() + return setmetatable({}, stringbuilder_mt) +end + diff --git a/stdlib/mirin/template.lua b/stdlib/mirin/template.lua new file mode 100644 index 0000000..a6d8093 --- /dev/null +++ b/stdlib/mirin/template.lua @@ -0,0 +1,1585 @@ +-- Enable strict mode for this file +-- See template/std.xml for more details +--setfenv(1, xero.strict) + + +-- ===================================================================== -- + +-- Convenience shortcuts / options + +local max_pn = 8 -- default: `8` +local debug_print_applymodifier_input = false -- default: `false` +local debug_print_mod_targets = false -- default: `false` + +local foreground = xero.foreground +local copy = xero.copy +local clear = xero.clear +local stringbuilder = xero.stringbuilder +local stable_sort = xero.stable_sort +local perframe_data_structure = xero.perframe_data_structure +local instant = xero.instant + + + +-- ===================================================================== -- + +-- The global data structures used in the program +-- These are declared early so that other functions can easily fill them in + +-- eases :: list of {beat/time, len, ease_, *args, pn = number, start_time = number} and a couple of other optional string keys +-- table for eases/add/set/acc/reset +-- +-- pn must be present, and must be a number. +-- start_time must be present, and is the time that the effect starts *in seconds* +-- +-- Before ready_command is run: +-- * Is in the order that the mods have been inserted +-- * You can insert into this table +-- After ready_command is run: +-- * is sorted by start_time with insertion order as tiebreaker +-- * ease/add/set/acc should not insert anymore, because it will ruin the sort order. +local eases = {} + +-- funcs :: list of {beat, len?, fn}, and a couple of other optional string keys +-- the table of scheduled functions and perframes +-- +-- if the len is present, it will be treated as a perframe, otherwise a func. +local funcs = {} + +-- auxes :: table where auxes[modname] = true when modname is auxed +-- auxilliary variables +-- +-- when a mod is "auxed", it won't be applied via ApplyModifiers to the game. +-- This usually means that the mod has an in-template implementation in lua. +-- When a mod isn't auxed, it will be handled by the c++ game engine source code. +local auxes = {} + +-- nodes :: list of {list inputs, function(inputs) -> outputs, list outputs} +-- stores nodes / definemods +-- +-- After the nodes are compiled, it changes format to something different. +local nodes = {} + +-- aliases :: table where aliases[old] = new +-- mods that should be renamed. +-- +-- The replacement happens in the resolve_aliases function. +-- This table is stored in lowercase. +local aliases = {} + +-- touched_mods :: table where touched_mods[pn][mod] = true +-- mods whose values have changed that need to be applied +local touched_mods = {} +for pn = 1, max_pn do + touched_mods[pn] = {} +end + +-- default_mods :: table where default_mods[mod] = number +-- store the default values for every mod +local default_mods = {} + +-- use metatables to prefill the default_mods table with 0 +setmetatable(default_mods, { + __index = function(self, i) + self[i] = 0 + return 0 + end +}) + + +local song = GAMESTATE:GetCurrentSong() + +-- ===================================================================== -- + +-- Functions + + +-- the `plr=` system +local default_plr = {1, 2} + +-- for reading the `plr` variable from the xero environment +-- without falling back to the global table +local function get_plr() + return rawget(xero, 'plr') or default_plr +end + +local banned_chars = {} +local banned_chars_string = '\'\\{}(),;* ' +for i = 1, #banned_chars_string do + banned_chars[string.sub(banned_chars_string, i, i)] = true +end + +-- A mod name isn't valid if it would cause problems when put into +-- the "*-1 100 {}" format that GAMESTATE:ApplyModifiers expects. +-- For example, the space in 'invert ' means the game engine would treat +-- it identically to regular 'invert', which is why 'invert ' should be denied. +local function ensure_mod_name_is_valid(name) + if banned_chars[string.sub(name, 1, 1)] or banned_chars[string.sub(name, -1, -1)] then + error( + 'You have a typo in your mod name. '.. + 'You wrote \''..name..'\', but you probably meant '.. + '\''..string.gsub(name, '[\'\\{}(),;* ]', '')..'\'' + ) + end + if string.find(name, '^c[0-9]+$') then + error( + 'You can\'t name your mod \''..name..'\'.\n'.. + 'Use \'cmod\' if you want to set a cmod.' + ) + end + if string.find(name, '^[0-9.]+x$') then + error( + 'You can\'t name your mod \''..name..'\'.\n'.. + 'Use \'xmod\' if you want to set an xmod.' + ) + end +end + +local function normalize_mod_no_checks(name) + name = string.lower(name) + return aliases[name] or name +end + +-- convert a mod to its lowercase dealiased name +local function normalize_mod(name) + if not auxes[name] then ensure_mod_name_is_valid(name) end + return normalize_mod_no_checks(name) +end + + +-- ease {start, len, eas, percent, 'mod'} +-- adds an ease to the ease table +local function ease(self) + + -- Welcome to Ease! + -- + -- -- Flags set by the user + -- * plr: number[] or number or nil + -- * mode: 'end' or nil (also m could be set to 'e') + -- * time: true or nil + -- + -- -- Flags set by the user (but only from `reset`) + -- * only: mod[] or nil + -- * exclude: mod[] or nil + -- + -- -- Set by the other ease functions + -- * relative: true or nil + -- * makes this entry act like `add` + -- * reset: true or nil + -- * activates special reset code later + -- + -- [1]: start beat (or time) + -- [2]: length + -- [3]: the ease + -- [4 + 2*n]: the target mod value + -- [5 + 2*n]: the mod name + + -- convert mode into a regular true or false + self.mode = self.mode == 'end' or self.m == 'e' + + -- convert the ease into relative + if self.mode then + self[2] = self[2] - self[1] + end + + -- convert the start beat into time and store it in start_time + self.start_time = self.time and self[1] or song:GetElapsedTimeFromBeat(self[1]) + + -- future steps assume that plr is a number, so if it's a table, + -- we need to duplicate the entry once for each player number + -- The table is then stored into `eases` for later + local plr = self.plr or get_plr() + if type(plr) == 'table' then + for _, plr in ipairs(plr) do + local new = copy(self) + new.plr = plr + table.insert(eases, new) + end + else + self.plr = plr + table.insert(eases, self) + end + +end + +-- add {start, len, eas, percent, mod} +-- adds an ease to the ease table +local function add(self) + self.relative = true + ease(self) +end + +-- set {start, percent, mod} +-- adds a set to the ease table +local function set(self) + table.insert(self, 2, 0) + table.insert(self, 3, instant) + ease(self) +end + + +-- acc {start, percent, mod} +-- adds a relative set to the ease table +local function acc(self) + self.relative = true + table.insert(self, 2, 0) + table.insert(self, 3, instant) + ease(self) +end + +-- reset {start, [len, eas], [exclude = {mod list}]} +-- adds a reset to the ease table +local function reset(self) + -- if a length and ease aren't provided, use `0, instant` to make it act like `set` + self[2] = self[2] or 0 + self[3] = self[3] or instant + + -- set flag for the `run_eases` function to know that this is a reset entry + self.reset = true + + if self.only then + -- you can pass `only` to reset only a specific set of mods + -- later code assumes this is a table if present, so here, + -- single values need to get wrapped in a table. + if type(self.only) == 'string' then + self.only = {self.only} + end + elseif self.exclude then + -- you can pass `exclude` to exclude a specific set of mods + -- later code assumes this is a table if present, so here, + -- single values need to get wrapped in a table. + if type(self.exclude) == 'string' then + self.exclude = {self.exclude} + end + + -- When exclude is passed in, each mod is a value + -- but it needs to become a table where each mod is a key + local exclude = {} + for _, v in ipairs(self.exclude) do + exclude[v] = true + end + + -- store it back + self.exclude = exclude + end + + -- just use ease to insert it into the ease table + ease(self) +end + +-- func helper for scheduling a function +local function func(self) + -- func {5, 'P1:xy', 2, 3} + if type(self[2]) == 'string' then + local args, syms = {}, {} + for i = 1, #self - 2 do + syms[i] = 'arg' .. i + args[i] = self[i + 2] + end + local symstring = table.concat(syms, ', ') + local code = 'return function('..symstring..') return function() '..self[2]..'('..symstring..') end end' + self[2] = xero(assert(loadstring(code, 'func_generated')))()(unpack(args)) + while self[3] do + table.remove(self) + end + end + self[2], self[3] = nil, self[2] + local persist = self.persist + -- convert mode into a regular true or false + self.mode = self.mode == 'end' or self.m == 'e' + + if type(persist) == 'number' and self.mode then + persist = persist - self[1] + end + if persist == false then + persist = 0.5 + end + if type(persist) == 'number' then + local fn = self[3] + local final_time = self[1] + persist + self[3] = function(beat) + if beat < final_time then + fn(beat) + end + end + end + self.priority = (self.defer and -1 or 1) * (#funcs + 1) + self.start_time = self.time and self[1] or song:GetElapsedTimeFromBeat(self[1]) + table.insert(funcs, self) +end + +local disallowed_poptions_perframe_persist = setmetatable({}, {__index = function(_) + error('you cannot use poptions and persist at the same time. = 5 and table.remove(self, 4) or 0 + local end_percent = #self >= 4 and table.remove(self, 4) or 1 + local end_beat = self[1] + self[2] + + if type(fn) == 'string' then + fn = xero(assert(loadstring('return function(p) '..fn..'(p) end', 'func_generated')))() + end + + self[3] = function(beat) + local progress = (beat - self[1]) / self[2] + fn(start_percent + (end_percent - start_percent) * eas(progress)) + end + + -- it's a function-ease variant, so make it persist + if self.persist ~= false then + local final_percent = eas(1) > 0.5 and end_percent or start_percent + func { + end_beat, + function() + fn(final_percent) + end, + persist = self.persist, + defer = self.defer, + } + end + self.persist = false + perframe(self, true) +end + +-- alias {'old', 'new'} +-- aliases a mod +local function alias(self) + local a, b = self[1], self[2] + a, b = string.lower(a), string.lower(b) + aliases[a] = b +end + +-- setdefault {percent, 'mod'} +-- set the default value of a mod +local function setdefault(self) + for i = 1, #self, 2 do + default_mods[self[i + 1]] = self[i] + end + return setdefault +end + +-- aux {'mod'} +-- mark a mod as an aux, which won't get sent to `ApplyModifiers` +local function aux(self) + if type(self) == 'string' then + local v = self + auxes[v] = true + elseif type(self) == 'table' then + for i = 1, #self do + aux(self[i]) + end + end + return aux +end + +-- node {'inputs', function(inputs) return outputs end, 'outputs'} +-- create a listener that gets run whenever a mod value gets changed +local function node(self) + + if type(self[2]) == 'number' then + -- transform the shorthand into the full version + local multipliers = {} + local i = 2 + while self[i] do + local amt = string.format('p * %f', table.remove(self, i) * 0.01) + table.insert(multipliers, amt) + i = i + 1 + end + local ret = table.concat(multipliers, ', ') + local code = 'return function(p) return '..ret..' end' + local fn = loadstring(code, 'node_generated')() + table.insert(self, 2, fn) + end + + local i = 1 + local inputs = {} + while type(self[i]) == 'string' do + table.insert(inputs, self[i]) + i = i + 1 + end + local fn = self[i] + i = i + 1 + local out = {} + while self[i] do + table.insert(out, self[i]) + i = i + 1 + end + local result = {inputs, out, fn} + result.priority = (self.defer and -1 or 1) * (#nodes + 1) + table.insert(nodes, result) + return node +end + +-- definemod{'mod', function(mod, pn) end} +-- calls aux and node on the provided arguments +local function definemod(self) + for i = 1, #self do + if type(self[i]) ~= 'string' then + break + end + aux(self[i]) + end + node(self) + return definemod +end + + +-- ===================================================================== -- + +-- Runtime + + +-- mod targets are the values that the mod would be at if the current eases finished +local targets = {} +local targets_mt = {__index = default_mods} +for pn = 1, max_pn do + targets[pn] = setmetatable({}, targets_mt) +end + +-- the live value of the current mods. Gets recomputed each frame +local mods = {} +local mods_mt = {} +for pn = 1, max_pn do + mods_mt[pn] = {__index = targets[pn]} + mods[pn] = setmetatable({}, mods_mt[pn]) +end + +-- a stringbuilder of the modstring that is being applied +local mod_buffer = {} +for pn = 1, max_pn do + mod_buffer[pn] = stringbuilder() +end + +-- data structure for nodes +local node_start = {} + +-- keep track of which players are awake +local last_seen_awake = {} + +-- poptions table +local poptions = {} +local poptions_logging_target +for pn = 1, max_pn do + local pn = pn + local mods_pn = mods[pn] + local mt = { + __index = function(_, k) + return mods_pn[normalize_mod_no_checks(k)] + end, + __newindex = function(_, k, v) + k = normalize_mod_no_checks(k) + mods_pn[k] = v + if v then + poptions_logging_target[pn][k] = true + end + end, + } + poptions[pn] = setmetatable({}, mt) +end + +local function touch_mod(mod, pn) + -- run metatables to ensure that the mod gets applied this frame + if pn then + mods[pn][mod] = mods[pn][mod] + else + -- if no player is provided, run for every player + for pn = 1, max_pn do + touch_mod(mod, pn) + end + end +end + +local function touch_all_mods(pn) + for mod in pairs(default_mods) do + touch_mod(mod) + end + if pn then + for mod in pairs(targets[pn]) do + touch_mod(mod, pn) + end + else + for pn = 1, max_pn do + for mod in pairs(targets[pn]) do + touch_mod(mod, pn) + end + end + end +end + +-- runs once during OnCommand +-- takes Name= actors and places them in the xero table +local function scan_named_actors() + local mt = {} + function mt.__index(self, k) + self[k] = setmetatable({}, mt) + return self[k] + end + local actors = setmetatable({}, mt) + local list = {} + local code = stringbuilder() + local function sweep(actor, skip) + if actor.GetNumChildren then + for i = 0, actor:GetNumChildren() - 1 do + sweep(actor:GetChildAt(i)) + end + end + if skip then + return + end + local name = actor:GetName() + if name and name ~= '' then + if loadstring('t.'..name..'=t') then + table.insert(list, actor) + code'actors.'(name)' = list['(#list)']\n' + else + SCREENMAN:SystemMessage('invalid actor name: \''..name..'\'') + end + end + end + + code'return function(list, actors)\n' + sweep(foreground, true) + code'end' + + local load_actors = xero(assert(loadstring(code:build())))() + load_actors(list, actors) + + local function clear_metatables(tab) + setmetatable(tab, nil) + for _, obj in pairs(tab) do + if type(obj) == 'table' and getmetatable(obj) == mt then + clear_metatables(obj) + end + end + end + clear_metatables(actors) + + for name, actor in pairs(actors) do + xero[name] = actor + end + +end + +local function on_command(self) + scan_named_actors() + self:queuecommand('Ready') +end + +-- runs once during ScreenReadyCommand, before the user code is loaded +-- hides various actors that are placed by the theme +local function hide_theme_actors() + for _, element in ipairs { + 'Overlay', 'Underlay', + 'ScoreP1', 'ScoreP2', + 'LifeP1', 'LifeP2', + } do + local child = SCREENMAN(element) + if child then child:hidden(1) end + end +end + +-- runs once during ScreenReadyCommand, before the user code is loaded +-- sets up the player tables +local P = {} +local function prepare_variables() + for pn = 1, max_pn do + local player = SCREENMAN('PlayerP' .. pn) + xero['P' .. pn] = player + P[pn] = player + end + xero.P = P +end + +-- runs once during ScreenReadyCommand, after the user code is loaded +-- sorts the mod tables so that things can be processed in order +-- after the mod tables are sorted, no more calls to table-inserting functions are allowed +local function sort_tables() + -- sort eases by their start time, with resets running first if there's a tie break + -- it's a stable sort, so other ties are broken based on insertion order + stable_sort(eases, function(a, b) + if a.start_time == b.start_time then + return a.reset and not b.reset + else + return a.start_time < b.start_time + end + end) + + -- sort the funcs by their start time and priority + -- the priority matches the insertion order unless the user added `defer = true`, + -- in which case the priority will be negative + stable_sort(funcs, function(a, b) + if a.start_time == b.start_time then + local x, y = a.priority, b.priority + return x * x * y < x * y * y + else + return a.start_time < b.start_time + end + end) + + -- sort the nodes by their priority + -- the priority matches the insertion order unless the user added `defer = true`, + -- in which case the priority will be negative + stable_sort(nodes, function(a, b) + local x, y = a.priority, b.priority + return x * x * y < x * y * y + end) +end + +-- runs once during ScreenReadyCommand, after the user code is loaded +-- replaces aliases with their respective mods +local function resolve_aliases() + -- aux + local old_auxes = copy(auxes) + clear(auxes) + for mod, _ in pairs(old_auxes) do + -- auxes bypass name checks + auxes[normalize_mod_no_checks(mod)] = true + end + -- ease + for _, e in ipairs(eases) do + for i = 5, #e, 2 do + e[i] = normalize_mod(e[i]) + end + if e.exclude then + local exclude = {} + for k, v in pairs(e.exclude) do + exclude[normalize_mod(k)] = v + end + e.exclude = exclude + end + if e.only then + for i = 1, #e.only do + e.only[i] = normalize_mod(e.only[i]) + end + end + end + -- node + for _, node_entry in ipairs(nodes) do + local input = node_entry[1] + local output = node_entry[2] + for i = 1, #input do input[i] = normalize_mod(input[i]) end + for i = 1, #output do output[i] = normalize_mod(output[i]) end + end + -- default_mods + local old_default_mods = copy(default_mods) + clear(default_mods) + for mod, percent in pairs(old_default_mods) do + local normalized = normalize_mod(mod) + default_mods[normalized] = percent + for pn = 1, max_pn do + touched_mods[pn][normalized] = true + end + end +end + +-- runs once during ReadyCommand +local function compile_nodes() + local terminators = {} + for _, nd in ipairs(nodes) do + for _, mod in ipairs(nd[2]) do + terminators[mod] = true + end + end + local priority = -1 * (#nodes + 1) + for k, _ in pairs(terminators) do + table.insert(nodes, {{k}, {}, nil, nil, nil, nil, nil, true, priority = priority}) + end + local start = node_start + local locked = {} + local last = {} + for _, nd in ipairs(nodes) do + -- struct node { + -- list inputs; + -- list out; + -- lua_function fn; + -- list children; + -- list> parents; // the inner lists also have a [0] field that is a boolean + -- lua_function real_fn; + -- list> outputs; + -- bool terminator; + -- int seen; + -- } + local terminator = nd[8] + if not terminator then + nd[4] = {} -- children + nd[7] = {} -- outputs + for pn = 1, max_pn do + nd[7][pn] = {} + end + end + nd[5] = {} -- parents + local inputs = nd[1] + local out = nd[2] + local fn = nd[3] + local parents = nd[5] + local outputs = nd[7] + local reverse_in = {} + for i, v in ipairs(inputs) do + reverse_in[v] = true + start[v] = start[v] or {} + parents[i] = {} + if not start[v][locked] then + table.insert(start[v], nd) + end + if start[v][locked] then + parents[i][0] = true + end + for _, pre in ipairs(last[v] or {}) do + table.insert(pre[4], nd) + table.insert(parents[i], pre[7]) + end + end + for _, v in ipairs(out) do + if reverse_in[v] then + start[v][locked] = true + last[v] = {nd} + elseif not last[v] then + last[v] = {nd} + else + table.insert(last[v], nd) + end + end + + local function escapestr(s) + return '\'' .. string.gsub(s, '[\\\']', '\\%1') .. '\'' + end + local function list(code, i, sep) + if i ~= 1 then code(sep) end + end + + local code = stringbuilder() + local function emit_inputs() + for i, mod in ipairs(inputs) do + list(code, i, ',') + for j = 1, #parents[i] do + list(code, j, '+') + code'parents['(i)']['(j)'][pn]['(escapestr(mod))']' + end + if not parents[i][0] then + list(code, #parents[i] + 1, '+') + code'mods[pn]['(escapestr(mod))']' + end + end + end + local function emit_outputs() + for i, mod in ipairs(out) do + list(code, i, ',') + code'outputs[pn]['(escapestr(mod))']' + end + return out[1] + end + code + 'return function(outputs, parents, mods, fn)\n' + 'return function(pn)\n' + if terminator then + code'mods[pn]['(escapestr(inputs[1]))'] = ' emit_inputs() code'\n' + else + if emit_outputs() then code' = ' end code 'fn(' emit_inputs() code', pn)\n' + end + code + 'end\n' + 'end\n' + + local compiled = assert(loadstring(code:build(), 'node_generated'))() + nd[6] = compiled(outputs, parents, mods, fn) + if terminator then + for i, mod in ipairs(inputs) do + touch_mod(mod) + end + else + for pn = 1, max_pn do + nd[6](pn) + end + end + end + + for mod, v in pairs(start) do + v[locked] = nil + end +end + +local function apply_modifiers(str, pn) + GAMESTATE:ApplyModifiers(str, pn) +end + +-- this if statement won't run unless you are mirin +if debug_print_applymodifier_input then + -- luacov: disable + local old_apply_modifiers = apply_modifiers + apply_modifiers = function(str, pn) + if debug_print_applymodifier_input == true or debug_print_applymodifier_input < GAMESTATE:GetSongBeat() then + print('PLAYER ' .. pn .. ': ' .. str) + if debug_print_applymodifier_input ~= true then + apply_modifiers = old_apply_modifiers + end + end + old_apply_modifiers(str, pn) + end + -- luacov: enable +end + +local eases_index = 1 +local active_eases = {} + +local function run_eases(beat, time) + -- {start_beat, len, ease, p0, m0, p1, m1, p2, m2, p3, m3} + -- `eases` is the full sorted timeline of every ease + -- `eases_index` is pointing to the next ease in the timeline that hasn't started yet + while eases_index <= #eases do + local e = eases[eases_index] + -- The ease measures timings by beat by default, or time if time=true was set + local measure = e.time and time or beat + -- if it's not ready, break out of the loop + -- the eases table is sorted, so none of the later eases will be done either + if measure < e[1] then break end + + -- At this point, we've already decided we need to add the ease to the active_eases table + -- The next step is to prepare the entry to go into the `active_eases` table + -- The problem is that the `active_eases` code makes a bunch of assumptions (so it can run faster), so + -- the ease entry needs to be normalized. + -- A "normalized" ease is basically of the form: + -- {beat, len, ease, offset1, mod1, offset2, mod2, ...} + -- + -- Here are some important things that need to be made true for an active ease: + -- * It always lists out all mods being affected + -- * even a 'reset' one + -- * It always has relative numbers for its mods + -- * this makes the logic just work when there's more than one ease touching the same mod + -- * It is relative to the end point, not the start point. + -- * This one is kind of complicated. + -- the ease "commits" its changes to the targets table instantly + -- and the display value only lags behind visually. + + -- plr is just a number at this point, because of the code in `ease` + local plr = e.plr + + -- special cases for reset + if e.reset then + if e.only then + -- Reset only these mods, because only= was set. + for _, mod in ipairs(e.only) do + table.insert(e, default_mods[mod]) + table.insert(e, mod) + end + else + -- Reset any mod that isn't excluded and isn't at its default value. + -- The goal is to normalize the reset into a regular ease entry + -- by just inserting the default values. + for mod in pairs(targets[plr]) do + if not(e.exclude and e.exclude[mod]) and targets[plr][mod] ~= default_mods[mod] then + table.insert(e, default_mods[mod]) + table.insert(e, mod) + end + end + end + end + + + -- If the ease value ends with 0.5 or more, the ease should "stick". + -- Ie, if you use outExpo, the value should stay on until turned off. + -- this is a poor quality comment + local ease_ends_at_different_position = e[3](1) >= 0.5 + e.offset = ease_ends_at_different_position and 1 or 0 + + for i = 4, #e, 2 do + -- If it isn't using relative already, it needs to be adjusted to be relative + -- (ie, like 'add', not like 'ease') + -- Adjusted based on what the current target is set to + -- This is the reason why the sorting the eases table needs to be stable. + if not e.relative then + local mod = e[i + 1] + e[i] = e[i] - targets[plr][mod] + end + + -- Update the target if it needs to be updated + if ease_ends_at_different_position then + local mod = e[i + 1] + targets[plr][mod] = targets[plr][mod] + e[i] + end + end + -- activate the ease and move to the next one + table.insert(active_eases, e) + eases_index = eases_index + 1 + end + + -- Every ease that's active needs to be animated + local active_eases_index = 1 + while active_eases_index <= #active_eases do + local e = active_eases[active_eases_index] + local plr = e.plr + local measure = e.time and time or beat + if measure < e[1] + e[2] then + -- For every active ease, calculate the current magnitude of the ease + local e3 = e[3]((measure - e[1]) / e[2]) - e.offset + -- Go through all of the mods in the ease and write the temporary changes + -- to the mods table. + for i = 4, #e, 2 do + local mod = e[i + 1] + mods[plr][mod] = mods[plr][mod] + e3 * e[i] + end + active_eases_index = active_eases_index + 1 + else + -- If the ease is done, the change to the targets table has already been made + -- so the ease only needs to be removed from the active_eases table. + -- First, we mark the mods as touched, so that eases with length 0 + -- will still apply, even while being active for 0 frames. + for i = 4, #e, 2 do + local mod = e[i + 1] + touch_mod(mod, plr) + end + -- Then, the ease needs to be tossed out. + if active_eases_index ~= #active_eases then + -- Since the order of the active eases table doesn't matter, + -- we can remove an item by moving the last item to the current index. + -- For example, turning the list [1, 2, 3, 4, 5] into [1, 5, 3, 4] removes item 2 + -- This strategy is used because it's faster than calling table.remove with an index + active_eases[active_eases_index] = table.remove(active_eases) + else + -- If it's already at the end of the list, just remove the item with table.remove. + table.remove(active_eases) + end + end + end +end + +local funcs_index = 1 +local active_funcs = perframe_data_structure(function(a, b) + local x, y = a.priority, b.priority + return x * x * y < x * y * y +end) +local function run_funcs(beat, time) + while funcs_index <= #funcs do + local e = funcs[funcs_index] + local measure = e.time and time or beat + if measure < e[1] then break end + if not e[2] then + e[3](measure) + elseif measure < e[1] + e[2] then + active_funcs:add(e) + end + funcs_index = funcs_index + 1 + end + + while true do + local e = active_funcs:next() + if not e then break end + local measure = e.time and time or beat + if measure < e[1] + e[2] then + poptions_logging_target = e.mods + e[3](measure, poptions) + else + if e.mods then + for pn = 1, max_pn do + for mod, _ in pairs(e.mods[pn]) do + touch_mod(mod, pn) + end + end + end + active_funcs:remove() + end + end + +end + +local seen = 1 +local active_nodes = {} +local active_terminators = {} +local propagateAll, propagate +function propagateAll(nodes_to_propagate) + if nodes_to_propagate then + for _, nd in ipairs(nodes_to_propagate) do + propagate(nd) + end + end +end +function propagate(nd) + if nd[9] ~= seen then + nd[9] = seen + if nd[8] then + table.insert(active_terminators, nd) + else + propagateAll(nd[4]) + table.insert(active_nodes, nd) + end + end +end + +local function run_nodes() + for pn = 1, max_pn do + if P[pn] and P[pn]:IsAwake() then + if not last_seen_awake[pn] then + last_seen_awake[pn] = true + for mod, _ in pairs(touched_mods[pn]) do + touch_mod(mod, pn) + touched_mods[pn][mod] = nil + end + end + seen = seen + 1 + for k in pairs(mods[pn]) do + -- identify all nodes to execute this frame + propagateAll(node_start[k]) + end + for _ = 1, #active_nodes do + -- run all the nodes + table.remove(active_nodes)[6](pn) + end + for _ = 1, #active_terminators do + -- run all the nodes marked as 'terminator' + table.remove(active_terminators)[6](pn) + end + else + last_seen_awake[pn] = false + for mod, _ in pairs(mods[pn]) do + mods[pn][mod] = nil + touched_mods[pn][mod] = true + end + end + end +end + +local function run_mods() + -- each player is processed separately + for pn = 1, max_pn do + -- if the player is active + if P[pn] and P[pn]:IsAwake() then + local buffer = mod_buffer[pn] + -- toss everything that isn't an aux into the buffer + for mod, percent in pairs(mods[pn]) do + if not auxes[mod] then + buffer('*-1 '..percent..' '..mod) + end + mods[pn][mod] = nil + end + -- if the buffer has at least 1 item in it + -- then pass it to ApplyModifiers + if buffer[1] then + apply_modifiers(buffer:build(','), pn) + buffer:clear() + end + end + end +end + +-- this if statement won't run unless you are mirin +if debug_print_mod_targets then + -- luacov: disable + func {0, 9e9, function(beat) + if debug_print_mod_targets == true or debug_print_mod_targets < beat then + for pn = 1, max_pn do + if P[pn] and P[pn]:IsAwake() then + local outputs = {} + local i = 0 + for k, v in pairs(targets[pn]) do + if v ~= default_mods[k] then + i = i + 1 + outputs[i] = tostring(k)..': '..tostring(v) + end + end + print('Player '..pn..' at beat '..beat..' --> '..table.concat(outputs, ', ')) + else + print('Player '..pn..' is asleep or missing') + end + end + debug_print_mod_targets = (debug_print_mod_targets == true) + end + end} + -- luacov: enable +end + +local is_beyond_load_command = false + +local function ready_command(self) + hide_theme_actors() + prepare_variables() + foreground:hidden(0) + + -- loads both the plugins and the layout.xml due to propagation + foreground:playcommand('Load') + -- loads mods.lua + --xero.require 'mods' + + sort_tables() + resolve_aliases() + compile_nodes() + + for i = 1, max_pn do + mod_buffer[i]:clear() + end + + -- load command has happened + -- Set this variable so that ease{}s get denied past this point + is_beyond_load_command = true + + -- make sure nodes are up to date + run_nodes() + run_mods() + + self:luaeffect('Update') +end + +local function update_command(self) + self:hidden(1) + + local beat = GAMESTATE:GetSongBeat() + local time = self:GetSecsIntoEffect() + + run_eases(beat, time) + run_funcs(beat, time) + run_nodes() + run_mods() + + -- if no errors have occurred, unhide self + -- to make the update_command run again next frame + self:hidden(0) +end + +--------------------------------------------------------------------------------------- +GAMESTATE:ApplyModifiers('clearall') + + +-- zoom +aux 'zoom' +node { + 'zoom', 'zoomx', 'zoomy', + function(zoom, x, y) + local m = zoom * 0.01 + return m * x, m * y + end, + 'zoomx', 'zoomy', + defer = true, +} + +setdefault { + 100, 'zoom', + 100, 'zoomx', + 100, 'zoomy', + 100, 'zoomz', +} + +setdefault {400, 'grain'} + +-- movex +local function repeat8(a) + return a, a, a, a, a, a, a, a +end + +for _, a in ipairs {'x', 'y', 'z'} do + definemod { + 'move' .. a, + repeat8, + 'move'..a..'0', 'move'..a..'1', 'move'..a..'2', 'move'..a..'3', + 'move'..a..'4', 'move'..a..'5', 'move'..a..'6', 'move'..a..'7', + defer = true, + } +end + +-- xmod +setdefault {1, 'xmod'} +definemod { + 'xmod', 'cmod', + function(xmod, cmod, pn) + if cmod == 0 then + mod_buffer[pn](string.format('*-1 %fx', xmod)) + else + mod_buffer[pn](string.format('*-1 %fx,*-1 c%f', xmod, cmod)) + end + end, + defer = true, +} + + +-- ===================================================================== -- + +-- Error checking + + +local function is_valid_ease(eas) + local err = type(eas) ~= 'function' and (not getmetatable(eas) or not getmetatable(eas).__call) + if not err then + local result = eas(1) + err = type(result) ~= 'number' + end + return not err +end + +local function check_ease_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + if type(self[1]) ~= 'number' then + return 'beat missing' + end + local is_set = name == 'set' or name == 'acc' + if not is_set then + if type(self[2]) ~= 'number' then + return 'len / end missing' + end + if not is_valid_ease(self[3]) then + return 'invalid ease function' + end + end + local i = is_set and 2 or 4 + while self[i] do + if type(self[i]) ~= 'number' then + return 'invalid mod percent' + end + if type(self[i + 1]) ~= 'string' then + return 'invalid mod' + end + i = i + 2 + end + assert(self[i + 1] == nil, 'invalid mod percent: '..tostring(self[i])) + local plr = self.plr or get_plr() + if type(plr) ~= 'number' and type(plr) ~= 'table' then + return 'invalid plr' + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_reset_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + if type(self[1]) ~= 'number' then + return 'the first argument needs to be a number in beats' + end + if self[2] and self[3] then + if type(self[2]) ~= 'number' then + return 'invalid length' + end + if not is_valid_ease(self[3]) then + return 'invalid ease' + end + elseif self[2] or self[3] then + return 'needs both length and ease' + end + if type(self.exclude) ~= 'nil' and type(self.exclude) ~= 'string' and type(self.exclude) ~= 'table' then + return 'invalid `exclude=` value: ' .. tostring(self.exclude) + end + if type(self.only) ~= 'nil' and type(self.only) ~= 'string' and type(self.only) ~= 'table' then + return 'invalid `only=` value: `' .. tostring(self.only) + end + if self.exclude and self.only then + return 'exclude= and only= are mutually exclusive' + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local valid_func_signatures = { + ['number, function'] = true, + ['number, number, function'] = true, + ['number, number, ease, function'] = true, + ['number, number, ease, string'] = true, + ['number, number, ease, number, function'] = true, + ['number, number, ease, number, string'] = true, + ['number, number, ease, number, number, function'] = true, + ['number, number, ease, number, number, string'] = true, + ['number, string, ?'] = true, +} + +local function check_func_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + local beat, fn = self[1], self[2] + if type(beat) ~= 'number' then + return 'the first argument needs to be a number in beats' + end + if type(fn) ~= 'function' and type(fn) ~= 'string' then + return 'the second argument needs to be a function\n(maybe try using func_ease or perframe instead)' + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_func_ease_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + local beat, len, eas, fn = self[1], self[2], self[3], self[#self] + if type(beat) ~= 'number' then + return 'the first argument needs to be a number in beats' + end + if type(len) ~= 'number' then + return 'the second argument needs to be a number in beats' + end + if not is_valid_ease(eas) then + return 'the third argument needs to be an ease' + end + if #self > 5 and type(self[4]) ~= 'number' then + return 'the fourth argument needs to be a percentage' + end + if type(fn) ~= 'function' and type(fn) ~= 'string' then + return 'the last argument needs to be a function to be eased' + end + if #self > 4 and type(self[#self - 1]) ~= 'number' then + return 'the second-to-last argument needs to be a number' + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_perframe_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + local beat, len, fn = self[1], self[2], self[3] + if type(beat) ~= 'number' then + return 'the first argument needs to be a number in beats' + end + if type(len) ~= 'number' then + return 'the second argument needs to be a number in beats' + end + if type(fn) ~= 'function' and type(fn) ~= 'string' then + return 'the third argument needs to be a function' + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_alias_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + local a, b = self[1], self[2] + if type(a) ~= 'string' then + return 'argument 1 should be a string' + end + if type(b) ~= 'string' then + return 'argument 2 should be a string' + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_setdefault_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + for i = 1, #self, 2 do + if type(self[i]) ~= 'number' then + return 'invalid mod percent' + end + if type(self[i + 1]) ~= 'string' then + return 'invalid mod name' + end + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_aux_errrors(self, name) + if type(self) ~= 'string' and type(self) ~= 'table' then + return 'expecting curly braces' + end + if type(self) == 'table' then + for _, v in ipairs(self) do + if type(v) ~= 'string' then + return 'invalid mod to aux: '.. tostring(v) + end + end + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + +local function check_node_errors(self, name) + if type(self) ~= 'table' then + return 'curly braces expected' + end + if type(self[2]) == 'number' then + -- the shorthand version + for i = 2, #self, 2 do + if type(self[i]) ~= 'number' then + return 'invalid mod percent' + end + if type(self[i + 1]) ~= 'string' then + return 'invalid mod name' + end + end + else + -- the function form + local i = 1 + while type(self[i]) == 'string' do + i = i + 1 + end + if i == 1 then + return 'the first argument needs to be the mod name' + end + if type(self[i]) ~= 'function' then + return 'mod definition expected' + end + i = i + 1 + while self[i] do + if type(self[i]) ~= 'string' then + return 'unexpected argument '..tostring(self[i])..', expected a string' + end + i = i + 1 + end + end + if is_beyond_load_command then + return 'cannot call '..name..' after LoadCommand finished' + end +end + + +-- ===================================================================== -- + +-- Exports + + +local function export(fn, check_errors, name) + local function inner(self) + local err = check_errors(self, name) + if err then + error(name..': '..err, 2) + else + fn(self) + end + return inner + end + xero[name] = inner +end + +export(ease, check_ease_errors, 'ease') +export(add, check_ease_errors, 'add') +export(set, check_ease_errors, 'set') +export(acc, check_ease_errors, 'acc') +export(reset, check_reset_errors, 'reset') +export(func, check_func_errors, 'func') +export(perframe, check_perframe_errors, 'perframe') +export(func_ease, check_func_ease_errors, 'func_ease') +export(alias, check_alias_errors, 'alias') +export(setdefault, check_setdefault_errors, 'setdefault') +export(aux, check_aux_errrors, 'aux') +export(node, check_node_errors, 'node') +export(definemod, check_node_errors, 'definemod') +xero.get_plr = get_plr +xero.touch_mod = touch_mod +xero.touch_all_mods = touch_all_mods + +xero.max_pn = max_pn + + +xero() + +scx = SCREEN_CENTER_X +scy = SCREEN_CENTER_Y +sw = SCREEN_WIDTH +sh = SCREEN_HEIGHT + +dw = DISPLAY:GetDisplayWidth() +dh = DISPLAY:GetDisplayHeight() + +e = 'end' + +function sprite(self) + self:basezoomx(sw / dw) + self:basezoomy(-sh / dh) + self:x(scx) + self:y(scy) +end + +function aft(self) + self:SetWidth(dw) + self:SetHeight(dh) + self:EnableDepthBuffer(false) + self:EnableAlphaBuffer(false) + self:EnableFloat(false) + self:EnablePreserveTexture(true) + self:Create() +end + + +-- UNDOCUMENTED +xero.mod_buffer = mod_buffer + +---@diagnostic disable-next-line: lowercase-global +function xero.aftsprite(aft, sprite) + sprite:SetTexture(aft:GetTexture()) +end + +-- end UNDOCUMENTED + +-- This is the entry point of the template. +-- It sets up all of the commands used to run the template. +function xero.init_command(self) + -- This sets up a trick to get the Song time during the update command + self:effectclock('music') + + -- Register the commands to the actor + + -- OnCommand is for resolving Name= on all the actors + self:addcommand('On', on_command) + + -- ReadyCommand is called after OnCommand, and does all of the loading + -- at the end of ReadyCommand, the tables are sorted and prepared for UpdateCommand + self:addcommand('Ready', ready_command) + + -- Update command is called every frame. It is what sets the mod values every frame, + -- and reads through everything that's been queued by the user. + -- Delay one frame because the escape menu issue + self:addcommand('Update', function() + self:removecommand('Update') + self:addcommand('Update', update_command) + end) + + -- NotITG and OpenITG have a long standing bug where the InitCommand on an actor can run twice in certain cases. + -- By removing the command here (at the end of init_command), we prevent it from being run again. + self:removecommand('Init') + + -- init_command is the only one that was in the xero table, so clean it up + xero.init_command = nil +end diff --git a/stdlib/profiler.lua b/stdlib/profiler.lua new file mode 100644 index 0000000..47f611a --- /dev/null +++ b/stdlib/profiler.lua @@ -0,0 +1,56 @@ +PROFILER_ENABLED = true +profilerShowing = 'update' + +local easable = require('stdlib.easable') + +local text = BitmapText() +local quad = Quad() + +oat.useProfiler = true + +if PROFILER_ENABLED then + local max = easable(0) + + local function draw() + if not profilerInfo[profilerShowing] then return end + + quad:diffuse(0.2, 1, 0.2, 0.9) + quad:align(0, 0) + text:align(0, 0) + text:shadowlength(0) + + table.sort(profilerInfo[profilerShowing], function(a, b) return a.t > b.t end) + local maxt = 0 + for i, e in ipairs(profilerInfo[profilerShowing]) do + maxt = math.max(maxt, e.t) + quad:zoomto(e.t / max.a * sw * 0.4, 24) + quad:xy(0, i * 24) + quad:Draw() + + text:settext((math.floor(e.t * 100000) / 100) .. 'ms') + text:xy(0, i * 24) + text:zoom(0.3) + text:diffuse(0.2, 0.2, 0.2, 0.9) + text:Draw() + text:settext(e.src) + text:xy(0, i * 24 + 12) + text:zoom(0.2) + text:diffuse(0.1, 0.1, 0.1, 0.9) + text:Draw() + end + + max:set(maxt) + + text:diffuse(1, 1, 1, 1) + text:xy(0, 0) + text:zoom(0.5) + text:shadowlength(3) + text:settext('Profiler - ' .. profilerShowing) + text:Draw() + end + + function uranium.update(dt) + max(dt * 12) + draw() + end +end \ No newline at end of file diff --git a/typings.lua b/typings.lua index 9eabd49..de19985 100644 --- a/typings.lua +++ b/typings.lua @@ -58,6 +58,7 @@ function ActorFrame() end ---@param actor Actor --- Resets an actor to its initial state function reset(actor) end +resetActor = reset ---@param frame ActorFrame ---@param actor Actor @@ -114,4 +115,11 @@ uranium.update = function() end function uranium:call(event, ...) end --- Equivalent to a modfile-sandboxed `_G`, similar to Mirin's `xero`. You shouldn't need this; and if you do, *what are you doing?* -oat = _G \ No newline at end of file +oat = _G + +---@class ProfilerInfo +---@field public t number +---@field public src string + +---@type table +profilerInfo = {} \ No newline at end of file