-- 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