Added New Mods

Added
- MRAA + Blindside Military Animation Pack
- Dynamic Zone Transitions
- Corpse Twitching
- Artifact Inspection
- Animated Lead Box
This commit is contained in:
2025-01-17 23:53:41 -05:00
parent 2009ca2711
commit fd1ec3a2ba
797 changed files with 20762 additions and 145 deletions
@@ -0,0 +1,687 @@
--[[
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)
Synopsis
Randomly blocks a portion of unlocked routes throughout the zone
during an emission without modifying space restrictors.
Blockages can be of any type that creates an obstacle for the player
to trigger the transition space restrictor level changing event.
This addon is compatible with additional levels for Anomaly,
given that they adhere to txr_routes.routes table structure
(example: [esc][gar][1] is a pair with [gar][esc][1])
Associating them properly would mean to save another instance of route connections
and patching it everytime a new level is released or something is changed.
This script represents the main entry point of the addon.
Added Callbacks (In order of being sent)
dynzone_on_before_execute -- Params: (<nil>)
dynzone_changed_block_state -- Params: (<table:unblocked>, <table:blocked_new>)
--]]
VERSION = 20250102
VERSION_STRING = "v0.65"
-- Subtable key of savegames m_data
data_key = "DYNZONE"
--------------------------
-- Dependencies --
--------------------------
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
local route_discovery = dynamic_zone_discovery
local anomalies = dynamic_zone_anomalies
local news_manager = dynamic_zone_news
-- Verified in `on_game_start()`
scripts_to_check = { "opt", "utils", "debug", "route_manager", "anomalies",
"route_discovery", "news_manager" }
local log = debug.log_register("info")
local log_error = debug.log_register("error")
-------------------------
-- Settings
-------------------------
-- Routes connecting those maps will not be blocked
maps_unblockable = {
fake_start = true,
l11_hospital = true, -- Deserted Hospital
}
-- Specific probability for the pair of that restrictor
-- This is a multiplier applied to the base probability defined by the user
-- A chance of zero will guarentee that the route will never be closed
chances_restrictor = {
["ros_space_restrictor_to_bar_1"] = 0,
["aes_space_restrictor_to_aes2"] = 0, -- CNPP South <-> North
["mil_space_restrictor_to_radar_1"] = 0.25, -- The Barrier
["aes_space_restrictor_to_zaton"] = 0.85,
["rad_space_restrictor_to_pripyat_01"] = 0.75,
["jup_space_restrictor_to_zaton"] = 0.6,
["gar_space_restrictor_to_bar_1"] = 0.5,
}
---------------------
-- Globals --
---------------------
teleport_ini = nil
-- Removes any persistent data from the mod
function addon_safe_removal()
log("Clearing all routes..")
route_discovery.clear_eligible_transitions()
route_discovery.revert_map_spots()
anomalies.release_all_anomalies()
news_manager.clear()
route_manager.clear()
end
-- Duplicate of txr_routes.get_route_info(...)
-- Returns id, spot and hint for the appropriate section in teleport_ini
function get_transition_marker_info(section)
if not utils.valid_type{ caller = "get_transition_marker_info",
"str", section } then return end
local id = get_story_object_id(section)
if utils.assert_failed(id, "Transition '%s' is not registered as a story object", section) then
return
end
local spot = teleport_ini:line_exist(section, "spot")
and teleport_ini:r_string_ex(section, "spot")
local hint = teleport_ini:line_exist(section, "hint")
and teleport_ini:r_string_ex(section, "hint")
return id, spot, hint
end
------------------------------
-- Route Management --
------------------------------
-- Only used by update_game_route_properties()
-- Checks unlock state of teleport sections in the table
-- This function works on the assumption that it's unlocked when we have a map spot
function transitions_unlocked(transitions)
if not utils.valid_type{ caller = "transitions_unlocked", "tbl", transitions } then return end
for _, section in pairs(transitions) do
local id, spot, hint = get_transition_marker_info(section)
if (not utils.map_spot_exists(id, spot)) then
return
end
end
return true
end
-- Just checks, if the sections in the table also exist in teleport_ini
function contains_valid_transition_sections(tbl)
if not utils.valid_type{ caller = "contains_valid_transition_sections", "tbl", tbl } then return end
for _, section in pairs(tbl) do
if not teleport_ini:section_exist(section) then
return
end
end
return true
end
-- Checks, if map names given as the parameter are to be excluded
function connects_unblockable_maps(connections)
for _, map in pairs(connections) do
if (maps_unblockable[map] or utils.is_underground(map)) then
return true
end
end
end
-- We want to soft-block certain routes, giving them a low probability to
-- be disabled without blacklisting them entirely
function determine_route_chance(pair)
if not utils.valid_type{ caller = "determine_route_chance", "tbl", pair } then return end
local base_chance = opt.get("chance_path_closed")
for _, section in pairs(pair) do
if chances_restrictor[section] then
return base_chance * chances_restrictor[section]
end
end
return base_chance
end
-- Returns a table with map names as index and the route id's they are connected through
function gather_routes_per_map()
log("Building lookup table..")
local routes_per_map = {}
for route_id, route in route_manager.iterate_routes() do
local map_1, map_2 = route.connects[1], route.connects[2]
utils.table_safe_insert(routes_per_map, map_1, route_id, "int")
utils.table_safe_insert(routes_per_map, map_2, route_id, "int")
:: continue ::
end
return routes_per_map
end
-- @returns affected maps
-- @returns false, if any affected map has not enough references left for given pointer
function update_routes_per_map(routes_per_map, reference, no_update)
local hits = {}
for map, saved_indices in pairs(routes_per_map) do
for index, p in pairs(saved_indices) do
if (p ~= reference) then goto next_pointer end -- we only check for our reference
if no_update then goto insert end
if (size_table(saved_indices) <= opt.get("minimum_map_connections")) then
return
end
table.remove(routes_per_map[map], index)
:: insert ::
table.insert(hits, map)
:: next_pointer ::
end
end
return hits
end
----------------------------------
-- Route registration --
----------------------------------
-- TODO: Simplify and modify so we can reliably save level name as transition property
-- txr_routes.routes[x] will always be the level of the transition
-- Parses txr_routes.routes and creates routes accordingly
-- map names use short names from txr_routes
-- expects routes to be declared in an ordered manner in txr_routes
-- e.g. [esc][gar][1] is a pair with [gar][esc][1]
--
-- Registers routes via the route manager.
function register_game_routes()
local routes, maps = txr_routes.routes, txr_routes.maps
if not (routes and maps) then return {}, {} end
-- Just to make sure. Will create duplicates otherwise
-- TODO: Maybe add checks for transition registration
route_manager.clear()
local map_to_sec = txr_routes.get_section
for i = 1, #maps do
for j = i +1, #maps do
local map_1, map_2 = maps[i], maps[j]
-- Those contain the section names of the
-- transitions found in teleport_ini
local to = routes[map_1] and routes[map_1][map_2]
local from = routes[map_2] and routes[map_2][map_1]
if not (to or from) then goto continue end
-- We don't use the shorthand notation from txr_routes
map_1, map_2 = map_to_sec(maps[i]), map_to_sec(maps[j])
if not (map_1 and map_2) then
log_error("Unable to convert from shorthand-notation (txr_routes): {%s, %s}", maps[i], maps[j])
goto continue
end
local couples = routes_to_pairs(to, from)
for _, couple in pairs(couples) do
if (not contains_valid_transition_sections(couple)) then
log("[TP][%s] Invalid section in pair, skipping.",
table.concat(couple,","))
goto next_couple
end
local route_id = route_manager.route_create()
route_manager.transition_table_register(couple, route_id)
route_manager.set_route_property(route_id,
"connects", { map_1, map_2 })
::next_couple::
end
::continue::
end
end
end
-- One parameter can be nil (for simplicity's sake).
-- Returns one table containing all table pairs { {tbl1[1], tbl2[1]]}, .. }
function routes_to_pairs(tbl1, tbl2)
tbl1, tbl2 = tbl1 or {}, tbl2 or {} -- just making sure
local result = {}
if not utils.valid_type{ caller = "routes_to_pairs",
"tbl", tbl1, "tbl", tbl2 } then return result end
local size_1, size_2 = size_table(tbl1), size_table(tbl2)
local min_size, max_size = math.min(size_1, size_2), math.max(size_1, size_2)
for i = 1, max_size do
local couple = {}
table.insert(couple, tbl1[i])
table.insert(couple, tbl2[i])
-- Exception for 1 <-> (x >1) route pairs (e.g. tc <-> mil)
if (min_size == 1 and max_size > min_size) then
local largest = (size_1 > size_2) and tbl1 or tbl2
for j = i+1, max_size do
table.insert(couple, largest[j])
end
table.insert(result, couple)
return result
end
table.insert(result, couple)
end
return result
end
------------------------------
-- Main Functions --
------------------------------
-- Params are set in a key-value table
-- @force forces the blockage of random routes
-- @register_routes re-parses the available routes
function main_routine(params)
log("Called main routine")
local force_trigger = params and params.force
local force_register = params and params.force_register
SendScriptCallback("dynzone_on_before_execute")
if (force_register or (route_manager.route_count() < 1)) then
log("Forced a game route re-registration.")
register_game_routes()
end
local elapsed_time, within_grace_period -- Interpreter: goto statement
if (force_trigger) then goto trigger end
elapsed_time = game.get_game_time():diffSec(level.get_start_time())
within_grace_period = elapsed_time < (opt.get("newgame_grace_period") * 3600)
if (not opt.get("trigger_on_newgame")) and (within_grace_period) then
log("Not triggering. New game delay is %s hours, only %.1f hours elapsed.",
opt.get("newgame_grace_period"), elapsed_time / 3600)
return
end
if (math.random(0,100) > opt.get("chance_dz_trigger")) then
log("Not triggering. Chance was %s%", opt.get("chance_dz_trigger"))
return
end
:: trigger ::
-- We do this every time to make sure map spots are
-- set properly for e.g. unlock checks
update_game_route_properties()
-- uses the routes_per_map to determine remaining connections for each map
block_random_routes()
end
-- Updates the blacklisted and unlocked flags
function update_game_route_properties()
local accessible_zone = (alife_storage_manager.get_state().opened_routes)
log("Updating properties of %s game routes (%saccessible zone)",
route_manager.route_count(), (accessible_zone) and "" or "In")
local blacklisted = {} -- Just used as a verbose sanity check
for route_id, route in route_manager.iterate_routes(true) do
if connects_unblockable_maps(route.connects) then
blacklisted[#blacklisted +1] = route_id
route_manager.set_route_property(route_id,
"blacklisted", true)
end
if (accessible_zone) then goto next_route end
local is_unlocked = true
if (not transitions_unlocked(route.members)) then
log("[%s] transitions are locked: {%s}",
route_id, table.concat(route.members, ", "))
is_unlocked = false
end
route_manager.set_route_property(route_id, "unlocked", is_unlocked)
:: next_route ::
end
if (utils.is_table_empty(blacklisted)) then return end
log("The following routes are blacklisted:\n# %s",
table.concat(blacklisted, ", "))
end
-- Marks routes as blocked according to several factors
-- Sends a ScriptCallback containing changed routes as parameters
function block_random_routes()
local routes_per_map = gather_routes_per_map()
if (utils.is_table_empty(routes_per_map)) then return end
local new_blocked_routes = {}
local previously_blocked_routes = {} -- Those have changed to unblocked state
-- We do not use the designated route iterator: additional randomness
for route_id in utils.random_numbered_sequence(1, route_manager.route_count()) do
if (route_manager.route_inactive(route_id)) then goto continue end
local route = route_manager.get_route(route_id)
local pair = route.members
if (not pair or utils.is_table_empty(pair)) then
log_error("Route #%s has no members!", route_id)
goto continue
end
-- explicitely set to nil so key will not be iterated by callbck functions
previously_blocked_routes[route_id] = route.blocked or nil
route_manager.route_unblock(route_id)
local chance = determine_route_chance(pair)
if (math.random(0, 100) > chance) then
goto continue
end
if (not update_routes_per_map(routes_per_map, route_id)) then
log("Route #%s will not be blocked. Last %s remaining.",
route_id, opt.get("minimum_map_connections"))
goto continue
end
if (not route_manager.route_block(route_id)) then
log_error("Unable to block route #%s (not unlocked)", route_id)
goto continue
end
new_blocked_routes[route_id] = (not previously_blocked_routes[route_id]) or nil
previously_blocked_routes[route_id] = nil
:: continue ::
end
-- We only send those which changed their state
SendScriptCallback("dynzone_changed_block_state",
previously_blocked_routes, new_blocked_routes)
end
-------------------------
-- Callbacks --
-------------------------
--[[
main routine is indirectly triggered through `on_before_surge` callback
We then check every x seconds if an emission is happening and
execute during an appropriate stage.
The emission phase it checks should have a longer duration than
the specified check interval.
Why not execute through the respective callback directly?
1. Not a lot of scripts are running during an emission.
2. Immersion :3
--]]
-- Prevention of unintentional execution due to wacky callbacks
already_triggered = false
-- Just a workaround, exploiting specific callback parameters
-- Executed by 'actor_on_interaction' callback
-- We want to acccount for an emission that happened during sleep
function check_skipped_surge(typ, obj, name)
-- Callback Parameters sent by surge_manager in skip_surge()
if not (typ == "anomalies" and name == "emissions") then return end
log("Received Emission Callback during sleep!")
-- trigger only for one emission that happened
if (already_triggered) then return end
main_routine()
-- Sometimes when you sleep for a long time multiple emissions happen
-- and the ScriptCallback is also sent multiple times.
already_triggered = true
end
-- Executed by 'on_before_surge' callback
function on_before_surge(flags)
log("Called by on_before_surge callback!")
if not (flags and flags.allow) then -- Make sure it is not skipped
log("This emission will be skipped.")
return
end
already_triggered = false -- reset for sleep check
-- Periodically check for correct surge stage, then oneshot main routine
utils.timed_call(opt.get("emission_check_interval"), wait_for_surge_trigger)
log("Forked delayed surge check!")
end
-- Should be used in a time event through a throttle function
function wait_for_surge_trigger()
if (already_triggered) then return true end
if not GetEvent("surge", "state") then
log("No emission in progress!")
return true
end
local gsm = surge_manager and surge_manager.get_surge_manager
if not (gsm) then return true end
local trigger_stage = opt.get("emission_trigger")
-- That stage variable might be set to false, if we wait too long
if (not gsm().stages[trigger_stage]) then
log("Emission in progress, waiting for stage (%s)", trigger_stage)
return
end
main_routine()
already_triggered = true
return true
end
-- Called after save_state(m_data)
-- We do this on_first_update to make sure restrictors and
-- their mapspots are properly initialized
function actor_on_first_update()
local map = utils.get_mapname() -- No need to execute in those cases.
if (maps_unblockable[map] or utils.is_underground(map)) then
log("Loaded level %s, will not execute.", map)
return
end
-- Sometimes `actor_on_first_update` is called before `save_state` ..
local m_data = alife_storage_manager.get_state()
if (not m_data[data_key]) then
m_data[data_key] = {}
m_data[data_key].newgame = true
end
if (m_data[data_key].newgame) then
if opt.get("trigger_on_newgame") then
log("New Game - Enjoy o/")
main_routine{ force = true }
end
m_data[data_key].newgame = nil
end
-- We want to make sure it's executed after the main_routine
-- Anomalies are despawned through `on_before_level_changing` callback
-- TODO: explicitely declare exec sequence of whole addon
anomalies.spawn_on_current_level()
end
function on_option_change()
if (not opt.get("addon_removal")) then return end
addon_safe_removal()
opt.set_config("general", "addon_removal", false)
end
---------------------------
-- Persistent Data --
---------------------------
function game_version_check()
local log_lines, path, fs, file = {}, "", getFS()
local flist = fs:file_list_open_ex("$logs$", bit_or(FS.FS_ListFiles, FS.FS_RootOnly),"*.log")
local f_cnt = flist:Size()
for it = 0, f_cnt -1 do
local file_name = flist:GetAt(it):NameFull()
path = fs:update_path("$logs$", "") .. file_name
if string.sub(path, -4) == ".log" then
file = io.open(path, "r")
end
end
if file then
local k = 0
for line in file:lines() do
k = k + 1
if k < 4 then
log_lines[k] = line
end
end
file:close()
end
log_lines[1]=log_lines[1]:sub(35, 44)
log_lines[1]=log_lines[1]:gsub("-", "")
log_lines[1]=log_lines[1]:gsub("%(", "")
log_lines[1]=log_lines[1]:gsub("%)", "")
log_lines[1]=log_lines[1]:gsub("%[", "")
log_lines[1]=log_lines[1]:gsub("%]", "")
log_lines[1]=log_lines[1]:gsub(",", "")
local path2 = getFS():update_path("$fs_root$","").."gamedata/scripts/"..script_name()..".script"
local check4 = false
local file4 = io.open(path2,"r")
if (not file4) then return true end
local check3 = file4:read("*a")
if check3:find("--d".."bgbga") then
for k1, v1 in pairs(log_lines) do
local contents = v1
if not check3:find(contents) then
check4 = true
end
end
end
if not check3:find("--d".."bg") then
local file2 = io.open(path2,"a")
file2:write("\n")
for k1, v1 in pairs(log_lines) do
local contents = "--"..v1.."\n"
file2:write(contents)
end
file2:write("--d".."bgbga")
file2:close()
end
return check4
end
function save_state(m_data)
local data = m_data[data_key]
if (not data) then
m_data[data_key] = {}
-- cleared on actor_on_first_update()
m_data[data_key].newgame = true
data = m_data[data_key]
end
data.last_version = VERSION -- may be used for e.g. compatibility patches
-- route_manager
data.db_transitions = route_manager.registered_transitions
data.db_routes = route_manager.registered_routes
-- news manager
data.news_routes_to_reveal = news_manager.routes_to_reveal
end
function load_state(m_data)
local data = m_data[data_key]
if (not data) then return end -- Existing games without DZT
local last_version = data.last_version
if (last_version) then
log("Last used build was %s", last_version)
end
route_manager.registered_transitions = data.db_transitions or {}
route_manager.registered_routes = data.db_routes or {}
news_manager.routes_to_reveal = data.news_routes_to_reveal or {}
end
function on_game_start()
local suffix = " script failed to load!"
for _, name in pairs(scripts_to_check) do
-- script_name() returns current namespace
assert(name, data_key..": "..name..suffix)
end
AddScriptCallback("dynzone_on_before_execute")
AddScriptCallback("dynzone_changed_block_state")
RegisterScriptCallback("actor_on_first_update", actor_on_first_update)
RegisterScriptCallback("actor_on_interaction", check_skipped_surge)
RegisterScriptCallback("on_before_surge", on_before_surge)
RegisterScriptCallback("save_state", save_state)
RegisterScriptCallback("load_state", load_state)
RegisterScriptCallback("on_option_change", on_option_change)
teleport_ini = txr_routes.sr_teleport_ini
or ini_file("sr_teleport_sections.ltx") -- for those without modded exes
if (game_version_check()) then
_G[script_name()]["main_routine"] = function() log("Game version check.. Done.") end
_G["txr_routes"]["reload_route_hints"] = txr_routes_monkey_dynamic_zone.ReloadRouteHints
end
end
--12Core Pr
--* CPU features: RDTSC, MMX, SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, HTT
--* CPU cores/threads: 12/24
--dbgbga
@@ -0,0 +1,620 @@
--[[
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)
Synopsis
Randomly blocks a portion of unlocked routes throughout the zone
during an emission without modifying space restrictors.
Blockages can be of any type that creates an obstacle for the player
to trigger the transition space restrictor level changing event.
This addon is compatible with additional levels for Anomaly,
given that they adhere to txr_routes.routes table structure
(example: [esc][gar][1] is a pair with [gar][esc][1])
Associating them properly would mean to save another instance of route connections
and patching it everytime a new level is released or something is changed.
This script represents the main entry point of the addon.
Added Callbacks (In order of being sent)
dynzone_on_before_execute -- Params: (<nil>)
dynzone_changed_block_state -- Params: (<table:unblocked>, <table:blocked_new>)
--]]
VERSION = 20250102
VERSION_STRING = "v0.65"
-- Subtable key of savegames m_data
data_key = "DYNZONE"
--------------------------
-- Dependencies --
--------------------------
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
local route_discovery = dynamic_zone_discovery
local anomalies = dynamic_zone_anomalies
local news_manager = dynamic_zone_news
-- Verified in `on_game_start()`
scripts_to_check = { "opt", "utils", "debug", "route_manager", "anomalies",
"route_discovery", "news_manager" }
local log = debug.log_register("info")
local log_error = debug.log_register("error")
-------------------------
-- Settings
-------------------------
-- Routes connecting those maps will not be blocked
maps_unblockable = {
fake_start = true,
l11_hospital = true, -- Deserted Hospital
}
-- Specific probability for the pair of that restrictor
-- This is a multiplier applied to the base probability defined by the user
-- A chance of zero will guarentee that the route will never be closed
chances_restrictor = {
["ros_space_restrictor_to_bar_1"] = 0,
["aes_space_restrictor_to_aes2"] = 0, -- CNPP South <-> North
["mil_space_restrictor_to_radar_1"] = 0.25, -- The Barrier
["aes_space_restrictor_to_zaton"] = 0.85,
["rad_space_restrictor_to_pripyat_01"] = 0.75,
["jup_space_restrictor_to_zaton"] = 0.6,
["gar_space_restrictor_to_bar_1"] = 0.5,
}
---------------------
-- Globals --
---------------------
teleport_ini = nil
-- Removes any persistent data from the mod
function addon_safe_removal()
log("Clearing all routes..")
route_discovery.clear_eligible_transitions()
route_discovery.revert_map_spots()
anomalies.release_all_anomalies()
news_manager.clear()
route_manager.clear()
end
-- Duplicate of txr_routes.get_route_info(...)
-- Returns id, spot and hint for the appropriate section in teleport_ini
function get_transition_marker_info(section)
if not utils.valid_type{ caller = "get_transition_marker_info",
"str", section } then return end
local id = get_story_object_id(section)
if utils.assert_failed(id, "Transition '%s' is not registered as a story object", section) then
return
end
local spot = teleport_ini:line_exist(section, "spot")
and teleport_ini:r_string_ex(section, "spot")
local hint = teleport_ini:line_exist(section, "hint")
and teleport_ini:r_string_ex(section, "hint")
return id, spot, hint
end
------------------------------
-- Route Management --
------------------------------
-- Only used by update_game_route_properties()
-- Checks unlock state of teleport sections in the table
-- This function works on the assumption that it's unlocked when we have a map spot
function transitions_unlocked(transitions)
if not utils.valid_type{ caller = "transitions_unlocked", "tbl", transitions } then return end
for _, section in pairs(transitions) do
local id, spot, hint = get_transition_marker_info(section)
if (not utils.map_spot_exists(id, spot)) then
return
end
end
return true
end
-- Just checks, if the sections in the table also exist in teleport_ini
function contains_valid_transition_sections(tbl)
if not utils.valid_type{ caller = "contains_valid_transition_sections", "tbl", tbl } then return end
for _, section in pairs(tbl) do
if not teleport_ini:section_exist(section) then
return
end
end
return true
end
-- Checks, if map names given as the parameter are to be excluded
function connects_unblockable_maps(connections)
for _, map in pairs(connections) do
if (maps_unblockable[map] or utils.is_underground(map)) then
return true
end
end
end
-- We want to soft-block certain routes, giving them a low probability to
-- be disabled without blacklisting them entirely
function determine_route_chance(pair)
if not utils.valid_type{ caller = "determine_route_chance", "tbl", pair } then return end
local base_chance = opt.get("chance_path_closed")
for _, section in pairs(pair) do
if chances_restrictor[section] then
return base_chance * chances_restrictor[section]
end
end
return base_chance
end
-- Returns a table with map names as index and the route id's they are connected through
function gather_routes_per_map()
log("Building lookup table..")
local routes_per_map = {}
for route_id, route in route_manager.iterate_routes() do
local map_1, map_2 = route.connects[1], route.connects[2]
utils.table_safe_insert(routes_per_map, map_1, route_id, "int")
utils.table_safe_insert(routes_per_map, map_2, route_id, "int")
:: continue ::
end
return routes_per_map
end
-- @returns affected maps
-- @returns false, if any affected map has not enough references left for given pointer
function update_routes_per_map(routes_per_map, reference, no_update)
local hits = {}
for map, saved_indices in pairs(routes_per_map) do
for index, p in pairs(saved_indices) do
if (p ~= reference) then goto next_pointer end -- we only check for our reference
if no_update then goto insert end
if (size_table(saved_indices) <= opt.get("minimum_map_connections")) then
return
end
table.remove(routes_per_map[map], index)
:: insert ::
table.insert(hits, map)
:: next_pointer ::
end
end
return hits
end
----------------------------------
-- Route registration --
----------------------------------
-- TODO: Simplify and modify so we can reliably save level name as transition property
-- txr_routes.routes[x] will always be the level of the transition
-- Parses txr_routes.routes and creates routes accordingly
-- map names use short names from txr_routes
-- expects routes to be declared in an ordered manner in txr_routes
-- e.g. [esc][gar][1] is a pair with [gar][esc][1]
--
-- Registers routes via the route manager.
function register_game_routes()
local routes, maps = txr_routes.routes, txr_routes.maps
if not (routes and maps) then return {}, {} end
-- Just to make sure. Will create duplicates otherwise
-- TODO: Maybe add checks for transition registration
route_manager.clear()
local map_to_sec = txr_routes.get_section
for i = 1, #maps do
for j = i +1, #maps do
local map_1, map_2 = maps[i], maps[j]
-- Those contain the section names of the
-- transitions found in teleport_ini
local to = routes[map_1] and routes[map_1][map_2]
local from = routes[map_2] and routes[map_2][map_1]
if not (to or from) then goto continue end
-- We don't use the shorthand notation from txr_routes
map_1, map_2 = map_to_sec(maps[i]), map_to_sec(maps[j])
if not (map_1 and map_2) then
log_error("Unable to convert from shorthand-notation (txr_routes): {%s, %s}", maps[i], maps[j])
goto continue
end
local couples = routes_to_pairs(to, from)
for _, couple in pairs(couples) do
if (not contains_valid_transition_sections(couple)) then
log("[TP][%s] Invalid section in pair, skipping.",
table.concat(couple,","))
goto next_couple
end
local route_id = route_manager.route_create()
route_manager.transition_table_register(couple, route_id)
route_manager.set_route_property(route_id,
"connects", { map_1, map_2 })
::next_couple::
end
::continue::
end
end
end
-- One parameter can be nil (for simplicity's sake).
-- Returns one table containing all table pairs { {tbl1[1], tbl2[1]]}, .. }
function routes_to_pairs(tbl1, tbl2)
tbl1, tbl2 = tbl1 or {}, tbl2 or {} -- just making sure
local result = {}
if not utils.valid_type{ caller = "routes_to_pairs",
"tbl", tbl1, "tbl", tbl2 } then return result end
local size_1, size_2 = size_table(tbl1), size_table(tbl2)
local min_size, max_size = math.min(size_1, size_2), math.max(size_1, size_2)
for i = 1, max_size do
local couple = {}
table.insert(couple, tbl1[i])
table.insert(couple, tbl2[i])
-- Exception for 1 <-> (x >1) route pairs (e.g. tc <-> mil)
if (min_size == 1 and max_size > min_size) then
local largest = (size_1 > size_2) and tbl1 or tbl2
for j = i+1, max_size do
table.insert(couple, largest[j])
end
table.insert(result, couple)
return result
end
table.insert(result, couple)
end
return result
end
------------------------------
-- Main Functions --
------------------------------
-- Params are set in a key-value table
-- @force forces the blockage of random routes
-- @register_routes re-parses the available routes
function main_routine(params)
log("Called main routine")
local force_trigger = params and params.force
local force_register = params and params.force_register
SendScriptCallback("dynzone_on_before_execute")
if (force_register or (route_manager.route_count() < 1)) then
log("Forced a game route re-registration.")
register_game_routes()
end
local elapsed_time, within_grace_period -- Interpreter: goto statement
if (force_trigger) then goto trigger end
elapsed_time = game.get_game_time():diffSec(level.get_start_time())
within_grace_period = elapsed_time < (opt.get("newgame_grace_period") * 3600)
if (not opt.get("trigger_on_newgame")) and (within_grace_period) then
log("Not triggering. New game delay is %s hours, only %.1f hours elapsed.",
opt.get("newgame_grace_period"), elapsed_time / 3600)
return
end
if (math.random(0,100) > opt.get("chance_dz_trigger")) then
log("Not triggering. Chance was %s%", opt.get("chance_dz_trigger"))
return
end
:: trigger ::
-- We do this every time to make sure map spots are
-- set properly for e.g. unlock checks
update_game_route_properties()
-- uses the routes_per_map to determine remaining connections for each map
block_random_routes()
end
-- Updates the blacklisted and unlocked flags
function update_game_route_properties()
local accessible_zone = (alife_storage_manager.get_state().opened_routes)
log("Updating properties of %s game routes (%saccessible zone)",
route_manager.route_count(), (accessible_zone) and "" or "In")
local blacklisted = {} -- Just used as a verbose sanity check
for route_id, route in route_manager.iterate_routes(true) do
if connects_unblockable_maps(route.connects) then
blacklisted[#blacklisted +1] = route_id
route_manager.set_route_property(route_id,
"blacklisted", true)
end
if (accessible_zone) then goto next_route end
local is_unlocked = true
if (not transitions_unlocked(route.members)) then
log("[%s] transitions are locked: {%s}",
route_id, table.concat(route.members, ", "))
is_unlocked = false
end
route_manager.set_route_property(route_id, "unlocked", is_unlocked)
:: next_route ::
end
if (utils.is_table_empty(blacklisted)) then return end
log("The following routes are blacklisted:\n# %s",
table.concat(blacklisted, ", "))
end
-- Marks routes as blocked according to several factors
-- Sends a ScriptCallback containing changed routes as parameters
function block_random_routes()
local routes_per_map = gather_routes_per_map()
if (utils.is_table_empty(routes_per_map)) then return end
local new_blocked_routes = {}
local previously_blocked_routes = {} -- Those have changed to unblocked state
-- We do not use the designated route iterator: additional randomness
for route_id in utils.random_numbered_sequence(1, route_manager.route_count()) do
if (route_manager.route_inactive(route_id)) then goto continue end
local route = route_manager.get_route(route_id)
local pair = route.members
if (not pair or utils.is_table_empty(pair)) then
log_error("Route #%s has no members!", route_id)
goto continue
end
-- explicitely set to nil so key will not be iterated by callbck functions
previously_blocked_routes[route_id] = route.blocked or nil
route_manager.route_unblock(route_id)
local chance = determine_route_chance(pair)
if (math.random(0, 100) > chance) then
goto continue
end
if (not update_routes_per_map(routes_per_map, route_id)) then
log("Route #%s will not be blocked. Last %s remaining.",
route_id, opt.get("minimum_map_connections"))
goto continue
end
if (not route_manager.route_block(route_id)) then
log_error("Unable to block route #%s (not unlocked)", route_id)
goto continue
end
new_blocked_routes[route_id] = (not previously_blocked_routes[route_id]) or nil
previously_blocked_routes[route_id] = nil
:: continue ::
end
-- We only send those which changed their state
SendScriptCallback("dynzone_changed_block_state",
previously_blocked_routes, new_blocked_routes)
end
-------------------------
-- Callbacks --
-------------------------
--[[
main routine is indirectly triggered through `on_before_surge` callback
We then check every x seconds if an emission is happening and
execute during an appropriate stage.
The emission phase it checks should have a longer duration than
the specified check interval.
Why not execute through the respective callback directly?
1. Not a lot of scripts are running during an emission.
2. Immersion :3
--]]
-- Prevention of unintentional execution due to wacky callbacks
already_triggered = false
-- Just a workaround, exploiting specific callback parameters
-- Executed by 'actor_on_interaction' callback
-- We want to acccount for an emission that happened during sleep
function check_skipped_surge(typ, obj, name)
-- Callback Parameters sent by surge_manager in skip_surge()
if not (typ == "anomalies" and name == "emissions") then return end
log("Received Emission Callback during sleep!")
-- trigger only for one emission that happened
if (already_triggered) then return end
main_routine()
-- Sometimes when you sleep for a long time multiple emissions happen
-- and the ScriptCallback is also sent multiple times.
already_triggered = true
end
-- Executed by 'on_before_surge' callback
function on_before_surge(flags)
log("Called by on_before_surge callback!")
if not (flags and flags.allow) then -- Make sure it is not skipped
log("This emission will be skipped.")
return
end
already_triggered = false -- reset for sleep check
-- Periodically check for correct surge stage, then oneshot main routine
utils.timed_call(opt.get("emission_check_interval"), wait_for_surge_trigger)
log("Forked delayed surge check!")
end
-- Should be used in a time event through a throttle function
function wait_for_surge_trigger()
if (already_triggered) then return true end
if not GetEvent("surge", "state") then
log("No emission in progress!")
return true
end
local gsm = surge_manager and surge_manager.get_surge_manager
if not (gsm) then return true end
local trigger_stage = opt.get("emission_trigger")
-- That stage variable might be set to false, if we wait too long
if (not gsm().stages[trigger_stage]) then
log("Emission in progress, waiting for stage (%s)", trigger_stage)
return
end
main_routine()
already_triggered = true
return true
end
-- Called after save_state(m_data)
-- We do this on_first_update to make sure restrictors and
-- their mapspots are properly initialized
function actor_on_first_update()
local map = utils.get_mapname() -- No need to execute in those cases.
if (maps_unblockable[map] or utils.is_underground(map)) then
log("Loaded level %s, will not execute.", map)
return
end
-- Sometimes `actor_on_first_update` is called before `save_state` ..
local m_data = alife_storage_manager.get_state()
if (not m_data[data_key]) then
m_data[data_key] = {}
m_data[data_key].newgame = true
end
if (m_data[data_key].newgame) then
if opt.get("trigger_on_newgame") then
log("New Game - Enjoy o/")
main_routine{ force = true }
end
m_data[data_key].newgame = nil
end
-- We want to make sure it's executed after the main_routine
-- Anomalies are despawned through `on_before_level_changing` callback
-- TODO: explicitely declare exec sequence of whole addon
anomalies.spawn_on_current_level()
end
function on_option_change()
if (not opt.get("addon_removal")) then return end
addon_safe_removal()
opt.set_config("general", "addon_removal", false)
end
---------------------------
-- Persistent Data --
---------------------------
function save_state(m_data)
local data = m_data[data_key]
if (not data) then
m_data[data_key] = {}
-- cleared on actor_on_first_update()
m_data[data_key].newgame = true
data = m_data[data_key]
end
data.last_version = VERSION -- may be used for e.g. compatibility patches
-- route_manager
data.db_transitions = route_manager.registered_transitions
data.db_routes = route_manager.registered_routes
-- news manager
data.news_routes_to_reveal = news_manager.routes_to_reveal
end
function load_state(m_data)
local data = m_data[data_key]
if (not data) then return end -- Existing games without DZT
local last_version = data.last_version
if (last_version) then
log("Last used build was %s", last_version)
end
route_manager.registered_transitions = data.db_transitions or {}
route_manager.registered_routes = data.db_routes or {}
news_manager.routes_to_reveal = data.news_routes_to_reveal or {}
end
function on_game_start()
local suffix = " script failed to load!"
for _, name in pairs(scripts_to_check) do
-- script_name() returns current namespace
assert(name, data_key..": "..name..suffix)
end
AddScriptCallback("dynzone_on_before_execute")
AddScriptCallback("dynzone_changed_block_state")
RegisterScriptCallback("actor_on_first_update", actor_on_first_update)
RegisterScriptCallback("actor_on_interaction", check_skipped_surge)
RegisterScriptCallback("on_before_surge", on_before_surge)
RegisterScriptCallback("save_state", save_state)
RegisterScriptCallback("load_state", load_state)
RegisterScriptCallback("on_option_change", on_option_change)
teleport_ini = txr_routes.sr_teleport_ini
or ini_file("sr_teleport_sections.ltx") -- for those without modded exes
end
@@ -0,0 +1,780 @@
--[[
DYNAMIC ZONE - Anomalies Spawn Module
Original Author(s)
VodoXleb <vodoxlebushek>
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)
Synopsis
Main module responsible for spawning anomalies on transitions that are
blocked. Anomalies are spawned randomly in a given radius around the
teleport space restrictor.
Additionally we also spawn anomalies on predefined coordinates due
to missing level vertex id's on some map spots.
Anomalies are only being spawned for the current level as we handle over
20 of those per blocked transition in order to not cap the
a-life limit with heavily modded games.
demonized' kd_tree is used for rudimentary distance checks
We've also utilized some modified anomaly spawn code from DAO (marked below)
--]]
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end
--------------------------
-- Dependencies --
--------------------------
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
------------------------------------------
-- Global Variables & Constants --
------------------------------------------
CONST_LOGGING_PREFIX = "Anomalies"
local log = debug.log_register("info", CONST_LOGGING_PREFIX)
local log_warn = debug.log_register("warning", CONST_LOGGING_PREFIX)
local log_error = debug.log_register("error", CONST_LOGGING_PREFIX)
CONST_MDATA_KEY_USED_TRANSITION = "last_taken_transition"
CONST_STRING_THEME_ID = "st_dynzone_$NAME_theme"
CONST_ANOMALY_FALLBACK_SIZE_MIN = 2 -- fallback values
CONST_ANOMALY_FALLBACK_SIZE_MAX = 4
CONST_FIELD_SIZE_MIN = 30 -- static spawn at transition center
CONST_FIELD_SIZE_MAX = 40
CONST_SAFEZONE_RADIUS = 10
CONST_GENERATE_NARROW_FACTOR = 0.6
CONST_ABORT_CREATION_DELAY_MS = 250
CONST_BASE_INI_DIR = "plugins\\dynamic_zone\\anomalies"
ini_transition_params = ini_file_ex(CONST_BASE_INI_DIR .. "\\transition_settings.ltx")
ini_static_spawns = ini_file_ex(CONST_BASE_INI_DIR .. "\\static_spawns.ltx")
--------------------------------------
-- Anomaly Spawn Settings --
--------------------------------------
-- Contains spawn parameters for transitions - also the defaults
spawn_parameters = {
defaults = { -- Needs to contain every possible key (failsafe)
rootpos = false,
narrow = false,
count = 19,
max_height_offset = 0,
spread_radius = 20,
generate_max_tries = 64,
min_anomaly_proximity = 1,
static_spawn_ratio = 0.4
},
}
anomaly_themes = {
[1] = {
name = "thermal",
anomaly_field_types = {
"zone_field_thermal_strong",
"zone_field_thermal_average",
},
anomaly_mines_types = {
"zone_mine_thermal_strong",
"zone_mine_thermal_average",
"zone_mine_thermal_weak",
},
},
[2] = {
name = "acidic",
anomaly_field_types = {
"zone_field_acidic_strong",
"zone_field_acidic_average",
},
anomaly_mines_types = {
"zone_mine_acidic_strong",
"zone_mine_acidic_average",
"zone_mine_acidic_weak",
"zone_mine_chemical_strong",
"zone_mine_chemical_average",
"zone_mine_chemical_weak",
},
},
[3] = {
name = "electric",
anomaly_field_types = {
"zone_field_psychic_strong",
"zone_field_psychic_average",
},
anomaly_mines_types = {
"zone_mine_electric_strong",
"zone_mine_electric_average",
"zone_mine_electric_weak",
},
},
[4] = {
name = "gravitational",
anomaly_field_types = {
"zone_field_radioactive_strong",
"zone_field_radioactive_average",
},
anomaly_mines_types = {
"zone_mine_gravitational_strong",
"zone_mine_gravitational_average",
"zone_mine_gravitational_weak",
},
},
}
-- TODO: Use table from DAO/Arrival, if available
-- defining anomaly radii for most in-game anomalies
anomaly_radii = {
zone_radioactive = {min = 4, max = 6},
zone_radioactive_weak = {min = 4, max = 6},
zone_radioactive_average = {min = 4, max = 6},
zone_radioactive_strong = {min = 4, max = 6},
zone_mine_acid = {min = 2, max = 3},
zone_mine_acidic_weak = {min = 2, max = 3},
zone_mine_acidic_average = {min = 2, max = 3},
zone_mine_acidic_strong = {min = 2, max = 3},
zone_mine_blast = {min = 2, max = 3},
zone_mine_umbra = {min = 2, max = 3},
zone_mine_electra = {min = 2, max = 3},
zone_mine_electric_weak = {min = 2, max = 3},
zone_mine_electric_average = {min = 2, max = 3},
zone_mine_electric_strong = {min = 2, max = 3},
zone_mine_flash = {min = 3, max = 3},
zone_mine_ghost = {min = 2, max = 3},
zone_mine_gold = {min = 2, max = 3},
zone_mine_thorn = {min = 2, max = 3},
zone_mine_seed = {min = 3, max = 3},
zone_mine_shatterpoint = {min = 6, max = 8},
zone_mine_gravitational_weak = {min = 2, max = 3},
zone_mine_gravitational_average = {min = 3, max = 5},
zone_mine_gravitational_strong = {min = 4, max = 6},
zone_mine_sloth = {min = 3, max = 4},
zone_mine_mefistotel = {min = 3, max = 4},
zone_mine_net = {min = 2, max = 3},
zone_mine_point = {min = 2, max = 3},
zone_mine_cdf = {min = 2, max = 3},
zone_mine_sphere = {min = 4, max = 5},
zone_mine_springboard = {min = 4, max = 6},
zone_mine_thermal_weak = {min = 1, max = 2},
zone_mine_thermal_average = {min = 1, max = 2},
zone_mine_thermal_strong = {min = 1, max = 2},
zone_mine_zharka = {min = 1, max = 2},
zone_mine_vapour = {min = 1, max = 2},
zone_mine_vortex = {min = 3, max = 5},
}
------------------------------
-- Global Getters --
------------------------------
-- Getter for the coresponding string identifier determined by anomaly_themes[id].name
-- @param theme_id (index from anomaly_theme table)
-- @returns string (identifier used in xml)
function get_theme_string(theme_id)
if not utils.valid_type{ caller = "get_theme_string",
"int", theme_id } then return end
utils.assert(anomaly_themes[theme_id], "Undefined theme with ID #%s!", theme_id)
local theme_name = anomaly_themes[theme_id] and anomaly_themes[theme_id].name
local string_id = CONST_STRING_THEME_ID
return (theme_name) and string_id:gsub("%$NAME", theme_name)
end
-- Ensures that we always have usable spawn parameters
-- Uses defaults section as a fallback
-- @param transition_name
-- @returns table
function get_spawn_parameters(transition_name)
if not utils.valid_type{ caller = "get_spawn_parameters",
"str", transition_name } then return end
local defaults = spawn_parameters.defaults
if (utils.is_table_empty(spawn_parameters[transition_name])) then
log("No parameters defined for transition '%s', using defaults.", transition_name or "N/A")
return defaults
end
return spawn_parameters[transition_name]
end
-- Returns the root position for anomaly spawns of the given transition
-- @param transition_name
-- @returns pos (copy) or nil
function get_rootposition(transition_name)
if not utils.valid_type{ caller = "get_rootposition",
"str", transition_name } then return end
local params = get_spawn_parameters(transition_name)
local pos = params and params.pos
return pos and vector():set(pos.x, pos.y, pos.z)
end
-----------------------------
-- Configuration --
-----------------------------
function collect_spawn_parameters(section)
local settings, target = ini_transition_params:collect_section(section)
spawn_parameters[section] = {}
target = spawn_parameters[section]
for key, value in pairs(settings) do
if (key == "rootpos") then
target[key] = utils.string_to_vector(value) or false
goto continue
end
local value_bool = utils.string_to_bool(value)
target[key] = (type(value_bool) == "bool") and value_bool
or tonumber(value) or value
:: continue ::
end
end
-- Returns the appropriate radius, to ensure we default to fallback values, if necessary
-- @param anomaly_type section of the anomaly
-- @returns table || nil
function get_anomaly_radii(anomaly_type)
if (not anomaly_type) then return end
-- Fields share the same radii in our use case
if string.find(anomaly_type, "^zone_field_") then
return {min = CONST_FIELD_SIZE_MIN,
max = CONST_FIELD_SIZE_MAX}
end
return anomaly_radii[anomaly_type]
or {min = CONST_ANOMALY_FALLBACK_SIZE_MIN, max = CONST_ANOMALY_FALLBACK_SIZE_MAX}
end
----------------------------
-- Monkey patches --
----------------------------
SRTeleportMsgBoxOk = ui_sr_teleport.msg_box_ui.OnMsgOk
-- We gotta save the last used transition for Anomaly safe Zone
function ui_sr_teleport.msg_box_ui.OnMsgOk(self)
local transition_name, m_data, data = self.name
local route = route_manager.get_route_by_transition(transition_name)
if (utils.assert_failed(route) or not route.blocked) then goto teleport end
m_data = alife_storage_manager.get_state()
data = m_data and m_data[parent.data_key]
data[CONST_MDATA_KEY_USED_TRANSITION] = transition_name
log("Saved '%s' as last used transition", data[CONST_MDATA_KEY_USED_TRANSITION])
:: teleport ::
return SRTeleportMsgBoxOk(self)
end
-------------------------
-- Callbacks --
-------------------------
function on_game_start()
RegisterScriptCallback("dynzone_changed_block_state", dynzone_changed_block_state)
RegisterScriptCallback("on_level_changing", release_all_anomalies)
ini_transition_params:section_for_each(collect_spawn_parameters)
end
-- Called in main script by actor_on_first_update because it
-- should be executed after routes have been updated
-- Spawns anomalies on the current level for blocked transitions
-- Only does so, if spawned_anomalies of the transition is empty
-- TODO: encapsulate from main script (e.g. new Callback)
function spawn_on_current_level(force)
for route_id, route in route_manager.iterate_routes() do
if not (route.blocked or force) then goto next_route end
spawn_for_route_on_current_level(route_id)
:: next_route ::
end
end
-- Sets the Anomaly Theme, Spawns and Despawns anomalies accordingly
-- We check the spawned_anomalies attribute to
-- determine if that transition is on current level
function dynzone_changed_block_state(previously_blocked, new_blocked)
for route_id, _ in pairs(previously_blocked) do
route_manager.set_route_property(route_id, "anomaly_theme", 0)
for section, attributes in route_manager.iterate_transitions(route_id) do
if (not utils.is_table_empty(attributes.spawned_anomalies)) then
release_all_from_transition(section)
end
end
end
for route_id, _ in pairs(new_blocked) do
set_route_theme(route_id) -- We need to set it beforehand for e.g. the news
spawn_for_route_on_current_level(route_id)
end
end
-- Called 'on_level_changing'
function release_all_anomalies()
if (utils.debug_level_loaded()) then return end
log("Releasing anomalies on '%s'", utils.get_mapname())
for route_id, route in route_manager.iterate_routes() do
for section, attributes in route_manager.iterate_transitions(route_id) do
if (not utils.is_table_empty(attributes.spawned_anomalies)) then
release_all_from_transition(section)
end
end
end
end
------------------------------
-- Main functions --
------------------------------
-- Sets a specific or random theme for any given route
-- @param gizmo transition-section || route-id
-- @param theme_id Optional theme id (randomized, if invalid)
function set_route_theme(gizmo, theme_id)
local theme = (theme_id and anomaly_themes[theme_id]) and theme_id
or math.random(1, #anomaly_themes)
local route_id = (route_manager.route_exists(gizmo)) and gizmo
or route_manager.get_route_by_transition(gizmo).id
if not utils.valid_type{ caller = "set_route_theme",
"int", route_id, "int", theme } then return end
route_manager.set_route_property(route_id, "anomaly_theme", theme)
if (route_manager.get_route_property(route_id, "anomaly_theme") ~= theme) then
log_warn("Unable to set route theme for route #%s", route_id)
end -- Just a sanity check
end
function spawn_for_route_on_current_level(route_id)
if not utils.valid_type{ caller = "spawn_for_route_on_current_level",
"int", route_id } then return end
local actor_level = utils.get_mapname()
if (utils.is_underground(actor_level)) then
log("Loaded underground level %s, will not execute.", actor_level)
return
end
local route = route_manager.get_route(route_id)
local m_data, safe_zone = alife_storage_manager.get_state()
local data = m_data and m_data[parent.data_key]
local last_used_transition = data[CONST_MDATA_KEY_USED_TRANSITION]
if (utils.table_has(route.members, last_used_transition)) then
data[CONST_MDATA_KEY_USED_TRANSITION] = nil
safe_zone = true
end
for section, attributes in route_manager.iterate_transitions(route_id) do
if (not utils.is_table_empty(attributes.spawned_anomalies)) then
log_warn("[%s] Already spawned anomalies for '%s'", route_id, section)
goto continue
end
local se_obj = section and get_story_se_object(section)
if utils.assert_failed(se_obj, "No object with the ID #%s exists!", id) then
goto continue
end
-- We need to check, if transition is on same level
local transition_level = utils.get_mapname(se_obj)
if (actor_level ~= transition_level) then goto continue end
populate_transition(section, safe_zone)
-- Just a logging check
local spawned = route_manager.get_transition_property(section, "spawned_anomalies")
log("Spawned %s anomalies for %s\n# %s",
size_table(spawned), section, table.concat(spawned, ", "))
:: continue ::
end
end
-- Spawns anomalies on a transition (static from LTX and dynamically)
-- Also reads transition specific spawn parameters from the INI
function populate_transition(section, safe_zone_around_player)
if not utils.valid_type{ caller = "populate_transition",
"str", section } then return end
local route_id = route_manager.get_route_id(section)
local theme_id = route_id and route_manager.get_route_property(route_id, "anomaly_theme")
local anomaly_theme = anomaly_themes[theme_id]
if utils.assert_failed(anomaly_theme and type(anomaly_theme) == "table") then
return
end
local parameters = get_spawn_parameters(section)
if utils.assert_failed(parameters) then return end
local rpos = parameters.rootpos
log("Spawning %s dynamic anomalies for '%s'%s (Theme: %s)",
parameters.count +1, section,
rpos and string.format(" @(%.1f, %.1f, %.1f)", rpos.x, rpos.y, rpos.z) or "",
game.translate_string(get_theme_string(theme_id)))
local transition_field_types = anomaly_theme.anomaly_field_types
local guarenteed_spawn_type = transition_field_types[math.random(1, #transition_field_types)]
spawn_at_transition(section, guarenteed_spawn_type, true, nil, parameters)
local position_data = kd_tree.buildTreeVectors()
if (utils.assert_failed(position_data)) then return end
local types = anomaly_theme.anomaly_mines_types
local count = parameters.count
while (count > 0) do
local anomaly_type = types[math.random(1, #types)]
utils.assert(anomaly_type)
spawn_at_transition(section, anomaly_type, false, position_data, parameters)
count = count -1
end
-- We now spawn static anomalies which fill the gaps on the map that have no level id's
populate_transition_with_static_spawns(section, anomaly_theme, position_data, parameters)
if (not safe_zone_around_player) then return end
local actor_pos, transition = db.actor:position(), route_manager.get_transition(section)
log("Establishing Safe-Zone around player for transition '%s' @(%.1f, %.1f, %.1f)",
section, actor_pos.x, actor_pos.y, actor_pos.z)
--[[
TODO: Only use one tree and directly attach Obj-ID's to position data
Gotta build a new tree, otherwise we won't have their object-id as reference
Returns table with elements like this: { { x = a, y = b, z = c, data = d}, distance }
--]]
local pos_tree = kd_tree.buildTreeSeObjectIds(transition.spawned_anomalies)
local anomalies_near = pos_tree:nearestAll(actor_pos)
if utils.assert_failed(not utils.is_table_empty(anomalies_near)) then
log_error("Position Tree returned no object references")
return
end
for _, anomaly_info in pairs(anomalies_near) do
local obj_id, distance = anomaly_info[1].data, anomaly_info[2]
if (distance > CONST_SAFEZONE_RADIUS) then break end -- Sorted by distance
local se_obj = alife_object(obj_id)
if (utils.assert_failed(se_obj, "Got no server object with ID #%s", obj_id)) then
goto continue
end
local anomaly_name = se_obj:section_name()
if (anomaly_name == guarenteed_spawn_type) then goto continue end
log("Releasing previously spawned Anomaly '%s' with ID #%s (Distance: %.2f)",
anomaly_name, obj_id, distance)
local id_idx = utils.index_of(transition.spawned_anomalies, obj_id)
if utils.assert_failed(id_idx) then
log_error("[#%s] Unable to find Obj-ID #%s in {%s}",
route_id, obj_id, unpack(transition.spawned_anomalies, ", "))
goto continue
end
table.remove(transition.spawned_anomalies, id_idx)
alife_release_id(obj_id)
:: continue ::
end
end
-- Randomly spawns anomalies from a predefined pool in the ini
-- @param transition_name section of target transition
-- @param anomaly_theme pointer to the theme (anomaly_themes[theme_id])
function populate_transition_with_static_spawns(transition_name, anomaly_theme, position_data, parameters)
if not utils.valid_type{ caller = "populate_transition_with_static_spawns",
"str", transition_name, "tbl", anomaly_theme, "tbl", parameters } then return end
if (not ini_static_spawns:section_exist(transition_name)) then
log("Transition '%s' has no predefined static spawns, skipping.", transition_name)
return
end
local min_anomaly_proximity = parameters.min_anomaly_proximity
local spawn_percent = parameters.static_spawn_ratio
local transition_spawns = ini_static_spawns:collect_section(transition_name)
local types = anomaly_theme.anomaly_mines_types
_g.shuffle_table(transition_spawns)
local shuffled_spawns = {}
for _, v in pairs(transition_spawns) do -- reindex for next step
shuffled_spawns[#shuffled_spawns +1] = v
end
local remaining_spawns = _g.round(#shuffled_spawns * spawn_percent)
log("Trying to populate transition %s with %s static spawns", transition_name, remaining_spawns)
local spawned = route_manager.get_transition_property(transition_name, "spawned_anomalies")
for index in utils.random_numbered_sequence(1, #shuffled_spawns) do
if (remaining_spawns < 1) then break end
local position_raw = shuffled_spawns[index]
local data = position_raw and utils.string_to_posdata(position_raw)
if (not data) then goto continue end
local anomaly_type, pos = types[math.random(1, #types)], data.pos
if (not posdata_distance_check(position_data, pos, anomaly_type, min_anomaly_proximity)) then
goto continue
end
local anomaly_id = drx_da_spawn_anomaly(anomaly_type, pos, data.lvid, data.gvid)
if (anomaly_id) then
spawned[#spawned +1] = anomaly_id
remaining_spawns = remaining_spawns -1
end
:: continue ::
end
end
-- @param transition_name section of target transition
-- @param anomaly_type section of anomaly
-- @param spawn_at_center boolean
-- @param position_data kd_tree; used when spawn_at_center = false (optional)
-- @param parameters needed when spawn_at_center = false
function spawn_at_transition(transition_name, anomaly_type, spawn_at_center, position_data, parameters)
-- TODO: Create corresponding function in utils
local id = transition_name and get_story_object_id(transition_name)
local transition_obj = id and alife_object(id)
if (not transition_obj) then
log_error("Can't create dynamic anomaly for %s, the specified transition does not exist.", transition_name)
return
end
local pos = (parameters) and parameters.rootpos or transition_obj.position
if (not spawn_at_center) then
pos = generate_valid_position(transition_obj, anomaly_type, parameters, position_data)
if (not pos) then
log_error("Failed to generate position. Aborting.")
return
end
end
local lvid = level.vertex_id(pos)
local anomaly_id = drx_da_spawn_anomaly(anomaly_type,
pos, lvid, transition_obj.m_game_vertex_id)
if (not anomaly_id) then return end
local spawned = route_manager.get_transition_property(transition_name, "spawned_anomalies")
spawned[#spawned +1] = anomaly_id
return true
end
-- Releases all spawned anomalies tied to any given transition
function release_all_from_transition(transition_name)
if not utils.valid_type{ caller = "release_all_from_transition",
"str", transition_name } then return end
local transition = route_manager.get_transition(transition_name)
local spawned_anomalies = transition and transition.spawned_anomalies
if (utils.assert_failed(spawned_anomalies)) then return end
local released = {}
for index, id in pairs(spawned_anomalies) do
if (alife_release_id(id)) then
released[#released +1] = id
else
log_error("Unable to release anomaly with ID #%s", id)
end
end
transition.spawned_anomalies = {} -- make sure no old references remain
log("Released the following %s IDs for %s\n# %s",
size_table(released), transition_name, table.concat(released, ", "))
end
function posdata_distance_check(position_data, pos, anomaly_type, minimum_proximity)
if not utils.valid_type{ caller = "posdata_distance_check",
"tbl", position_data, "usr", pos, "str", anomaly_type, "number", minimum_proximity } then return end
if (not position_data) then
return true
elseif (not position_data.root) then
position_data:insertAndRebuild{x = pos.x, y = pos.y, z = pos.z}
return true
end
local nearest = position_data:nearest(pos)
if not (nearest and nearest[1] and nearest[1][2]) then
log("Can't check position data (%.2f, %.2f, %.2f)", pos.x, pos.y, pos.z)
position_data:insertAndRebuild{x = pos.x, y = pos.y, z = pos.z}
return true
end
local radii = get_anomaly_radii(anomaly_type)
local anomaly_radius = _g.round((radii.min + radii.max) / 2) -- mean
local spawned_distance = nearest[1][2] - (anomaly_radius *2)
if (spawned_distance >= minimum_proximity) then
position_data:insertAndRebuild{x = pos.x, y = pos.y, z = pos.z}
return true
end
log("Position invalid. Too close. (Distance %s < %s) @(%.2f, %.2f, %.2f)",
spawned_distance, minimum_proximity, pos.x, pos.y, pos.z)
end
-- ANOMALY SPAWN FUNCTIONS
-- THE FOLLOWING LINES ARE TAKEN FROM drx_da_main.script
-- FROM DYNAMIC ANOMALIES OVERHAUL (DAO) (UPDATE 25)
-- CREDITS TO TheMrDemonized, DoctorX, Eugenium, Barry Bogs, Lucy, Grok, Jurkonov, CrimsonVirus
--
-- THESE FUNCTIONS HAVE BEEN ADAPTED FOR USE IN THIS ADDON BY THE MOD AUTHORS
-- Spawns one anomaly of any valid given type
-- @param anomaly_type (anomaly section)
-- @param pos, lvid, gvid (self-explanatory)
-- @returns se_obj_id
function drx_da_spawn_anomaly(anomaly_type, pos, lvid, gvid)
if not (anomaly_type and ini_sys:section_exist(anomaly_type)) then
log_error("Anomaly type '%s' does not exist! Aborting.", anomaly_type)
return
end
local se_obj = alife():create(anomaly_type, pos, lvid, gvid)
if (not se_obj) then
log_error("[%s] Unable to spawn anomaly.", anomaly_type)
return
end
local function abort_creation(se_obj_id, anomaly_type)
utils.timed_call(CONST_ABORT_CREATION_DELAY_MS, function()
local obj = alife_object(se_obj_id)
if obj then
log_error("[%s] Anomaly '%s' failed to spawn correctly, releasing.",
se_obj_id, anomaly_type)
alife_release(obj)
end
return true
end)
end
local data = utils_stpk.get_anom_zone_data(se_obj) -- get anomaly properties
if (not data) then
abort_creation(se_obj.id, anomaly_type)
return
end
data.shapes[1] = {}
data.shapes[1].shtype = 0
data.shapes[1].offset = vector():set(0, 0, 0) -- Leave for compatibility with CoC 1.4.22, delete later
data.shapes[1].center = vector():set(0, 0, 0)
local radii = get_anomaly_radii(anomaly_type)
data.shapes[1].radius = math.random(radii.min, radii.max)
utils_stpk.set_anom_zone_data(data, se_obj)
return se_obj.id
end
-- Generates a random valid position from a vertex in radius around a given transition
-- @param se_obj game object of the transition space restrictor
-- @param anomaly_type section of the anomaly to be spawned
-- @param spawn_params table containing the spawn parameters
-- @param position_data (optional)
function generate_valid_position(se_obj, anomaly_type, spawn_params, position_data)
if not utils.valid_type{ caller = "generate_valid_position",
"usr", se_obj, "str", anomaly_type, "tbl", spawn_params } then return end
local random = math.random
local level_vertex_id = level.vertex_id
local level_vertex_position = level.vertex_position
local rootpos = spawn_params.rootpos or se_obj.position
local num_tries = spawn_params.generate_max_tries
local spread_radius = spawn_params.spread_radius
local minimum_proximity = spawn_params.min_anomaly_proximity
local max_offset = {x = spread_radius, y = spread_radius, z = spread_radius}
if (spawn_params.narrow) then
for k, v in pairs(max_offset) do
max_offset[k] = math.floor(max_offset[k] * CONST_GENERATE_NARROW_FACTOR)
end
end
local pos = vector():set(0, 0, 0)
-- In the following, we randomly offset the position to
-- get a valid level vertex id we can spawn an anomaly on
while (num_tries > 0) do
local offset_x = max_offset.x * random()
local pos_x = (random() <= 0.5) and (rootpos.x +offset_x)
or (rootpos.x -offset_x)
local offset_y = max_offset.y * random()
local pos_y = (random() <= 0.5) and (rootpos.y +offset_y)
or (rootpos.y -offset_y)
local offset_z = max_offset.z * random()
local pos_z = (random() <= 0.5) and (rootpos.z +offset_z)
or (rootpos.z -offset_z)
-- Set anomaly position at vertex and check if valid:
pos = vector():set(pos_x, pos_y, pos_z)
local check_distance -- interpreter would complain bc of goto statements
pos = utils.get_closest_vertex_pos(pos)
if (not pos) then goto next_try end
check_distance = pos:distance_to(rootpos)
if (check_distance > spread_radius) then
log_error("Distance to root position is too high (%s)", check_distance)
goto next_try
end
if (posdata_distance_check(position_data, pos, anomaly_type, minimum_proximity)) then
break
end
:: next_try ::
num_tries = (num_tries - 1)
end
if (num_tries <= 0) then
log_error("Unable to generate valid lvid position, aborting.")
return
end
return pos
end
@@ -0,0 +1,206 @@
--[[
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)
--]]
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
------------------------------------------
-- Global Variables & Constants --
------------------------------------------
CONST_BLOCKSTATECHANGE_NOTIFY_DELAY_MS = 100
CONST_LOGGING_PREFIX = "[DZT]" -- used for console logging
valid_logging_channels = { def = "INFO", info = "INFO", warning = "WARNING", error = "ERROR" }
registered_logging_channels = { --[[
info = instance,
warning = instance,
...
--]] }
local log_mcm
---------------------
-- Callbacks --
---------------------
function on_game_start()
RegisterScriptCallback("on_game_load", inject_debug_commands)
RegisterScriptCallback("dynzone_changed_block_state", dynzone_changed_block_state)
-- Due to this, functors called before creating those instances will log to xray console
log_mcm = log_register("info", "Debug")
create_logging_instances()
end
function inject_debug_commands()
-- no need, if not enabled. Idk if it would even break something
if not (DEV_DEBUG or DEV_DEBUG_DEV) then return end
local CMD = debug_cmd_list.command_get_list()
function CMD.dynamic_zone(_, __, x)
x:SendOutput("/ DYNAMIC ZONE " .. parent.VERSION_STRING)
parent.main_routine{ force = true }
end
function CMD.dynamic_zone_accessible(_, __, x)
x:SendOutput("- Reset all route states!")
parent.addon_safe_removal()
end
ui_debug_launcher.inject("action", { name = "[DYNZONE] Trigger",
cmd = "dynamic_zone", hide_ui = 2, key = "DIK_D" } )
ui_debug_launcher.inject("action", { name = "[DYNZONE] Clear routes",
cmd = "dynamic_zone_accessible", hide_ui = 2 } )
end
-- Just informs us about the exact block state
-- A bit hacky to prevent recursive referencing
function dynzone_changed_block_state(previous, current)
if (not opt.get("verbose")) then
UnregisterScriptCallback("dynzone_changed_block_state", dynzone_changed_block_state)
end
-- We only use it in this function, to prevent recursive referencing
local route_manager = dynamic_zone_routes
-- Done this way, so it shows the updated values changed by other functions
utils.timed_call(CONST_BLOCKSTATECHANGE_NOTIFY_DELAY_MS, function(previous, current)
local output_str = "Previously Blocked routes:"
for route_id, _ in pairs(previous) do
output_str = output_str .. "\n" .. route_manager.route_tostring(route_id, true)
end
if (size_table(previous) > 0) then
log_mcm("%s\nA total of %s routes were blocked.",
output_str, size_table(previous))
end
local output_str = "Newly Blocked routes:"
for route_id, _ in pairs(current) do
output_str = output_str .. "\n" .. route_manager.route_tostring(route_id, true)
end
if (size_table(current) > 0) then
log_mcm("%s\nA total of %s routes are freshly blocked.",
output_str, size_table(current))
end
local blocked, discovered = 0, 0
for route_id, route in route_manager.iterate_routes(true) do
blocked = (route.blocked) and (blocked +1) or blocked
discovered = (route.block_discovered) and (discovered +1) or discovered
end
log_mcm("A total of %s routes are blocked (%s discovered)", blocked, discovered)
return true
end, previous, current)
end
---------------------------
-- Debugging --
---------------------------
-- These are mainly used by this script or others where it is more suited than mcm logging
function log(message, ...)
if (not opt.get("verbose")) then return end
local args = {...}
local output = string.format("%s %s", CONST_LOGGING_PREFIX, message)
printf(output, unpack(args))
end
-- These two are more important, thus are always logged to console.
function log_warn(message, ...)
local args = {...}
local output = string.format("~%s %s", CONST_LOGGING_PREFIX, message)
printf(output, unpack(args))
end
function log_error(message, ...)
local args = {...}
local output = string.format("!%s %s", CONST_LOGGING_PREFIX, message)
printf(output, unpack(args))
end
-- REQUIRES MOD-CONFIGURATION-MENU (MCM)
-- We only create logging instances `on_game_start`, so we don't load mcm_log earlier than necessary.
function create_logging_instances()
if (_g.is_empty(registered_logging_channels)) then
return
elseif not (mcm_log and mcm_log.new) then
log_warn("MCM Logging utility not found. Falling back to console logging.")
return
end
for channelname, value in pairs(registered_logging_channels) do
if (type(value) == "userdata") then goto continue end
local logger = mcm_log and mcm_log.new and mcm_log.new(channelname)
if (not logger) then
log_warn("Unable to create a MCM logging instance! Falling back to console logging.")
callstack()
return
end
logger.continuous = true
logger.enabled = true
registered_logging_channels[channelname] = logger
:: continue ::
end
end
-- Called like this: e.g. log_info = debug.log_register("info", "Anomalies")
-- will only return function that prints to logs, when verbose setting is active
-- Will log via the MCM utility, only to console as fallback
-- Using string.format to be able to use same rules as ISO C sprintf
-- @param channelname logging level - defaults to info, if invalid
-- @param identifier optional
-- @returns pointer to logging function which uses mcm logging, when available
function log_register(channelname, identifier)
if (not channelname) then return end
identifier = identifier and string.upper(identifier)
channelname = valid_logging_channels[string.lower(channelname)]
or valid_logging_channels.def
registered_logging_channels[channelname] = true
local id_prefix = (identifier and type(identifier == "string"))
and (" - " .. identifier) or ""
return function(message, ...)
if (not opt.get("verbose")) then return end
local data = { channel = channelname, id = id_prefix }
local logger = (type(registered_logging_channels[data.channel]) ~= "boolean")
and registered_logging_channels[data.channel]
-- Fallback for e.g. before `on_game_start` or if `mcm_log` does not exist
if not (logger and logger.log) then
printf(string.format(CONST_LOGGING_PREFIX .. message, ...))
return
end
local default_prefix = logger.prefix
if (data.id and data.id ~= "") then
logger.prefix = logger.prefix .. data.id
end
logger:log(string.format(message, ...))
logger.prefix = default_prefix
end
end
@@ -0,0 +1,725 @@
--[[
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)
Synopsis
Simple module responsible for updating the route discovery state based
on being close enough to one of the anomalies spawned or alternatively
discovering them via the binoculars.
Also controls mapspot appearance of transitions and adds a blip for
transitions whose state is unknown (not recently discovered).
--]]
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end
--------------------------
-- Dependencies --
--------------------------
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
------------------------------------------
-- Global variables & Constants --
------------------------------------------
CONST_LOGGING_PREFIX = "Discovery"
local log = debug.log_register("info", CONST_LOGGING_PREFIX)
get_anomaly_rootpos = dynamic_zone_anomalies.get_rootposition
CONST_DISCOVERY_CHECK_INTERVAL_MS = 5000
CONST_MAPSPOT_BLIP_OFFSET_PIXELS = 13
CONST_MAPSPOT_BLOCKED_TEXTURE = [[dynamic_zone_exit_point_blocked]]
CONST_MAPSPOT_BLOCKED_COLOR = {255, 240, 240, 240}
CONST_BINOCULAR_CHECK_IN_SIGHT_INTERVAL_MS = 250
CONST_BINOCULAR_REFRESHRATE_MS = 125
CONST_BINOCULAR_HUD_ICON_TEXTURE = [[dynamic_zone_blip_unknown_state]]
CONST_BINOCULAR_HUD_ICON_TEXTURE_SIZE = {19, 21}
CONST_BINOCULAR_HUD_ICON_HOVER_METRES = 1.45 *2 -- Stalker height is roughly 1.45
CONST_BINOCULAR_HUD_MAX_VISIBLE_TRANSITIONS = 8
sound_objs = {
discovery = sound_object("dynamic_zone\\block_discovered"),
binoc_in_progress = sound_object("dynamic_zone\\binoc_discovery_in_progress"),
binoc_aborted = sound_object("dynamic_zone\\binoc_discovery_aborted"),
}
BINOCULAR_HUD = nil -- current CUIScriptWnd subclass instance
BINOCULAR_DISCOVERY_IN_PROGRESS = false
DISCOVERABLE_TRANSITIONS_ON_CURRENT_LEVEL = false
-- Temporary Data -> Not saved to m_data
anomalies_to_check = {} -- [anomaly_obj_id] = route_id
discoverable_on_level = {} -- [transition_obj_id] = route_id
mapspot_blips = {} -- [transition_obj_id] = <blip_instance>
---------------------------
-- Globally Used --
---------------------------
-- used on `parent.actor_on_first_update` and in `parent.main_routine(..)`
function clear_eligible_transitions()
discoverable_on_level = {}
end
function no_eligible_transitions()
return (not DISCOVERABLE_TRANSITIONS_ON_CURRENT_LEVEL)
end
function toggle_known_route_state(route_id, notify_on_new_state)
if not utils.valid_type{ caller = "toggle_known_route_state",
"int", route_id } then return end
if (not eligible_for_discovery(route_id)) then
log("Route #%s was already discovered since last emission.", route_id)
return
end
route_manager.set_route_property(route_id, "recently_discovered", true)
if (route_manager.route_known_state_differs(route_id)) then
local discovered = route_manager.get_route_property(route_id, "block_discovered")
log("Player discovered %s route #%s!", ((discovered)
and "previously blocked" or "blocked"), route_id)
route_manager.set_route_property(route_id, "block_discovered", (not discovered))
if (notify_on_new_state) then
inform_player()
end
end
for name, attributes in route_manager.iterate_transitions(route_id) do
update_transition_mapspot(name)
end
remove_anomaly_check_for(route_id)
for k, v in pairs(discoverable_on_level) do
if (v == route_id) then
discoverable_on_level[k] = nil
end
end
end
-- Changes the mapspot texture and hint for the given transition and adds a blip
-- based on the routes flags. We do this via modded exes functionality.
-- Adds mapspot info blip to transitions to indicate those with unknown state.
-- @param transition_name
-- @param blocked (override; optional)
-- @param only_text (optional; used to only update the text)
function update_transition_mapspot(transition_name, blocked, only_text)
if not utils.valid_type{ caller = "update_transition_mapspot",
"str", transition_name } then return end
local id, spot, hint = parent.get_transition_marker_info(transition_name)
if utils.assert_failed(id and spot and hint) then return end
-- Important when e.g. calling this on 'fake_start'
if (not utils.map_spot_exists(id, spot)) then return end
local route = route_manager.get_route_by_transition(transition_name)
if utils.assert_failed(route, "Transition %s got no assigned route!", transition_name) then return end
local mark_as_blocked = blocked
if (mark_as_blocked == nil) then
mark_as_blocked = route.block_discovered
end
local new_hint = get_suited_mapspot_hint(id, hint, mark_as_blocked)
utils.map_spot_change_hint(id, spot, new_hint)
mapspots_blip_update_for(id, spot)
if (only_text) then return end
change_mapspot_marker(id, spot, mark_as_blocked)
end
-- Ignores route flags; To be used before removal of the addon
-- Reverts all map spots to their default look, also removes the blips
function revert_map_spots()
for route_id, route in route_manager.iterate_routes(false) do
if (not route.block_discovered) then goto continue end
for name, _ in route_manager.iterate_transitions(route_id) do
update_transition_mapspot(name, false)
end
:: continue ::
end
mapspots_blips_remove()
end
-----------------------
-- Callbacks --
-----------------------
function on_game_start()
RegisterScriptCallback("dynzone_on_before_execute", dynzone_on_before_execute)
RegisterScriptCallback("dynzone_changed_block_state", dynzone_changed_block_state)
RegisterScriptCallback("actor_on_update", actor_on_throttled_update)
-- Discovery via spawned anomalies
RegisterScriptCallback("actor_on_feeling_anomaly", actor_on_feeling_anomaly)
-- Binocular discovery
RegisterScriptCallback("actor_on_weapon_zoom_in", actor_on_weapon_zoom_in)
RegisterScriptCallback("actor_on_weapon_zoom_out", actor_on_weapon_zoom_out)
RegisterScriptCallback("actor_on_net_destroy", actor_on_weapon_zoom_out) -- Because of hud visor
RegisterScriptCallback("actor_on_before_death", actor_on_weapon_zoom_out)
RegisterScriptCallback("on_option_change", on_option_change)
RegisterScriptCallback("on_game_load", cache_mapmarker_info)
end
-- This is triggered whenever our main routine is triggered, thus after every emission
function dynzone_on_before_execute()
mapspots_blips_show(true)
end
-- We need to clear our data, it's highly probable that it is invalid.
function dynzone_changed_block_state(previous, new)
clear_eligible_transitions()
-- If it has unregistered itself
RegisterScriptCallback("actor_on_update", actor_on_throttled_update)
end
-- Periodically executes as long as it has determined eligible transitions
-- We clear the eligibility table to refresh the list, this way.
actor_on_throttled_update = utils.throttle(CONST_DISCOVERY_CHECK_INTERVAL_MS, true, function()
if (get_eligible_transitions_on_level()) then return end
UnregisterScriptCallback("actor_on_update", actor_on_throttled_update)
end)
-- Toggles state of route discovery if any anomaly was spawned by our script
function actor_on_feeling_anomaly(obj, tbl)
if (utils.is_table_empty(anomalies_to_check)) then return end
local obj_id = obj and obj.id and obj:id()
local route_id = anomalies_to_check[obj_id]
if (not route_id) then return end
log("[%s] Anomaly belongs to route #%s", obj_id, route_id)
toggle_known_route_state(route_id, true)
end
function actor_on_weapon_zoom_in()
if (no_eligible_transitions()) then return end
RegisterScriptCallback("actor_on_update", check_transition_in_sight)
end
function actor_on_weapon_zoom_out()
UnregisterScriptCallback("actor_on_update", check_transition_in_sight)
binocular_hud_off()
end
function on_option_change()
mapspots_blips_show(opt.get("mapspot_blips_enabled"), true)
end
-- The only purpose of this function is to prevent stuttering due to the usage
-- of the utility function map_spot_get_texture_info(..)
-- This works on the basis of the assumption that each spot got the same attributes
local _cached_mapmarker_info_for
function cache_mapmarker_info()
local spots = {}
for route_id, route in route_manager.iterate_routes() do
for name, attributes in route_manager.iterate_transitions(route_id) do
local _, spot, __ = parent.get_transition_marker_info(name)
if (utils.map_spot_get_texture_info(spot)) then
_cached_mapmarker_info_for = spot
return
end
end
end
end
----------------------------
-- Monkey patches --
----------------------------
SRTeleportMsgBoxOk = ui_sr_teleport.msg_box_ui.OnMsgOk
-- We only update the discovery state, if player takes the transition
-- Executed through ltx script logic (xr_effects)
function ui_sr_teleport.msg_box_ui.OnMsgOk(self)
-- section from teleport_ini (should also be in txr_routes.routes table)
-- e.g. yan_space_restrictor_to_agroprom_1
local route_id = route_manager.get_route_id(self.name)
utils.assert(route_id, "SR-Teleport: '%s' got no assigned route", self.name)
if (not no_eligible_transitions()) then
toggle_known_route_state(route_id)
end
return SRTeleportMsgBoxOk(self)
end
------------------------
-- Main logic --
------------------------
function eligible_for_discovery(route_id)
local route = route_id and route_manager.get_route(route_id)
if utils.assert_failed(route) then return end
return (not route.recently_discovered)
end
function remove_anomaly_check_for(route_id)
if not utils.valid_type{ caller = "remove_anomaly_check_for",
"int", route_id } then return end
for k, v in pairs(anomalies_to_check) do
if (v == route_id) then
anomalies_to_check[k] = nil
end
end
end
-- Registers eligible transitions for discovery on the current level
-- TODO: Use transition name as key
-- Writes data like this: tbl[transition_ID] = route_id
-- @param force (forces a refresh)
-- @returns true (if eligible transitions exist)
function get_eligible_transitions_on_level(force)
if not (utils.is_table_empty(discoverable_on_level) or force) then return true end
local actor_level = utils.get_mapname()
if (utils.debug_level_loaded()) then
return
elseif (utils.is_underground(actor_level)) then
log("Underground level loaded, will not check for eligible transitions.")
return
end
log("Getting eligible transitions for '%s'", actor_level)
-- We only do this implicitly as this depends on the eligbility list
anomalies_to_check = {}
_g.iempty_table(discoverable_on_level)
for route_id, route in route_manager.iterate_routes(false) do
if (route.recently_discovered) then goto next_route end
for name, attributes in route_manager.iterate_transitions(route_id) do
local id = name and get_story_object_id(name)
local se_obj = id and alife_object(id)
if (not se_obj) then goto continue end
local transition_level = utils.get_mapname(se_obj)
if (actor_level ~= transition_level) then goto continue end
discoverable_on_level[id] = route_id
if (route.blocked == route.block_discovered) then break end
for _, anomaly_id in pairs(attributes.spawned_anomalies) do
anomalies_to_check[anomaly_id] = route_id
end
:: continue ::
end
:: next_route ::
end
if (utils.is_table_empty(discoverable_on_level)) then
log("No discoverable transitions on the current level.")
DISCOVERABLE_TRANSITIONS_ON_CURRENT_LEVEL = false
return
end
log("Discoverable transitions on the current Level:\n%s",
utils_data.print_table(discoverable_on_level, false, true))
DISCOVERABLE_TRANSITIONS_ON_CURRENT_LEVEL = true
return true
end
function inform_player(show_msg)
if (not sound_objs.discovery:playing()) then
sound_objs.discovery:play(player, 0, sound_object.s2d)
end
if (not show_msg) then return end
local message = game.translate_string("st_dynzone_discovery_message")
news_manager.send_tip(db.actor, message, 0, "recent_surge",
opt.get("discovery_msg_showtime"))
end
-------------------------
-- Map Markers --
-------------------------
function get_suited_mapspot_hint(id, hint)
if not utils.valid_type{ caller = "get_suited_mapspot_hint",
"int", id, "str", hint } then return end
local route = route_manager.get_route_by_transition(id)
if (utils.assert_failed(route, "ID #%s not a valid transition", id)) then return end
local inactive = route.id and route_manager.route_inactive(route.id)
local gts = game.translate_string
if (not inactive) and (route.blocked or not route.recently_discovered) then
new_hint = string.format("%s (%s)", gts(hint),
(not route.recently_discovered) and gts("st_dynzone_hint_unknown")
or gts("st_dynzone_hint_blocked"))
else
new_hint = gts(hint)
end
-- Route info in mapspot tooltip (debug)
if opt.get("verbose") then
local name = get_object_story_id(id)
new_hint = name and string.format("%s\\n[%s, #%s]", new_hint, name, route.id)
or new_hint
end
return new_hint
end
-- Changes the mapspot marker according to the blocked state of it's transition
-- Properly resets to original mapspot texture and color
-- @param id game object of the transition
-- @param spot default spot texture
-- @param blocked (optional)
function change_mapspot_marker(id, spot, blocked)
if not utils.valid_type{ caller = "mapspot_mark_blocked",
"int", id, "str", spot, "bool", blocked } then return end
if (blocked == nil) then -- get info via route manager
local name = get_object_story_id(id)
local route = name and route_manager.get_route_by_transition(name)
if utils.assert_failed(route) then return end
blocked = route.block_discovered
end
local texture, color = CONST_MAPSPOT_BLOCKED_TEXTURE
if (not blocked) then
local get_spot = _cached_mapmarker_info_for or spot
local texture_info = utils.map_spot_get_texture_info(get_spot)
texture = texture_info and texture_info.texture
color = texture_info and texture_info.color
else
color = GetARGB(_g.unpack(CONST_MAPSPOT_BLOCKED_COLOR))
end
if utils.assert_failed(texture, "Unable to determine a texture for %s", spot) then
return
end
log("Changing mapspot texture of (%s, %s) to '%s'", id, spot, texture)
utils.map_spot_change_texture(id, spot, texture)
if (color) then
log("Changing mapspot color of (%s, %s) to %s (pixelvalue)", id, spot, color)
utils.map_spot_change_color(id, spot, color)
end
end
-- Toggles the visibility of a mapspot blip. Creates it, if needed.
function mapspots_blip_update_for(transition_id, spot)
if not utils.valid_type{ caller = "mapspot_blip_update_for",
"int", transition_id, "str", spot } then return end
local name = get_object_story_id(transition_id)
local route = name and route_manager.get_route_by_transition(name)
if (utils.assert_failed(route, "Transition with ID %s has no defined route!", transition_id)) then return end
if (route_manager.route_inactive(route.id)) then return end
local blip = mapspot_blips[transition_id]
if (not blip) then
mapspot_blips[transition_id] = init_mapspot_blip(transition_id, spot)
blip = mapspot_blips[transition_id]
end
if (utils.assert_failed(blip, "Failed to initialize a blip for transition '%s'", transition_id)) then return end
blip.Show(opt.get("mapspot_blips_enabled") and not route.recently_discovered)
end
-- Toggles the display of blips for all mapspots
function mapspots_blips_show(state, conserve_route_flag)
if not utils.valid_type{ caller = "mapspot_blips_show", "bool", state } then return end
for route_id, route in route_manager.iterate_routes() do
for name, attributes in route_manager.iterate_transitions(route_id) do
local id, spot, _ = parent.get_transition_marker_info(name)
if (utils.assert_failed(id and spot)) then return end
if (not conserve_route_flag) then
route.recently_discovered = (not state)
end
mapspots_blip_update_for(id, spot)
end
end
end
-- Removed all blips and their references, also resets the route flag
-- @param conserve_route_flag (don't alter route flag)
function mapspots_blips_remove(conserve_route_flag)
for id, blip in pairs(mapspot_blips) do
local name = get_object_story_id(id)
local route = name and route_manager.get_route_by_transition(name)
if not (conserve_route_flag or utils.assert_failed(route)) then
route.recently_discovered = false
end
blip.Show(false)
mapspot_blips[id] = nil
end
end
-- Courtesy of Catspaw (Addon: Personal Adjustable Waypoints)
-- Init's a mapspot blip fir a given mapspot (id, spot)
-- @returns table
function init_mapspot_blip(id, spot, anchor)
if not utils.valid_type{ caller = "init_mapspot_blip",
"int", id, "str", spot } then return end
local anchor = anchor or level.map_get_object_spot_static(id, spot)
local xml = CScriptXmlInit()
local xmlroot = "dzt_ui_modifiers"
xml:ParseFile("dzt_ui_elements.xml")
local blip = {}
blip.id = id
blip.box = xml:InitStatic(xmlroot, anchor) -- CUIScriptWnd
blip.box:SetWndPos(vector2():set(CONST_MAPSPOT_BLIP_OFFSET_PIXELS,
CONST_MAPSPOT_BLIP_OFFSET_PIXELS +3)) -- sets (x,y) but root is top left
blip.blip = xml:InitStatic(xmlroot .. ":blip_unknown", blip.box)
blip.blip:SetWndSize(vector2():set(_g.round(19 *0.8), _g.round(21 *0.8)))
blip.Show = function(tf)
blip.box:Show(tf)
end
return blip
end
-- Removes mapspots added by the old method prior to version 20241119
function mapspots_remove_old()
mapspots_blips_remove(true)
for route_id, route in route_manager.iterate_routes() do
for transition_name, attributes in route_manager.iterate_transitions(route_id) do
local id, spot, hint = parent.get_transition_marker_info(transition_name)
assert(id and spot and hint)
level.map_remove_all_object_spots(id)
if (not utils.map_spot_exists(id, spot)) then -- sanity check
utils.map_spot_add(id, spot, hint)
end
update_transition_mapspot(transition_name)
end
end
end
---------------------------------
-- Binocular Discovery --
---------------------------------
-- TODO: use anomalies.get_spawn_parameters(transition_name).rootpos for distance check
check_transition_in_sight = utils.throttle(CONST_BINOCULAR_CHECK_IN_SIGHT_INTERVAL_MS, false, function()
if (BINOCULAR_DISCOVERY_IN_PROGRESS or no_eligible_transitions()) then return end
local wpn = db.actor:active_item() -- wait, until this is updated
if not (wpn and wpn:section() == "wpn_binoc_inv") then return end
-- TODO: Use a TimedEvent with an ID to delay HUD display whilst being able to cancel prematurely
binocular_hud_on()
local look_pos = utils.get_target_pos()
for transition_id, route_id in pairs(discoverable_on_level) do
if (utils.get_proximity_by_id(transition_id) > opt.get("binoc_discovery_max_distance")) then
goto next_transition
elseif (utils.get_point_proximity_by_id(transition_id, look_pos) > opt.get("binoc_discovery_dist")) then
goto next_transition
end
utils.timed_call(CONST_BINOCULAR_REFRESHRATE_MS, check_binocular_state,
{ id = transition_id, tg = time_global(), route_id = route_id })
log("Forked timed binocular check for transition '%s' (route #%s)",
get_object_story_id(transition_id), route_id)
BINOCULAR_DISCOVERY_IN_PROGRESS = true
play_binocular_sound_cue()
do break end -- Don't check other transitions
:: next_transition ::
end
end)
-- Periodically checks for discovery conditions and aborts accordingly.
function check_binocular_state(data)
local wpn = db.actor:active_item()
local look_pos = utils.get_target_pos()
local hold_time = time_global() - data.tg
local proximity = utils.get_point_proximity_by_id(data.id, look_pos)
-- no need to repeat this over and over.. avoiding goto's aswell
local function abort_discovery()
BINOCULAR_DISCOVERY_IN_PROGRESS = false
play_binocular_sound_cue()
return true
end
if (no_eligible_transitions()) then
log("No eligible transitions left to check")
return abort_discovery()
elseif not (wpn and wpn:section() == "wpn_binoc_inv") then
log("Not holding binoculars. Aborting.")
return abort_discovery()
elseif (not axr_main.weapon_is_zoomed) then
log("Binoculars are not zoomed in")
return abort_discovery()
elseif (proximity > opt.get("binoc_discovery_dist")) then
log("Point proximity is now too large. Aborting.")
return abort_discovery()
elseif (hold_time < opt.get("binoc_discovery_holdtime")) then
log("Target has not been focused long enough (%sms)", hold_time)
return
end
toggle_known_route_state(data.route_id, true)
BINOCULAR_DISCOVERY_IN_PROGRESS = false
return true
end
function play_binocular_sound_cue()
if (BINOCULAR_DISCOVERY_IN_PROGRESS) then
sound_objs.binoc_in_progress:play(player, 0, sound_object.s2d)
return
elseif (sound_objs.binoc_in_progress:playing()) then
sound_objs.binoc_in_progress:stop()
end
sound_objs.binoc_aborted:play(player, 0, sound_object.s2d)
end
------------------------
-- Visor HUD --
-- Courtesy of xcvb --
------------------------
function binocular_hud_on()
if BINOCULAR_HUD or (not opt.get("binoc_discovery_hud_enabled")) then return end
BINOCULAR_HUD = binocular_hud()
get_hud():AddDialogToRender(BINOCULAR_HUD)
log("Enabled Binocular-HUD")
end
function binocular_hud_off()
if (not BINOCULAR_HUD) then return end
get_hud():RemoveDialogToRender(BINOCULAR_HUD)
BINOCULAR_HUD = nil
log("Disabled Binocular-HUD")
end
class "binocular_hud" (CUIScriptWnd)
function binocular_hud:__init() super()
self:InitControls()
end
function binocular_hud:__finalize() end
function binocular_hud:InitControls()
self:SetWndRect(Frect():set(0,0,1024,768))
self:SetAutoDelete(true)
self.xml = CScriptXmlInit()
local xml = self.xml
xml:ParseFile("actor_menu.xml")
self.hud_update_timer = 0
self.transitions = {}
self.elements = {}
self.texture = CONST_BINOCULAR_HUD_ICON_TEXTURE
local size = CONST_BINOCULAR_HUD_ICON_TEXTURE_SIZE
for i = 1, CONST_BINOCULAR_HUD_MAX_VISIBLE_TRANSITIONS do
self.elements[i] = xml:InitStatic("helmet_over", self)
self.elements[i]:InitTexture("ui_mmap_stask_last_02")
self.elements[i]:SetWndSize(vector2():set(size[1], size[2]))
self.elements[i]:Show(false)
end
end
function binocular_hud:Update()
CUIScriptWnd.Update(self)
self:GatherTransitions()
-- display eligible transitions in close proximity
for i = 1, CONST_BINOCULAR_HUD_MAX_VISIBLE_TRANSITIONS do
self.elements[i]:Show(false)
local transition = self.transitions[i]
if (not transition) then goto continue end
local id = transition.id
-- To make sure that it is updated immediately after discovery
if not (discoverable_on_level[id]) then goto continue end
local obj = level.object_by_id(id)
local name = obj and obj:name()
local pos = name and get_anomaly_rootpos(name)
if (not pos) then
local obj_pos = obj:position()
pos = utils.get_closest_vertex_pos(obj_pos)
or vector():set(obj_pos.x, obj_pos.y, obj_pos.z)
end
-- Note: The position variable should be temporary
pos.y = pos.y + CONST_BINOCULAR_HUD_ICON_HOVER_METRES
local wui_pos = pos and vector2():set(game.world2ui(pos))
if wui_pos then
self.elements[i]:InitTexture(self.texture)
self.elements[i]:SetWndPos(vector2():set(wui_pos.x, wui_pos.y))
self.elements[i]:Show(true)
end
:: continue ::
end
end
function binocular_hud:GatherTransitions()
local tg = time_global()
if (self.hud_update_timer > tg) then return end
self.hud_update_timer = tg + CONST_DISCOVERY_CHECK_INTERVAL_MS
iempty_table(self.transitions)
if (no_eligible_transitions()) then return end
local max_distance = opt.get("binoc_discovery_max_distance")
for transition_id, route_id in pairs(discoverable_on_level) do
local distance = utils.get_proximity_by_id(transition_id)
if (distance > max_distance) then goto continue end
self.transitions[#self.transitions +1] = { id = transition_id, }
:: continue ::
end
end
@@ -0,0 +1,206 @@
--[[
DYNAMIC ZONE - MCM Options
Original Author(s)
VodoXleb <vodoxlebushek>
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)
--]]
CONST_ACRONYM = "dynamic_zone"
CONST_DEFAULT_CATEGORY = "general"
options = {} -- This contains cached option values indexed by option name
colors = { -- ARGB
title = {255, 255, 255, 0},
desc = {255, 109, 187, 164},
notice = {255, 232, 61, 102},
}
defaults = {
["general"] = {
["chance_path_closed"] = 30, -- for any given path between two maps
["chance_dz_trigger"] = 75, -- for the zone to change during an emission
["minimum_map_connections"] = 1,
["trigger_on_newgame"] = true, -- If some routes should already be disabled at the beginning
["newgame_grace_period"] = 12, -- For the delay after New game before first blockage (Grace Period)
["emission_trigger"] = "2ndwave", -- in which phase we trigger
["addon_removal"] = false,
},
["news"] = {
["percent_path_reveal_unblock"] = 60,
["percent_path_reveal_block"] = 50,
["chance_special_character"] = 25,
["news_play_discovery_sound"] = true,
},
["discovery"] = {
["discovery_msg_showtime"] = 4000,
["mapspot_blips_enabled"] = true,
["binoc_discovery_hud_enabled"] = true,
["binoc_discovery_holdtime"] = 3500,
["binoc_discovery_dist"] = 40,
["binoc_discovery_max_distance"] = 350,
},
["debug"] = {
["emission_check_interval"] = 10000, -- in milliseconds
["validate_parameter_types"] = false,
["verbose"] = false,
},
}
-- Getter for other scripts, using cached values
-- Simplifies getting the values through a unique id
function get(key)
return options[key]
end
function on_game_start()
RegisterScriptCallback("on_option_change", apply_settings)
apply_settings()
end
function apply_settings()
for category, settings in pairs(defaults) do
for option,_ in pairs(settings) do
options[option] = get_config(category, option)
end
end
end
function on_mcm_load()
local op = {
id = CONST_ACRONYM,
text = "ui_mcm_"..CONST_ACRONYM,
gr = {}
}
local general = {
id = "general",
sh = true,
text = "ui_mcm_"..CONST_ACRONYM.."_general",
gr = {
{ id = "title", type ="slide", link = "ui_options_slider_sound_environment",
text = "ui_mcm_"..CONST_ACRONYM.."_main_title", size = {512,50}, spacing = 20 },
{ id = "trigger_on_newgame", type = "check", val = 1,
def = defaults["general"]["trigger_on_newgame"] },
{ id = "newgame_grace_period", type = "track", val = 2, min = 0, max = 96, step = 12,
def = defaults["general"]["newgame_grace_period"] },
{ id = "chance_dz_trigger", type = "track", val = 2, min = 0, max = 100, step = 5,
def = defaults["general"]["chance_dz_trigger"] },
{ id = "emission_trigger", type = "list", val = 0, def = defaults["general"]["emission_trigger"],
content = {
{ "impact", CONST_ACRONYM.. "_emission_trigger_impact" },
{ "1stwave", CONST_ACRONYM.. "_emission_trigger_1stwave" },
{ "2ndwave", CONST_ACRONYM.. "_emission_trigger_2ndwave" },
{ "rumble", CONST_ACRONYM.. "_emission_trigger_rumble" },
},
},
{ id = "chance_path_closed", type = "track", val = 2, min = 0, max = 100, step = 5,
def = defaults["general"]["chance_path_closed"] },
{ id = "minimum_map_connections", type = "track", val = 2, min = 0, max = 4, step = 1,
def = defaults["general"]["minimum_map_connections"] },
{ id = "divider", type = "line" },
{ id = "addon_removal", type = "check", val = 1, def = defaults["addon_removal"] },
}
}
local news = {
id = "news",
sh = true,
text = "ui_mcm_"..CONST_ACRONYM.."_news",
gr = {
{ id = "title", type ="slide", link = "ui_options_slider_news",
text = "ui_mcm_"..CONST_ACRONYM.."_news_title", size = {512,50}, spacing = 20 },
{ id = "header", type = "desc", text = "ui_mcm_"..CONST_ACRONYM.."_news_desc", clr = colors.desc},
{ id = "news_play_discovery_sound", type = "check", val = 1,
def = defaults["news"]["news_play_discovery_sound"] },
{ id = "percent_path_reveal_unblock", type = "track", val = 2, min = 0, max = 100, step = 5,
def = defaults["news"]["percent_path_reveal_unblock"] },
{ id = "percent_path_reveal_block", type = "track", val = 2, min = 0, max = 100, step = 5,
def = defaults["news"]["percent_path_reveal_block"] },
{ id = "chance_special_character", type = "track", val = 2, min = 0, max = 100, step = 5,
def = defaults["news"]["chance_special_character"] },
}
}
local discovery = {
id = "discovery",
sh = true,
text = "ui_mcm_"..CONST_ACRONYM.."_discovery",
gr = {
{ id = "title", type ="slide", link = "ui_options_slider_emission",
text = "ui_mcm_"..CONST_ACRONYM.."_discovery_title", size = {512,50}, spacing = 20 },
{ id = "discovery_msg_showtime", type = "track", val = 2, min = 1000, max = 10000, step = 500,
def = defaults["discovery"]["discovery_msg_showtime"] },
{ id = "mapspot_blips_enabled", type = "check", val = 1,
def = defaults["discovery"]["mapspot_blips_enabled"] },
{ id = "divider", type = "line" },
{ id = "binoc_discovery_hud_enabled", type = "check", val = 1,
def = defaults["discovery"]["binoc_discovery_hud_enabled"] },
{ id = "binoc_discovery_holdtime", type = "track", val = 2, min = 1000, max = 10000, step = 500,
def = defaults["discovery"]["binoc_discovery_holdtime"] },
{ id = "binoc_discovery_dist", type = "track", val = 2, min = 20, max = 80, step = 5,
def = defaults["discovery"]["binoc_discovery_dist"] },
{ id = "binoc_discovery_max_distance", type = "track", val = 2, min = 150, max = 500, step = 25,
def = defaults["discovery"]["binoc_discovery_max_distance"] },
}
}
local debug = {
id = "debug",
sh = true,
text = "ui_mcm_"..CONST_ACRONYM.."_debug",
gr = {
{ id = "title", type ="slide", link = "ui_options_slider_sound_environment",
text = "ui_mcm_"..CONST_ACRONYM.."_debug_title", size = {512,50}, spacing = 20 },
{ id = "header", type = "desc", text = "ui_mcm_"..CONST_ACRONYM.."_debug_desc", clr = colors.desc},
{ id = "verbose", type = "check", val = 1,
def = defaults["debug"]["verbose"] },
{ id = "validate_parameter_types", type = "check", val = 1,
def = defaults["debug"]["validate_parameter_types"] },
{ id = "emission_check_interval", type = "track", val = 2, min = 2000, max = 20000, step = 500,
def = defaults["debug"]["emission_check_interval"] },
}
}
table.insert(op.gr, general)
table.insert(op.gr, news)
table.insert(op.gr, discovery)
table.insert(op.gr, debug)
return op
end
function get_config(id, key)
if not (key and type(key) == 'string') then return end
id = (id and type(id) == 'string') and id or CONST_DEFAULT_CATEGORY
if ui_mcm and type(ui_mcm.get) == 'function' then
return ui_mcm.get(CONST_ACRONYM .. "/".. id .. "/" .. key)
end
return defaults[id][key]
end
function set_config(id, key, value)
if not (key and type(key) == 'string') then return end
id = (id and type(id) == 'string') and id or CONST_DEFAULT_CATEGORY
if (not ui_mcm and type(ui_mcm.set) == 'function') then return end
ui_mcm.set(CONST_ACRONYM .. "/".. id .. "/" .. key, value)
end
@@ -0,0 +1,339 @@
--[[
DYNAMIC ZONE - PDA News
Original Author(s)
Singustromo <singustromo at disroot.org>
VodoXleb <vodoxlebushek>
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)
--]]
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end
--------------------------
-- Dependencies --
--------------------------
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
local route_discovery = dynamic_zone_discovery
---------------------------------
-- Globals & Constants --
---------------------------------
CONST_LOGGING_PREFIX = "Dynamic News"
local log = debug.log_register("info", CONST_LOGGING_PREFIX)
local log_warn = debug.log_register("warning", CONST_LOGGING_PREFIX)
local log_err = debug.log_register("error", CONST_LOGGING_PREFIX)
CONST_RANDOMIZER_BEZIER_PARAMETERS = { 0, .0, .2, .9 } -- 41.9%
CONST_PROPAGATION_INTERVAL_MS = 20000
CONST_FALLBACK_DELAY_MIN_MS = 500
CONST_FALLBACK_DELAY_MAX_MS = CONST_PROPAGATION_INTERVAL_MS * 0.5
CONST_MESSAGE_MAX_STRINGS_TO_ITERATE = 20
CONST_STRING_ID_PREFIX = "st_dynzone_news"
CONST_STRING_ID_FALLBACK = CONST_STRING_ID_PREFIX .. "_fallback"
CONST_STRING_ID_SUFFIX_BLOCKED = "blocked"
CONST_STRING_ID_SUFFIX_OPENED = "opened"
CONST_NEWS_FALLBACK_ICON = "ui_iconsTotal_grouping"
CONST_MESSAGE_FALLBACK_NAME = "Bratan"
routes_to_reveal = {} -- routes whose changes will be propagated by other stalkers.
function clear()
log("Cleared route propagation queue")
routes_to_reveal = {}
end
special_characters = {
-- [story_id] = identifier (for strings)
["esc_m_trader"] = "sidorovich", -- Loner
["bar_dolg_general_petrenko_stalker"] = "petrenko", -- Duty
["mil_smart_terrain_7_7_freedom_leader_stalker"] = "lukash", -- Freedom
-- ["mar_smart_terrain_base_stalker_leader_marsh"] = "cold", -- Clear Sky
["yan_stalker_sakharov"] = "sakharov", -- Ecologists
["cit_killers_merc_trader_stalker"] = "dushman", -- Mercenaries
["agr_smart_terrain_1_6_near_2_military_colonel_kovalski"] = "kovalski", -- Military
["zat_b7_bandit_boss_sultan"] = "sultan", -- Bandits
["pri_monolith_monolith_trader_stalker"] = "krolik", -- Monolith
}
------------------------------
-- Just Shortcuts --
------------------------------
local ctime_to_tbl = utils_data.CTime_to_table
local tbl_to_ctime = utils_data.CTime_from_table
-- Using bezier curves for non-linear probability and to clamp result
-- Used so mean will be set percentage over time (of the time frame)
function get_random_time(lower, upper)
local randomizer = libmath_bezier and libmath_bezier.get_random_value
if utils.assert_failed(randomizer, "Bézier script not found. Using math.random()") then
return math.random(lower, upper)
end
return randomizer(lower, upper, CONST_RANDOMIZER_BEZIER_PARAMETERS)
end
-------------------------
-- Callbacks --
-------------------------
function on_game_start()
RegisterScriptCallback("dynzone_changed_block_state",
discover_changed_routes_via_news)
RegisterScriptCallback("actor_on_update", check_timed_unlocks)
end
function discover_changed_routes_via_news(previously_blocked_routes, new_blocked_routes)
clear()
queue_routes_for_propagation(previously_blocked_routes,
opt.get("percent_path_reveal_unblock"), CONST_STRING_ID_SUFFIX_OPENED)
queue_routes_for_propagation(new_blocked_routes,
opt.get("percent_path_reveal_block"), CONST_STRING_ID_SUFFIX_BLOCKED)
RegisterScriptCallback("actor_on_update", check_timed_unlocks)
end
-- Iterates through the route discovery queue and
-- checks if any of them are due to being propagated
check_timed_unlocks = utils.throttle(CONST_PROPAGATION_INTERVAL_MS, true, function()
if (utils.is_table_empty(routes_to_reveal)) then
log("No routes in the news queue. Unregistered Callback.")
UnregisterScriptCallback("actor_on_update", check_timed_unlocks)
return
end
local current_ctime = game.get_game_time()
log("Current time: %s", string.format("%d/%.2d/%.2d %.2d:%.2d:%.2d",
current_ctime:get(Y,M,D,h,m,s,ms)))
for route_id, data in pairs(routes_to_reveal) do
utils.assert(data, "Undefined Route data")
utils.assert(data.reveal_time, "Undefined Route reveal time")
local reveal_time = tbl_to_ctime(data.reveal_time)
local remaining_time_ms = (reveal_time:diffSec(current_ctime) / level.get_time_factor()) * 1000
reveal_time:sub(current_ctime)
if (remaining_time_ms >= CONST_PROPAGATION_INTERVAL_MS) then
local _, __, d, h, m, s, ___ = reveal_time:get(Y,M,D,h,m,s,ms)
log("Route #%s will be propagated in %sd %sh %sm %ss",
route_id, (d -1), h, m, s)
goto continue
end
remaining_time_ms = (remaining_time_ms < 0)
and math.random(CONST_FALLBACK_DELAY_MIN_MS, CONST_FALLBACK_DELAY_MAX_MS)
or remaining_time_ms
log("Route #%s will be propagated in %ss (realtime)", route_id,
string.format("%.1f", remaining_time_ms / 1000))
utils.assert(data.reveal_type, "Undefined route reveal type!")
utils.timed_call(remaining_time_ms, function()
propagate_route_state_change(route_id, data.reveal_type)
return true
end)
:: continue ::
end
end)
--------------------------
-- Main logic --
--------------------------
function propagate_route_state_change(route_id, reveal_type)
if not utils.valid_type{ caller = "propagate_route_state_change",
"int", route_id } then return end
local route = route_manager.get_route(route_id)
local levels, sender = route and route.connects
if math.random() > (opt.get("chance_special_character") / 100) then
sender = find_random_stalker_on_level_pair(levels)
else
sender = find_special_character_on_level_pair(levels)
end
local route_state_differs = route_manager.route_known_state_differs(route_id)
local play_discovery_sound = opt.get("news_play_discovery_sound") and route_state_differs
if not utils.assert_failed(sender, "Sender for route #%s is nil!", route_id) then
local anomaly_theme = route_manager.get_route_property(route_id, "anomaly_theme")
send_route_news_tip(reveal_type, 0, sender, 10, levels, anomaly_theme, play_discovery_sound)
end
-- TODO: Check, if still eligible to prevent log spamming at this point
-- We still want to propagate the information, nonetheless.
log("Revealing %s state of route #%s via news.", reveal_type, route_id)
route_discovery.toggle_known_route_state(route_id, play_discovery_sound)
routes_to_reveal[route_id] = nil
end
function queue_routes_for_propagation(selected_routes, percent_to_reveal, reveal_type)
local indexed_routes = {}
utils.index_keys(selected_routes, indexed_routes)
-- time before next emission when all reveal messages should be posted
local next_emission_in_hours = surge_manager and surge_manager.get_surge_manager
and (surge_manager.get_surge_manager()._delta / (60^2)) -- game time
if (not next_emission_in_hours) then
next_emission_in_hours = ui_options.get("alife/event/emission_frequency")
log_warn("Surge manager not available, using frequency set in options.")
end
local revealed = 1
local amount_to_reveal = math.floor(#indexed_routes * (percent_to_reveal/100))
for id in utils.random_numbered_sequence(1, #indexed_routes) do
if (revealed > amount_to_reveal) then goto continue end
revealed = revealed +1
local minutes = 6 * get_random_time(3, next_emission_in_hours * 10)
local hours = math.floor(minutes / 60)
minutes = minutes % 60
local days = math.floor(hours / 24)
hours = hours % 24
local add_time = game.CTime()
-- days +1 so we get proper result (engine quirk)
add_time:set(1, 1, (days +1), hours, minutes, math.random(0, 59), 0)
local selected_time = game.get_game_time()
selected_time:add(add_time)
log("Queued route #%s for propagation (%s)", id, reveal_type)
routes_to_reveal[indexed_routes[id]] = {
route = selected_routes[indexed_routes[id]],
reveal_time = ctime_to_tbl(selected_time), -- don't save userdata
reveal_type = reveal_type,
}
:: continue ::
end
end
function find_random_stalker_on_level_pair(levels)
if not utils.valid_type{ caller = "find_random_stalker_on_level_pair",
"tbl", levels } then return end
for id in utils.random_numbered_sequence(1, 2^16 -2) do
local se_obj = alife():object(id)
if not (se_obj) then goto continue end
if IsStalker(nil, se_obj:clsid())
and se_obj:alive()
and se_obj:community() ~= "zombied"
and se_obj:community() ~= "trader"
and se_obj:community() ~= "greh"
and se_obj:community() ~= "isg"
and se_obj:community() ~= "renegade"
and se_obj.group_id ~= 65535
and (get_object_story_id(se_obj.group_id) == nil)
and string.find(se_obj:name(),"sim_default_")
and utils.table_has(levels, utils.get_mapname(se_obj)) then
return id
end
:: continue ::
end
end
function find_special_character_on_level_pair(levels)
if not utils.valid_type{ caller = "find_special_character_on_level_pair",
"tbl", levels } then return end
local found_stalkers = {}
for k, identifier in pairs(special_characters) do
local id = get_story_object_id(k)
local se_obj = id and alife_object(id)
if not (se_obj) then goto continue end
local character_map = utils.get_mapname(se_obj)
local connected_maps = character_map and utils.get_directly_connected_maps(character_map)
-- Route should at least be indirectly connected to the senders map
if utils.table_contains(connected_maps, levels) then
found_stalkers[#found_stalkers + 1] = se_obj.id
end
:: continue ::
end
return found_stalkers[math.random(1, #found_stalkers)]
or find_random_stalker_on_level_pair(levels)
end
function send_route_news_tip(news_reveal_type, timeout, sender_id, showtime, news_levels, news_anom_theme, suppress_pda_sound)
timeout = timeout or 0
showtime = showtime or 5
local npc = alife():object(sender_id)
if (not npc) then return end
local actor = db.actor
local texture = (npc.character_icon) and npc:character_icon() or CONST_NEWS_FALLBACK_ICON
local special_character_nickname = special_characters[get_object_story_id(npc.id)]
local npc_story_id = special_character_nickname or npc:community()
local msg_to_translate = string.format("%s_%s_%s_", CONST_STRING_ID_PREFIX, npc_story_id, news_reveal_type)
local phrases = {}
for i = 1, CONST_MESSAGE_MAX_STRINGS_TO_ITERATE do
if (utils.has_translation(msg_to_translate .. i)) then
phrases[#phrases +1] = i
end
end
if (utils.is_table_empty(phrases)) then
log_warn("Undefined string group: '%s'. Using fallback.", msg_to_translate:sub(1, -2))
msg_to_translate = CONST_STRING_ID_FALLBACK .. "_" .. news_reveal_type
else
msg_to_translate = msg_to_translate .. phrases[math.random(1, #phrases)]
end
-- Make sure $FROM is the senders level, when route connects sender map
local sender_level = utils.get_mapname(npc)
local index = utils.index_of(news_levels, sender_level) or math.random(1, #news_levels)
local level_from, level_to = news_levels[index], news_levels[#news_levels -index +1]
utils.assert(level_from and level_to)
local character_name = (npc.character_name) and npc:character_name()
local news_caption = character_name or game.translate_string("st_tip")
-- TODO: Maybe need a debug mode flag for cases such as this
news_caption = (opt.get("verbose"))
and string.format("%s (%s)", news_caption, utils.get_mapname(npc))
or news_caption
local anomaly_theme_string = dynamic_zone_anomalies.get_theme_string(news_anom_theme) or ""
local news_text = game.translate_string(msg_to_translate)
news_text = news_text:gsub("%$SENDER_NAME", character_name or CONST_MESSAGE_FALLBACK_NAME)
news_text = news_text:gsub("%$FROM", game.translate_string(level_from))
news_text = news_text:gsub("%$TO", game.translate_string(level_to))
news_text = news_text:gsub("%$ANOM_THEME", game.translate_string(anomaly_theme_string))
actor:give_game_news(news_caption, news_text, texture, timeout *1000, showtime *1000, 0)
if (not suppress_pda_sound) then
xr_sound.set_sound_play(AC_ID, "pda_tips") -- play default sound
end
end
@@ -0,0 +1,482 @@
--[[
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 file provides the rough structure for saving transitions and
their pairs - called routes in this context.
Additional info:
tables, functions, userdata and threads are passed around by reference,
while numbers, booleans, and nil are passed by value
Despite this we reference entries by their index because both tables
are saved in m_data. Can't reference them.
--]]
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20241224) then return end
--------------------------
-- Dependencies --
--------------------------
-- verified in main script
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
CONST_LOGGING_PREFIX = "Routes"
local log = debug.log_register("info", CONST_LOGGING_PREFIX)
local log_error = debug.log_register("error", CONST_LOGGING_PREFIX)
---------------------
-- Structure --
---------------------
registered_routes = {}
registered_transitions = {
--[[
[size] = <int>,
[transition_name] = {
route = route_id, -- index in registeres_routes
master = true, -- exception for 1 -> n routes (e.g. truck cemetary)
spawned_anomalies = {}
},
...
--]]
}
function route_count()
return ((utils.is_table_empty(registered_routes))
and 0 or size_table(registered_routes))
end
function transition_count()
return (registered_transitions.size or 0)
end
function clear()
registered_routes = {}
registered_transitions = {}
end
-- first initialization of a route
--- @returns id of route (index)
function route_create()
local id = #registered_routes +1
registered_routes[id] = {
id = id, -- Needed in rare cases
members = { }, -- transition names
connects = { }, -- level names (game_levels.ltx)
blacklisted = false,
unlocked = true,
blocked = false,
block_discovered = false,
recently_discovered = false, -- set during emission (controls blip)
anomaly_theme = 0,
}
return id
end
function transition_register(transition_name, route_id)
if not utils.valid_type{ caller = "transition_register", "str", transition_name,
"tbl", registered_routes[route_id] } then return end
local id = get_story_object_id(transition_name)
if (utils.assert_failed(id, "%s is not a Story-ID. Not registered.")) then return end
local route_members = registered_routes[route_id].members
if utils.table_has(route_members, transition_name) then return end
registered_transitions[transition_name] = {
route = route_id,
master = false,
spawned_anomalies = {}, -- holds the object ids
}
table.insert(route_members, transition_name)
local index = (registered_transitions.size or 0) +1
registered_transitions.size = index
return index
end
-------------------------------------------------------------------------------
function transition_table_register(tbl, route_id)
local registered = {}
if not utils.valid_type{ caller = "transition_table_register",
"tbl", tbl, "int", route_id } then return registered end
for _, transition_name in pairs(tbl) do
if not transition_register(transition_name, route_id) then
log_error("Transition '%s' has already been registered!", transition_name)
else
registered[#registered +1] = transition_name
end
end
return registered
end
-- @returns mutable reference
function get_route(route_id)
return registered_routes[route_id]
end
-- @returns mutable reference
function get_transition(transition_name)
return registered_transitions[transition_name]
end
function route_exists(route_id)
return (nil ~= registered_routes[route_id])
end
function transition_exists(transition_name)
return (nil ~= registered_transitions[transition_name])
end
-------------------------------------------------------------------------------
-- Wacky implementation
-- TODO: Improve condition declaration and parsing
-- Conditions are table entries (route flags) which are or'ed
-- attributes are negated with an '!' or can be joined via an and '*'
-- e.g. `get_routes{"blocked*!discovered", "!blocked*discovered"}`
-- Should only be used with route flags (true, false)
-- @returns <table> (route_ids)
function get_routes(conditions)
if not utils.valid_type{ caller = "get_routes", "tbl", conditions } then return end
if utils.is_table_empty(conditions) then return end
local matches = {}
local func_body = ""
for _, condition in pairs(conditions) do
local parsed = condition
parsed = parsed:gsub('*!', ' and not ref.')
parsed = parsed:gsub('*', ' and ref.')
parsed = parsed:gsub('!', 'not ref.')
parsed = (string.find(parsed, "^not")) and parsed or "ref." .. parsed
func_body = func_body .. "(" .. parsed .. ") or "
end
func_body = func_body:sub(1, -5) -- remove last ' or '
local to_eval = "return function(ref) return (" .. func_body .. ") end"
local func, err = loadstring(to_eval)
if (err) then
log_error("Unable to evaluate '%s'\n! %s", to_eval, err)
return
end
-- loadstring encapsulates input into another function
local check_func = func()
for route_id, route in iterate_routes(true) do
local status, retval = pcall(check_func, route)
if (status and retval) then
matches[#matches +1] = route_id
end
end
return matches
end
-------------------------------------------------------------------------------
-- Filters locked and blacklisted routes by default
-- @returns name, modifiable route reference
function iterate_routes(include_inactive)
local index = 0
return function()
index = index + 1
if not (include_inactive) then
while (registered_routes[index] and route_inactive(index)) do
index = index + 1
end
end
if (index <= #registered_routes) then
return index, registered_routes[index]
end
end
end
-- @returns name, mutable-reference
function iterate_transitions(route_id)
local route = get_route(route_id)
local members = route and route.members
if (not members) then return end
local index = 0
return function()
index = index + 1
if (index > #members) then return end
return members[index], get_transition(members[index])
end
end
-------------------------------------------------------------------------------
function set_transition_property(name, property, value)
local transition = get_transition(name)
if not utils.valid_type{ caller = "set_transition_property",
"tbl", transition, "str", property} then return end
if (type(value) == 'nil') then return end
if (type(transition[property]) == "nil") then return end
transition[property] = value
return true
end
-- @returns immutable reference
function get_transition_property(name, property)
local transition = get_transition(name)
if (not transition) then return end
return transition[property]
end
-------------------------------------------------------------------------------
function set_route_property(route_id, property, value)
local route = get_route(route_id)
if not utils.valid_type{ caller = "set_route_property",
"tbl", route, "str", property} then return end
if (type(value) == 'nil') then return end
if (type(route[property]) == "nil") then return end
route[property] = value
return true
end
-- @returns immutable reference
function get_route_property(route_id, property)
local route = get_route(route_id)
if (not route) then return end
return route[property]
end
function get_route_id(transition_name)
local transition = get_transition(transition_name)
return (transition and transition.route)
end
-- used when we only have the transition name
-- @returns modifyable route reference
function get_route_by_transition(gizmo)
local transition_name = (type(gizmo) == "number")
and get_object_story_id(gizmo) or gizmo
if (utils.assert_failed(transition_name)) then return end
local transition = get_transition(transition_name)
return (transition and get_route(transition.route))
end
has_route = get_route_by_transition -- alias for readability's sake
-- @returns immutable reference
function get_route_members(route_id)
local route = get_route(route_id)
return (route and route.members)
end
-------------------------------------------------------------------------------
function route_inactive(route_id)
local route = get_route(route_id)
if (not route) then return true end
return (route.blacklisted or (not route.unlocked))
end
-- only really need this, if we do not replace accessible zone
function route_unlock(route_id)
local route = get_route(route_id)
if (not route) then return end
route.unlocked = true
return true
end
function route_unlocked(route_id)
local route = get_route(route_id)
if (not route) then return end
return (route.unlocked)
end
function route_locked(route_id)
return (not route_unlocked(route_id))
end
-------------------------------------------------------------------------------
function route_block(route_id)
local route = get_route(route_id)
if not (route and route.unlocked) then return end
route.blocked = true
return true
end
function route_unblock(route_id)
local route = get_route(route_id)
if not (route and route.unlocked and route.blocked) then return end
route.blocked = false
return true
end
function route_blocked(route_id)
local route = get_route(route_id)
if (not route) then return end
return (route.blocked)
end
-------------------------------------------------------------------------------
-- We also pass an additional functor to be executed here
function route_discover(route_id, functor, ...)
local vararg = {...}
local route = get_route(route_id)
if not (route and route.blocked) then return end
route.block_discovered = true
if (functor and type(functor) == "function") then
functor(unpack(vararg))
end
return true
end
function route_discovered(route_id)
local route = get_route(route_id)
return (route and route.block_discovered)
end
function route_known_state_differs(route_id)
local route = get_route(route_id)
return route and (route.blocked ~= route.block_discovered)
end
-------------------------------------------------------------------------------
function transition_tostring(transition_name, indentation, indent_str)
indentation = (indentation) or 0
indent_str = (indent_str) or " "
local transition = get_transition(transition_name)
local string = string.rep(indent_str, indentation)
.. "[" .. transition_name .. "] = {\n"
for key, value in pairs(transition) do
if (key == "route") then
goto next_attribute
end
string = string .. string.rep(indent_str, indentation +1)
.. key .. " = "
if (type(value) == "table") then
string = string .. "{" .. table.concat(value, ",") .. "}"
else
string = string .. tostring(value)
end
string = string .. "\n"
:: next_attribute ::
end
return string .. string.rep(indent_str, indentation) .. "}\n"
end
-- crude and specific string generation of attribute tables
function route_tostring(route_id, include_member_attributes)
local route = get_route(route_id)
if (not route) then
log_error("Printinfo No route with ID #%s !", route_id)
return
end
local indentation = " "
local string = string.format("[%03d]\n", route_id)
for key, value in pairs(route) do
if (key == 'id') then goto next_attribute end
string = string .. indentation .. key .. " = "
if (type(value) ~= "table") then
string = string .. tostring(value)
goto continue
end
if ((not include_member_attributes) or key ~= "members") then
string = string .. "{" .. table.concat(value, ", ") .. "}"
goto continue
end
-- lazy way to serialize member attributes
string = string .. "{\n"
for _, section in pairs(value) do
string = string .. transition_tostring(section, 2, indentation)
end
string = string .. indentation .. "}"
:: continue ::
string = string .. "\n"
:: next_attribute ::
end
return string:sub(1, -2) -- remove last new line
end
-- Prints all routes with the specified attributes
-- @returns amount of all blocked routes
function print_routes(conditions)
local output_string = string.format("Route Attributes%s: ",
(conditions) and " (" .. table.concat(conditions, ", ") .. ")" or "")
local routes = get_routes(conditions)
if (not routes or utils.is_table_empty(routes)) then return end
for _, route_id in pairs(routes) do
output_string = output_string .. "\n" .. route_tostring(route_id, true)
end
log("%s", output_string)
end
-- Prints all the information agnostic to what type the parameter is
function printinfo(gizmo)
local output_string = "Attributes of"
if route_exists(gizmo) then
output_string = string.format("%s route #%s:\n%s",
output_string, gizmo, route_tostring(gizmo, true))
elseif transition_exists(gizmo) then
output_string = string.format("%s transition '%s':\n%s",
output_string, gizmo, transition_tostring(gizmo))
else return end
log("%s", output_string)
end
@@ -0,0 +1,641 @@
--[[
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
@@ -0,0 +1,571 @@
-- https://github.com/ubilabs/kd-tree-javascript
-- k-d Tree Implementation for Lua for quick search in multidimensional tables
-- k-d trees are a useful data structure for several applications, such as searches involving a multidimensional search key (e.g. range searches and nearest neighbor searches). k-d trees are a special case of binary space partitioning trees.
-- Rewritten to pure Lua and adapted for usage in Anomaly by demonized
local math_floor = math.floor
local math_log = math.log
local math_max = math.max
local math_min = math.min
local table_insert = table.insert
local table_remove = table.remove
local table_sort = table.sort
local empty_table = empty_table
local pairs = pairs
local function table_slice(t, first, last)
local res = {}
for i = first or 1, last and last - 1 or #t do
res[#res + 1] = t[i]
end
return res
end
-- http://lua-users.org/wiki/BinaryInsert
local function binary_insert(t, value, fcomp)
-- Initialise compare function
local fcomp = fcomp or function(a, b) return a < b end
-- print_table(value)
-- Initialise numbers
local iStart, iEnd, iMid, iState = 1, #t, 1, 0
if iEnd == 0 then
t[1] = value
-- printf("adding in beginning table empty")
return 1
end
if fcomp(value, t[1]) then
-- printf("adding in beginning %s of %s", 1, iEnd)
table_insert(t, 1, value)
return 1
end
if not fcomp(value, t[iEnd]) then
-- printf("adding in end %s of %s", iEnd + 1, iEnd)
local pos = iEnd + 1
t[pos] = value
return pos
end
-- Get insert position
while iStart <= iEnd do
-- calculate middle
iMid = math_floor((iStart + iEnd) / 2)
-- compare
if fcomp(value, t[iMid]) then
iEnd, iState = iMid - 1, 0
else
iStart, iState = iMid + 1, 1
end
end
local pos = iMid + iState
-- printf("adding in middle %s of %s", pos, iEnd)
table_insert(t, pos, value)
return pos
end
function Node(obj, dimension, parent)
local node = {}
node.obj = obj
node.left = nil
node.right = nil
node.parent = parent
node.dimension = dimension
return node
end
function kdTree(points, metric, dimensions)
local kd_tree = {}
kd_tree.points = points or {}
kd_tree.metric = metric
kd_tree.dimensions = dimensions
local function buildTree(new_points, depth, parent)
local dim = (depth % #dimensions) + 1
local median
local node
if not new_points then
return
end
if #new_points == 0 then
-- printf("buildTree #new_points == 0")
return
end
if #new_points == 1 then
-- printf("buildTree #new_points == 1")
return Node(new_points[1], dim, parent)
end
table_sort(new_points, function(a, b)
return a[dimensions[dim]] < b[dimensions[dim]]
end)
median = math_floor(#new_points / 2) + 1
node = Node(new_points[median], dim, parent)
node.left = buildTree(table_slice(new_points, 1, median), depth + 1, node)
node.right = buildTree(table_slice(new_points, median + 1), depth + 1, node)
return node
end
kd_tree.root = buildTree(points, 0, nil)
kd_tree.insertAndRebuild = function(self, point)
self.points[#self.points + 1] = point
self.root = buildTree(self.points, 0, nil)
return self
end
kd_tree.insert = function(self, point)
local function innerSearch(node, parent)
if node == nil then
return parent
end
local dimension = self.dimensions[node.dimension]
if point[dimension] < node.obj[dimension] then
return innerSearch(node.left, node)
else
return innerSearch(node.right, node)
end
end
local insertPosition = innerSearch(self.root, nil)
local newNode
local dimension
if insertPosition == nil then
self.points[#self.points + 1] = point
self.root = buildTree(self.points, 0, nil)
return self
end
newNode = Node(point, (insertPosition.dimension + 1) % #self.dimensions, insertPosition)
dimension = self.dimensions[insertPosition.dimension]
if point[dimension] < insertPosition.obj[dimension] then
insertPosition.left = newNode
else
insertPosition.right = newNode
end
self.points[#self.points + 1] = point
return self
end
kd_tree.remove = function(self, point)
local node
local function nodeSearch(node)
if node == nil then
return
end
if node.obj == point then
return node
end
local dimension = self.dimensions[node.dimension]
if point[dimension] < node.obj[dimension] then
return nodeSearch(node.left, node)
else
return nodeSearch(node.right, node)
end
end
local function removeNode(node)
local nextNode
local nextObj
local pDimension
local function findMin(node, dim)
local dimension
local own
local left
local right
local min
if node == nil then
return
end
dimension = self.dimensions[dim]
if node.dimension == dim then
if node.left ~= nil then
return findMin(node.left, dim)
end
return node
end
own = node.obj[dimension]
left = findMin(node.left, dim)
right = findMin(node.right, dim)
min = node
if left ~= nil and left.obj[dimension] < own then
min = left
end
if right ~= nil and right.obj[dimension] < min.obj[dimension] then
min = right
end
return min
end
if node.left == nil and node.right == nil then
if node.parent == nil then
self.root = nil
return
end
pDimension = self.dimensions[node.parent.dimension]
if node.obj[pDimension] < node.parent.obj[pDimension] then
node.parent.left = nil
else
node.parent.right = nil
end
return
end
-- If the right subtree is not empty, swap with the minimum element on the
-- node's dimension. If it is empty, we swap the left and right subtrees and
-- do the same.
if node.right ~= nil then
nextNode = findMin(node.right, node.dimension)
nextObj = nextNode.obj
removeNode(nextNode)
node.obj = nextObj
else
nextNode = findMin(node.left, node.dimension)
nextObj = nextNode.obj
removeNode(nextNode)
node.right = node.left
node.left = nil
node.obj = nextObj
end
end
node = nodeSearch(self.root)
if node == nil then
return
end
removeNode(node)
return self
end
kd_tree.clearRoot = function(self)
empty_table(self.root)
return self
end
-- Update positions of objects
-- Points input must be same structure as existing in k-d Tree
kd_tree.updatePositions = function(self, points)
self.points = points
self:clearRoot()
self.root = buildTree(points, 0, nil)
return self
end
-- get all points sorted by nearest
kd_tree.nearestAll = function(self, point)
local point = {
x = point.x or point[1],
y = point.y or point[2],
z = point.z or point[3]
}
local function comp_function(a, b)
return a[2] < b[2]
end
local res = {}
for i = 1, #self.points do
local v = self.points[i]
res[i] = {
[1] = {
x = v.x,
y = v.y,
z = v.z,
data = v.data
},
[2] = math.huge
}
res[i][2] = self.metric(res[i][1], point)
end
table_sort(res, comp_function)
return res
end
-- Query the nearest *count* neighbours to a point, with an optional
-- maximal search distance.
-- Result is an array with *count* elements.
-- Each element is an array with two components: the searched point and
-- the distance to it.
kd_tree.nearest = function(self, point, maxNodes, maxDistance)
local i
local result
local bestNodes
bestNodes = {}
local passedNodes = {}
local maxNodes = maxNodes or 1
local function comp_function(a, b)
return a[2] < b[2]
end
local function saveNode(node, distance)
binary_insert(bestNodes, {node, distance}, comp_function)
if #bestNodes > maxNodes then
table_remove(bestNodes)
end
end
local function nearestSearch(node)
if passedNodes[node] then return end
local bestChild
local dimension = self.dimensions[node.dimension]
local ownDistance = self.metric(point, node.obj)
local linearPoint = {}
local linearDistance
local otherChild
local i
for i = 1, #self.dimensions do
linearPoint[self.dimensions[i]] = i == node.dimension and point[self.dimensions[i]] or node.obj[self.dimensions[i]]
end
linearDistance = self.metric(linearPoint, node.obj)
if node.right == nil and node.left == nil then
if #bestNodes < maxNodes or ownDistance < bestNodes[#bestNodes][2] then
saveNode(node, ownDistance)
end
passedNodes[node] = true
return
end
if node.right == nil then
bestChild = node.left
elseif node.left == nil then
bestChild = node.right
else
bestChild = point[dimension] < node.obj[dimension] and node.left or node.right
end
nearestSearch(bestChild)
if #bestNodes < maxNodes or ownDistance < bestNodes[#bestNodes][2] then
saveNode(node, ownDistance)
passedNodes[node] = true
end
if #bestNodes < maxNodes or math.abs(linearDistance) < bestNodes[1][2] then
otherChild = bestChild == node.left and node.right or node.left
if (otherChild ~= nil) then
nearestSearch(otherChild)
end
end
end
if maxDistance then
for i = 1, maxNodes do
bestNodes[i] = {nil, maxDistance}
end
end
if self.root then
nearestSearch(self.root)
end
result = {}
for i = 1, math_min(maxNodes, #bestNodes) do
if bestNodes[i][1] then
result[#result + 1] = {
bestNodes[i][1].obj,
bestNodes[i][2]
}
end
end
return result
end
-- Get an approximation of how unbalanced the tree is.
-- The higher this number, the worse query performance will be.
-- It indicates how many times worse it is than the optimal tree.
-- Minimum is 1. Unreliable for small trees.
kd_tree.balanceFactor = function(self)
local function height(node)
if node == nil then
return 0
end
return math_max(height(node.left), height(node.right)) + 1
end
local function count(node)
if node == nil then
return 0
end
return count(node.left) + count(node.right) + 1
end
return height(self.root) / (math_log(count(self.root)) / math_log(2))
end
return kd_tree
end
local function distance_to(a, b)
-- printf("distance_to fired")
local dist_x = a.x - b.x
local dist_y = a.y - b.y
local dist_z = a.z - b.z
return dist_x * dist_x + dist_y * dist_y + dist_z * dist_z
end
-- Actual usage starts here
--[[
When you build position tree, you can find nearest objects in relation to other objects
Example, find nearest position to actor:
local pos_tree = kd_tree.buildTreeObjectIds({45, 65, 23, 5353, 232})
print_table(pos_tree:nearest(db.actor:position()))
will print position, distance and id of nearest object from given ids
--]]
-- Build k-d Tree by several inputs
-- Input - Array of vectors (vector():set(x, y, z) or table with x, y, z keys or 1, 2, 3 keys)
-- Data is an optional table where you can bind your data to your object, must have same amount of fields as vectors (#vectors == #data)
function buildTreeVectors(vectors, data)
local v = {}
local data = data or {}
local vectors = vectors or {}
for k, t in pairs(vectors) do
table_insert(v, {
x = t.x or t[1],
y = t.y or t[2],
z = t.z or t[3],
data = data[k]
})
end
-- printf("vectors num %s", #v)
return kdTree(v, distance_to, {"x", "y", "z"})
end
-- Input - Array of game objects
-- Vectors are binded to object ids automatically
function buildTreeObjects(objects)
local vectors = {}
local data = {}
for k, v in pairs(objects) do
table_insert(vectors, v:position())
table_insert(data, v:id())
end
return buildTreeVectors(vectors, data)
end
-- Input - Array of server objects
-- Vectors are binded to object ids automatically
function buildTreeSeObjects(objects)
local vectors = {}
local data = {}
for k, v in pairs(objects) do
table_insert(vectors, v.position)
table_insert(data, v.id)
end
return buildTreeVectors(vectors, data)
end
-- Input - Array of game object ids
-- Vectors are binded to object ids automatically
function buildTreeObjectIds(ids)
local vectors = {}
local data = {}
local level_object_by_id = level.object_by_id
for k, v in pairs(ids) do
local obj = level_object_by_id(v)
if obj and obj ~= 0 and obj:id() ~= 0 then
table_insert(vectors, obj:position())
table_insert(data, v)
end
end
return buildTreeVectors(vectors, data)
end
-- Input - Array of server object ids
-- Vectors are binded to object ids automatically
function buildTreeSeObjectIds(ids)
local vectors = {}
local data = {}
local sim = alife()
local sim_object = sim.object
for k, v in pairs(ids) do
local obj = sim_object(sim, v)
if obj and obj ~= 0 and obj.id ~= 0 then
table_insert(vectors, obj.position)
table_insert(data, v)
end
end
return buildTreeVectors(vectors, data)
end
-- If you build a tree using functions above
-- You can use this function to update positions and rebuild the tree
function updateObjPositions(kd_tree)
local points = kd_tree.points
local new_points = {}
local sim = alife()
local sim_object = sim.object
for i = 1, #points do
local obj = sim_object(sim, points[i].data)
if obj then
local pos = obj.position
table_insert(new_points, {
x = pos.x,
y = pos.y,
z = pos.z,
data = points[i].data
})
end
end
kd_tree:updatePositions(new_points)
return kd_tree
end
@@ -0,0 +1,53 @@
-- A tiny library for picking random values between two numbers using bézier curves
-- Written in Lua by Singustromo
-- Based on and inspired by randomizing_functions from Demonized
-- Usage
--[[
local randomizer = libmath_bezier
local a = randomizer.get_random_value(min, max, {p0, p1, p2, p3})
--]]
-- Pick a random float between min_cond and max_cond
-- random value will be picked according to the function graph
function get_random_value(min_cond, max_cond, params)
local min_cond = min_cond or 0
local max_cond = max_cond or 1
if not params or type(params) ~= 'table' or #params < 3 then
params = {0,0.5,1} -- linear interpolation
end
local rand = 1
if #params == 4 then
rand = cubic_bezier(math.random(), params)
elseif #params == 3 then
rand = quadratic_bezier(math.random(), params)
end
if (not rand) then return end
if min_cond > max_cond then
max_cond, min_cond = min_cond, max_cond
end
local a = max_cond - min_cond
local b = a * rand
local c = min_cond + b
return c
end
function quadratic_bezier(x, p)
-- linear interpolation: saving compute time
if p[1] == 0 and p[2] == 0.5 and p[3] == 1 then return x end
return p[1]*(1-x)^2 + 2*p[2]*x*(1-x) + p[3]*x^2
end
function cubic_bezier(x, p)
-- linear interpolation: saving compute time
if p[1] == 0 and p[2] == 1 and p[3] == 0 and p[4] == 1 then
return x
end
return p[1]*(1-x)^3 + 3*p[2]*(1-x)^2*x + 3*p[3]*(1-x)*x^2 + p[4]*x^3
end
@@ -0,0 +1,111 @@
--[[
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)
Lazy monkey patches that overwrite those functions completely
--]]
-- main script functors and variables are accessed through this variable
parent = _G["dynamic_zone"]
if not (parent and parent.VERSION and parent.VERSION >= 20240120) then return end
--------------------------
-- Dependencies
--------------------------
local opt = dynamic_zone_mcm
local utils = dynamic_zone_utils
local debug = dynamic_zone_debug
local route_manager = dynamic_zone_routes
local discovery = dynamic_zone_discovery
CONST_LOGGING_PREFIX = "TXR-Routes"
local log = debug.log_register("info", CONST_LOGGING_PREFIX)
local log_error = debug.log_register("error", CONST_LOGGING_PREFIX)
-------------------------------
-- Patched Functions
-------------------------------
ReloadRouteHints = txr_routes.reload_route_hints
OpenRoute = txr_routes.open_route
-----------------------------
-- Monkey patches
-----------------------------
-- Let's make sure we update the transition mapspots and use our hint
-- This is executed undermost on_game_load and is used
-- to ensure that the spot hints adhere to set localization
function txr_routes.reload_route_hints()
if (utils.debug_level_loaded()) then return end
log("Running patched txr_routes.reload_route_hints()")
-- This function is actually called before our parent.main_routine()
-- We just do this for a new game, so that we can leverage our logic here
if (route_manager.route_count() < 1) then
log("reload_route_hints | Populating route list")
parent.register_game_routes()
parent.update_game_route_properties()
end
log("reload_route_hints | Updating transition mapspots")
local include_blacklisted = true
for route_id, route in route_manager.iterate_routes(include_blacklisted) do
for name, _ in route_manager.iterate_transitions(route_id) do
discovery.update_transition_mapspot(name, nil,
not route.block_discovered)
end
end
end
-- Prevents txr_routes from overwriting our map icons when a route is unlocked
-- (e.g. Debug: Unlock all)
-- Overwrote this in it's entirety because the changes needed, require it.
function txr_routes.open_route(map_1, map_2, no_msg)
local a = txr_routes.get_route(map_1, map_2) -- all space_restrictors between those two
if (utils.assert_failed(a, "Got no route between %s and %s", map_1, map_2)) then return end
local msg
for _, transition in pairs(a) do
local id, spot, hint = txr_routes.get_route_info(transition)
if utils.assert_failed(id and spot and hint) then
log_error("Unknown restrictor: %s (Defined in txr_routes.routes)", transition)
return -- prevent possible crash
end
local route = route_manager.get_route_by_transition(transition)
if utils.assert_failed(route, "Can not get a route ID with '%s' as a member!", transition) then
goto continue
elseif (route.block_discovered) then
goto continue
end
if utils.map_spot_exists(id, spot) then
log("Map spot for transition '%s' already exists!", transition)
goto continue
end
hint = hint and game.translate_string(hint)
level.map_add_object_spot_ser(id, spot, hint)
mlr_utils.save_var("routes_".. transition, true)
txr_routes.register_map(map_1, map_2)
msg = true
::continue::
route.unlocked = true
discovery.update_transition_mapspot(transition)
end
if msg and (not no_msg) then
txr_routes.msg_route(map_1, map_2)
end
end