--[[ DYNAMIC ZONE Original Author(s) Singustromo 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 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