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

726 lines
28 KiB
Plaintext

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