Divergent/mods/Dynamic Zone Transitions/gamedata/scripts/dynamic_zone_utils.script

642 lines
22 KiB
Plaintext
Raw Normal View History

--[[
DYNAMIC ZONE
Original Author(s)
Singustromo <singustromo at disroot.org>
Edited by
License
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
(https://creativecommons.org/licenses/by-nc-sa/4.0)
This script contains general purpose utility functions and logic used
to decouple the vanilla anomaly logic from the addon logic.
We've taken some inspiration from NLTP_ASHES' Western Goods.
--]]
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end
CONST_GAMETICK_DURATION_MS = 16
CONST_DEFAULT_PROXIMITY_DISTANCE = 2^16 -1
CONST_MAX_VALID_LVID = 2^32 -2
-------------------------
-- Dependencies
-------------------------
local opt = dynamic_zone_mcm
local debug = dynamic_zone_debug
CONST_LOGGING_PREFIX = "Utilities"
local log = debug.log_register("info", CONST_LOGGING_PREFIX)
local log_error = debug.log_register("error", CONST_LOGGING_PREFIX)
----------------------------
-- Input Validation --
----------------------------
-- Inspired by Western Goods
-- Checks, if all necessary parameters are non-nil and have the correct type
-- Use it like this: valid_type{ caller = "function_name", type, value, ... }
-- The boolean type explicitely accepts only true|false as values
-- @param tbl
-- @returns boolean
function valid_type(tbl)
if (not opt.get("validate_parameter_types")) then return true end
local type_alias = {
int = "number", str = "string", tbl = "table",
fn = "function" , bool = "boolean", usr = "userdata",
}
local caller = tbl.caller and string.format("[%s] ", tbl.caller) or ""
local param_index, encountered_error = 1, false
for i=1, #tbl, 2 do
local typ, value, value_type = tbl[i], tbl[i +1]
typ = type_alias[typ] or typ
if not (typ == "boolean" or value) then
log_error("%sParameter no. %s is nil%s", caller, param_index, callstack(nil, true))
encountered_error = true
goto continue
end
-- also accounts for nil values (it's own type)
value_type = type(value)
if value_type ~= typ then
log_error("%sType mismatch for parameter no. %s (%s != %s)%s",
caller, param_index, value_type, typ, callstack(nil, true))
encountered_error = true
end
:: continue ::
param_index = param_index +1
end
return (not encountered_error)
end
-- Alternative to native assert that does not crash the game nor is intended to
-- alter the control flow directly. It merely logs the message and a
-- callstack via the default methods used by this addon
-- @param condition (boolean)
-- @param message (optional; format)
-- @param vararg (format elements)
-- @returns true when assertion failed
function assert(condition, message, ...)
condition = (type(condition) == nil) or condition -- nil-checking
if (not opt.get("validate_parameter_types")) then return (not condition) end
if (not condition) then
local args = {...}
message = (message and type(message) == "string") and message or "failed!"
log_error("%s%s", string.format("Assertion: " .. message,
unpack(args)), callstack(nil, true))
return true
end
end
-- Just an alias for usage in if-statements
assert_failed = assert
---------------------------
-- Type Conversion --
---------------------------
-- Only returns true, if string is "true" or of type bool
function string_to_bool(str) return (str == "true" or s == true) end
-- Converts a comma delimited string into a 3 dimensional vector
-- @returns vector on success
function string_to_vector(str)
if not valid_type{ caller = "string_to_vector", "str", str } then
return
end
local tbl = str_explode(str, ",") or {}
if assert_failed(#tbl == 3, "Invalid posdata (not 3 elements)") then return end
for index, value in pairs(tbl) do
tbl[index] = tonumber(value)
if assert_failed(tbl[index], "Element is not a number") then return end
end
return vector():set(tbl[1], tbl[2], tbl[3])
end
-- Converts a comma delimited string in the form of
-- `x, y, z, lvid, gvid` into positional data
-- @returns position data as table (pos, lvid, gvid)
function string_to_posdata(str)
if not valid_type{ caller = "string_to_posdata", "str", str } then
return
end
local tbl = str_explode(str, ",")
if assert_failed(#tbl == 5, "Invalid posdata (not 5 elements)") then return end
-- Convert all substrings to numbers
for index, value in pairs(tbl) do
tbl[index] = tonumber(value)
if assert_failed(tbl[index], "Element is not a number") then return end
end
local lvid, gvid = tbl[4], tbl[5]
if assert_failed(lvid < CONST_MAX_VALID_LVID, "Invalid level vertex id") then return end
return {
pos = vector():set(tbl[1], tbl[2], tbl[3]),
lvid = lvid,
gvid = gvid,
}
end
-- ARGB32 -> a byte for each channel
-- @param pixelvalue e.g. return value from GetARGB(a,r,g,b)
-- @returns table containing following keys: r, g, b, a
function argb_convert_from_pixelvalue(pixelvalue)
if not valid_type{ caller = "argb_convert_from_pixelvalue", "int", pixelvalue } then return end
local bitmask = 2^8 -1 -- 0xFF
local alpha = bit.band(bit.rshift(pixelvalue, 24), bitmask)
local red = bit.band(bit.rshift(pixelvalue, 16), bitmask)
local green = bit.band(bit.rshift(pixelvalue, 8), bitmask)
local blue = bit.band(pixelvalue, bitmask)
if assert_failed((red and green and blue and alpha), "Invalid pixelvalue (argb)!") then return end
return { r = red, g = green, b = blue, a = alpha }
end
-- Changes the weight of the alpha pixelvalue
-- @param alpha 8-bit integer
-- @returns pixelvalue (ARGB32)
function argb_change_alpha(pixelvalue, alpha)
if not valid_type{ caller = "argb_change_alpha",
"int", pixelvalue, "int", alpha } then return end
local alphavalue = bit.lshift(bit.band(alpha, 2^8 -1), 24) -- make sure it's 1 byte
local no_alpha = bit.band(pixelvalue, 2^24 -1)
if assert_failed((no_alpha and alphavalue), "Invalid pixelvalue (argb)!") then return end
return bit.bor(no_alpha, alphavalue)
end
---------------------------
-- Table Functions --
---------------------------
-- output of next() is nil when table is empty
function is_table_empty(tbl)
return not (tbl and next(tbl))
end
function index_of(tbl, value)
if not valid_type{ caller = "index_of", "tbl", tbl } then return end
for k, v in pairs(tbl) do
if (v == value) then return k end
end
end
-- Checks, if table includes the value; Also checks subtables
-- @returns true if value is in table
function table_has(tbl, value)
if not valid_type{ caller = "table_has", "tbl", tbl } then return end
for _, v in pairs(tbl) do
if v == value then return true end
if type(v) == 'table' and table_has(v, value) then
return true
end
end
end
-- Checks, if all elements of tbl_in are contained in tbl
-- @returns boolean
function table_contains(tbl, tbl_in)
for _, v in pairs(tbl_in) do
if (not table_has(tbl, v)) then return end
end
return true
end
-- Creates subtable with key, if needed, needs the type of the value
-- @returns success state of insertion
function table_safe_insert(tbl, key, value, value_type)
if not valid_type{ caller = "table_safe_insert",
"tbl", tbl, "str", key, value_type, value } then return end
if not tbl[key] then
tbl[key] = { value }
else
table.insert(tbl[key], value)
end
return true
end
function table_copy(tbl)
if not valid_type{ caller = "table_copy", "tbl", tbl } then return end
local copy = {}
for k,v in pairs(tbl) do
copy[k] = (type(v) == "table") and table_copy(v) or v
end
return copy
end
-- recursively swaps values to keys into a one-dimensional dictionary
function values_to_keys(tbl, result)
if not valid_type{ caller = "values_to_keys",
"tbl", tbl, "tbl", result } then return end
for k, v in pairs(tbl) do
if type(v) == 'table' then
values_to_keys(v, result)
else
result[v] = true
end
end
end
-- lists all keys in tbl into an indexed table
function index_keys(tbl, result)
if not valid_type{ caller = "index_keys",
"tbl", tbl, "tbl", result } then return end
for key, v in pairs(tbl) do
result[#result +1] = key
end
end
------------------------
-- Closures --
------------------------
function random_numbered_sequence(from, to)
if not valid_type{ caller = "random_numbered_sequence",
"int", from, "int", to } then return end
local tbl = {}
for i = from, to do
tbl[#tbl + 1] = i
end
for i = #tbl, 2, -1 do
local j = math.random(i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
local index = 0
return function()
if index > #tbl then return end
index = index + 1
return tbl[index]
end
end
------------------------------------
-- Timed Function Execution --
------------------------------------
-- Taken from Western Goods
-- executes functor once on next tick
-- @author: demonized
function next_tick(functor, ...)
if not valid_type{ caller = "next_tick", "fn", functor } then return end
local args = {...}
AddUniqueCall(function()
functor(unpack(args))
return true
end)
end
-- Wrapper to throttle function execution with time delay
-- Modified derivative from modded exes
-- @delay number (milliseconds)
-- @delay_first_call boolean
-- @func functor
-- @vararg functor-parameters
-- @returns functor
function throttle(delay, delay_first_call, functor, ...)
if not valid_type{ caller = "throttle",
"int", delay, "bool", delay_first_call, "fn", functor } then return end
local args = {...}
if not (delay and delay > (CONST_GAMETICK_DURATION_MS or 16)) then
return function()
return functor(unpack(args))
end
end
local TimeGlobal = time_global
local tg_threshold = (delay_first_call)
and TimeGlobal() + delay or 0
return function()
local tg = TimeGlobal()
if (tg_threshold +1) > tg then return end
tg_threshold = tg + delay
return functor(unpack(args))
end
end
-- Repeatedly calls functor
-- Time should be declared in milliseconds
function timed_call(delay, functor, ...)
if not valid_type{ caller = "timed_call", "int", delay, "fn", functor } then return end
local args = {...}
-- We also delay first execution
local throttled_func = throttle(delay, true, functor, unpack(args))
if (not throttled_func) then return end
-- Calls functor every game tick (approx. 16-17 ms)
AddUniqueCall(throttled_func)
if (opt.get("validate_parameter_types")) then
log("timed_call | Added Unique Call for %s with delay of %sms%s",
functor, delay, callstack(nil, true))
end
end
---------------------------
-- Map Markers --
---------------------------
function map_spot_exists(id, spot)
if not valid_type{ caller = "map_spot_exists",
"int", id, "str", spot } then return end
return (level.map_has_object_spot(id,spot) == 1)
end
function map_spot_remove(id, spot)
if assert_failed(map_spot_exists(id, spot), "Spot (%s, %s) does not exist!", id, spot) then
return
end
level.map_remove_object_spot(id, spot)
return true
end
function map_spot_add(id, spot, hint)
if not valid_type{ caller = "map_spot_add", "str", hint } then return end
if assert_failed(not map_spot_exists(id, spot), "Spot (%s, %s) already exists!", id, spot) then
return
end
level.map_add_object_spot_ser(id, spot, hint)
return true
end
function map_spot_change_hint(id, spot, hint)
if not valid_type{ caller = "map_spot_change_hint",
"int", id, "str", spot, "str", hint } then return end
if assert_failed(map_spot_exists(id, spot), "Spot (%s, %s) does not exist!", id, spot) then
return
end
level.map_change_spot_hint(id, spot, hint)
return true
end
-- Read defined attributes from map_spots.xml for a given map spot
-- Uses a cache to speed up subsequent calls
-- @param spot (spot name; e.g. level_changer_right)
-- @returns table || nil (table contains texture and color (pixelvalue), if read)
local _cache_map_spot_texture_info = {}
function map_spot_get_texture_info(spot, color_as_argb, default_alpha)
if not valid_type{ caller = "get_mapspot_texture", "str", spot } then return end
local cached_result = _cache_map_spot_texture_info[spot]
if (cached_result) then return (cached_result) end
default_alpha = default_alpha or 255
spot = spot .. "_spot" -- that node got the actual info we need
local attr_tex = "texture"
local xml = _cache_map_spot_texture_info.parser
if (not xml) then
xml = CScriptXmlInit()
xml:ParseFile("map_spots.xml")
_cache_map_spot_texture_info.parser = xml
end
xml:NavigateToRoot() -- to <map_spots>
if assert_failed(xml:NodeExist(spot, 0), "Node %s does not exist", spot) then return end
xml:NavigateToNode(spot, 0)
local result = {}
result.texture = xml:ReadValue(attr_tex, 0)
if assert_failed(xml:NodeExist(attr_tex, 0), "Node %s/%s does not exist", spot, attr_tex) then return end
local color = {}
color.r = tonumber(xml:ReadAttribute(attr_tex, 0, "r"))
color.g = tonumber(xml:ReadAttribute(attr_tex, 0, "g"))
color.b = tonumber(xml:ReadAttribute(attr_tex, 0, "b"))
if (not is_table_empty(color)) then
color.a = tonumber(xml:ReadAttribute(attr_tex, 0, "a")) or default_alpha
result.color = color
if (not color_as_argb) then
result.color = GetARGB(color.a, color.r, color.g, color.b)
end
end
if (not is_table_empty(result)) then
_cache_map_spot_texture_info[spot] = result
return result
end
end
-- REQUIRES MODDED-EXES
-- Changes the map spot texture of any given object (given that it already has a map spot)
-- @param id object-id
-- @param spot current texture
-- @param texture new texture id
-- @returns true on success
function map_spot_change_texture(id, spot, texture)
if not valid_type{ caller = "map_spot_change_spot", "str", texture } then return end
if assert_failed(map_spot_exists(id, spot), "Spot (%s, %s) does not exist!", id, spot) then
return
end
local spot_static = level.map_get_object_minimap_spot_static(id, spot)
local mini_static = level.map_get_object_spot_static(id, spot)
if assert_failed(spot_static and mini_static) then return end
spot_static:InitTexture(texture)
mini_static:InitTexture(texture)
return true
end
-- REQUIRES MODDED-EXES
-- @param color argb32 pixelvalue (e.g. GetARGB(a,r,g,b))
-- @returns true on success
function map_spot_change_color(id, spot, color)
if not valid_type{ caller = "map_spot_change_color", "int", color } then return end
if assert_failed(map_spot_exists(id, spot), "Spot (%s, %s) does not exist!", id, spot) then
return
end
local main_mapspot = level.map_get_object_spot_static(id, spot)
local mini_mapspot = level.map_get_object_minimap_spot_static(id, spot)
main_mapspot:SetTextureColor(color)
mini_mapspot:SetTextureColor(color)
return true
end
-- REQUIRES MODDED-EXES
-- @returns pixelvalue (integer)
function map_spot_get_color(id, spot, get_mini)
if assert_failed(map_spot_exists(id, spot), "Spot (%s, %s) does not exist!", id, spot) then
return
end
local main_mapspot = level.map_get_object_spot_static(id, spot)
local mini_mapspot = (get_mini) and level.map_get_object_minimap_spot_static(id, spot)
return (mini_mapspot and mini_mapspot:GetTextureColor())
or (main_mapspot and main_mapspot:GetTextureColor())
end
-----------------------------------
-- Game related Checks --
-----------------------------------
function has_translation(string)
if not valid_type{ caller = "has_translation", "str", string } then return end
return (game.translate_string(string) ~= string)
end
------------------------------
-- Object related --
------------------------------
-- @param obj (optional)
-- @returns level name
function get_mapname(obj)
if (not obj) then
return level.name()
elseif not valid_type{ caller = "get_mapname", "userdata", obj } then
return
end
local gvid
if (obj.online and type(obj.id) == "function") then
gvid = obj:game_vertex_id()
elseif (obj.id) then
gvid = obj.m_game_vertex_id
end
return (alife():level_name(game_graph():vertex(gvid):level_id()))
end
-- Only returns correct position for online objects as
-- we only check for the proximity to the player
-- @returns distance (to player)
function get_proximity_by_id(id, default)
if not valid_type{ caller = "get_proximity_by_id", "int", id } then return end
default = (default and type(default) == "number") or CONST_DEFAULT_PROXIMITY_DISTANCE
local se_obj = id and alife_object(id)
if not (se_obj and se_obj.online) then return default end
local pos = se_obj.position
return (pos and pos:distance_to(db.actor:position()) or default)
end
function get_point_proximity_by_id(id, point, default)
if not valid_type{ caller = "get_point_proximity_by_id", "int", id, "usr", point } then return end
default = default or CONST_DEFAULT_PROXIMITY_DISTANCE
local se_obj = id and alife_object(id)
if not (se_obj and se_obj.online) then return default end
local pos = se_obj.position
return (pos and pos:distance_to(point) or default)
end
-----------------------------
-- Level related --
-----------------------------
-- This works with and without modded exes
-- @returns pos from the target in the center of the viewport
function get_target_pos()
return (level.get_target_pos) and level.get_target_pos() -- REQUIRES MODDED-EXES
or device().cam_pos:add(device().cam_dir:mul(level.get_target_dist()))
end
-- Returns the position of a level vertex in close proximity (determined in-engine)
-- @param pos vector
-- @returns vector (copy)
function get_closest_vertex_pos(pos)
if not valid_type{ caller = "get_closest_vertex_pos", "usr", pos } then return end
local lvid = pos and pos.x and level.vertex_id(pos)
-- signed & last are reserved
if not (lvid and (lvid < CONST_MAX_VALID_LVID)) then return end
local pos = level.vertex_position(lvid)
return vector():set(pos.x, pos.y, pos.z)
end
-- Taken from Catspaw's utilities
function levelname_from_gvid(gvid)
if not valid_type{ caller = "levelname_from_gvid", "int", gvid } then return end
local gv = game_graph():vertex(gvid)
return alife():level_name(gv:level_id())
end
-- Requires that the weather manager is running (just before actor_on_first_update)
-- Additional levels need to be registered there to have working weather
-- @param level_name
-- @returns boolean
function is_underground(level)
if not valid_type{ caller = "is_underground", "str", level } then return end
return (not level_weathers.valid_levels[level])
end
function debug_level_loaded()
return (get_mapname() == "fake_start")
end
-- Uses txr_routes to determine connected maps
-- @param map
-- @returns table
function get_directly_connected_maps(map)
local get_txr_section = txr_routes.get_section
local get_txr_mapname = txr_routes.get_map
local routes = txr_routes.routes
local txr_section = map and get_txr_mapname(map)
if not (txr_section and routes[txr_section]) then return end
local connected = {}
for map, _ in pairs(routes[txr_section]) do
connected[#connected +1] = get_txr_section(map)
end
return connected
end