--[[ DYNAMIC ZONE Original Author(s) Singustromo Edited by License Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) (https://creativecommons.org/licenses/by-nc-sa/4.0) 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] = --------------------------- -- 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