├── .gitignore ├── README.md ├── thumbnail.png ├── @src ├── buttons.afdesign ├── fonts.afdesign └── icons.afdesign ├── .gitattributes ├── gamedata ├── configs │ ├── ai_tweaks │ │ ├── mod_xr_gather_items_z_idiots.ltx │ │ ├── mod_xr_corpse_detection_z_idiots.ltx │ │ ├── mod_xr_help_wounded_z_idiots.ltx │ │ ├── mod_xr_weapon_jam_z_idiots.ltx │ │ ├── mod_xr_danger_z_idiots.ltx │ │ ├── mod_xrs_facer_z_idiots.ltx │ │ └── mod_xr_combat_ignore_z_idiots.ltx │ ├── text │ │ ├── rus │ │ │ ├── idiots.xml │ │ │ └── idiots_mcm.xml │ │ └── eng │ │ │ └── idiots.xml │ ├── unlocalizers │ │ └── unlocalizer_idiots.ltx │ ├── ui │ │ ├── textures_descr │ │ │ └── idiots_ui.xml │ │ └── idiots_ui.xml │ └── scripts │ │ └── mod_beh_companion_z_idiots.ltx ├── textures │ └── ui │ │ ├── idiots_fade.dds │ │ ├── idiots_colors.dds │ │ └── idiots_icons.dds └── scripts │ ├── illish │ ├── patches │ │ ├── itms_manager.lua │ │ ├── ea_callbacks.lua │ │ ├── sr_light.lua │ │ ├── xr_help_wounded.lua │ │ ├── xr_weapon_jam.lua │ │ ├── rx_ff.lua │ │ ├── xr_corpse_detection.lua │ │ ├── state_mgr.lua │ │ ├── utils_obj.lua │ │ ├── xr_gather_items.lua │ │ ├── xr_conditions.lua │ │ ├── surge_manager.lua │ │ ├── xr_danger.lua │ │ ├── axr_beh.lua │ │ ├── xr_combat.lua │ │ ├── axr_companions.lua │ │ └── xr_combat_ignore.lua │ └── lib │ │ ├── ray.lua │ │ ├── weapon.lua │ │ ├── util.lua │ │ ├── table.lua │ │ ├── mcm.lua │ │ ├── vector.lua │ │ ├── surge.lua │ │ ├── dxml.lua │ │ ├── combat.lua │ │ └── pos.lua │ ├── z_idiots_patches.script │ ├── a_idiots_config.script │ ├── a_idiots_mdata.script │ ├── idiots_surge.script │ ├── idiots_combat_snipe.script │ ├── idiots_mcm.script │ ├── idiots_combat_guard.script │ ├── idiots_keybinds.script │ └── idiots_combat_support.script └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | @releases/ 2 | @docs/ 3 | *.code-workspace 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/README.md -------------------------------------------------------------------------------- /thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/thumbnail.png -------------------------------------------------------------------------------- /@src/buttons.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/@src/buttons.afdesign -------------------------------------------------------------------------------- /@src/fonts.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/@src/fonts.afdesign -------------------------------------------------------------------------------- /@src/icons.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/@src/icons.afdesign -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # syntax highlighting in GitHub 2 | *.script linguist-language=lua 3 | *.ltx linguist-language=ini 4 | -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xr_gather_items_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | ![settings] 2 | ; Distance squared 3 | max_detect_dist = 1024 4 | -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xr_corpse_detection_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | ![settings] 2 | ; Distance squared 3 | always_detect_dist = 1024 4 | -------------------------------------------------------------------------------- /gamedata/configs/text/rus/idiots.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/gamedata/configs/text/rus/idiots.xml -------------------------------------------------------------------------------- /gamedata/textures/ui/idiots_fade.dds: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/gamedata/textures/ui/idiots_fade.dds -------------------------------------------------------------------------------- /gamedata/textures/ui/idiots_colors.dds: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/gamedata/textures/ui/idiots_colors.dds -------------------------------------------------------------------------------- /gamedata/textures/ui/idiots_icons.dds: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/gamedata/textures/ui/idiots_icons.dds -------------------------------------------------------------------------------- /gamedata/configs/text/rus/idiots_mcm.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellyillish/useful-idiots/HEAD/gamedata/configs/text/rus/idiots_mcm.xml -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xr_help_wounded_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | ![settings] 2 | ; Distance squared 3 | always_help_distance = {=npc_companion} 216, {=is_redone_combat} 35, 50 4 | help_in_combat = true 5 | -------------------------------------------------------------------------------- /gamedata/configs/unlocalizers/unlocalizer_idiots.ltx: -------------------------------------------------------------------------------- 1 | [axr_beh] 2 | init_custom_data 3 | 4 | [xr_danger] 5 | bd_types 6 | is_danger 7 | danger_in_radius 8 | npc_on_hear_callback 9 | npc_on_hit_callback 10 | 11 | [xr_combat_ignore] 12 | ignored_zone 13 | 14 | [utils_obj] 15 | validate 16 | 17 | [xr_weapon_jam] 18 | SETTINGS 19 | GUN_TRACKING 20 | 21 | [xr_help_wounded] 22 | AlwaysHelpDistance 23 | 24 | [tasks_guide] 25 | curr_guid 26 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/itms_manager.lua: -------------------------------------------------------------------------------- 1 | -- Fixes reference to nonexistent "item_id" 2 | -- (I'm not really sure what fixing it does) 3 | 4 | local PATCH_actor_on_item_take = itms_manager.actor_on_item_take 5 | 6 | function itms_manager.actor_on_item_take(obj) 7 | -- redo the check with the correct "obj:id()" instead 8 | if IsWeapon(obj) and se_load_var(obj:id(), nil, "strapped_item") then 9 | se_save_var(obj:id(), nil, "strapped_item", nil) 10 | end 11 | 12 | return PATCH_actor_on_item_take(obj) 13 | end 14 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/ea_callbacks.lua: -------------------------------------------------------------------------------- 1 | -- disable the individual functions instead of blanking the files to prevent crashes 2 | 3 | if ea_callbacks and not ea_callbacks.EA_RegisterScriptCallback then 4 | function ea_callbacks.EA_RegisterScriptCallback() end 5 | end 6 | 7 | if ea_callbacks and not ea_callbacks.EA_UnregisterScriptCallback then 8 | function ea_callbacks.EA_UnregisterScriptCallback() end 9 | end 10 | 11 | if ea_callbacks and not ea_callbacks.EA_SendScriptCallback then 12 | function ea_callbacks.EA_SendScriptCallback() end 13 | end 14 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/ray.lua: -------------------------------------------------------------------------------- 1 | local RAY = {} 2 | 3 | 4 | function RAY.cast(pos, dir, dist, flags) 5 | local ray = ray_pick() 6 | 7 | ray:set_flags(flags or 15) 8 | ray:set_position(pos) 9 | ray:set_direction(dir) 10 | ray:set_range(dist) 11 | 12 | return ray 13 | end 14 | 15 | 16 | function RAY.distance(pos, dir, dist, flags, limit) 17 | local ray = RAY.cast(pos, dir, dist, flags) 18 | ray:query() 19 | 20 | local castDist = ray:get_distance() 21 | 22 | if castDist == 0 and limit ~= false then 23 | castDist = dist 24 | end 25 | 26 | return castDist 27 | end 28 | 29 | 30 | return RAY 31 | -------------------------------------------------------------------------------- /gamedata/scripts/z_idiots_patches.script: -------------------------------------------------------------------------------- 1 | AddScriptCallback("idiots_on_start") 2 | 3 | 4 | require "illish.patches.axr_beh" 5 | require "illish.patches.axr_companions" 6 | require "illish.patches.ea_callbacks" 7 | require "illish.patches.itms_manager" 8 | require "illish.patches.rx_ff" 9 | require "illish.patches.sr_light" 10 | require "illish.patches.state_mgr" 11 | require "illish.patches.surge_manager" 12 | require "illish.patches.utils_obj" 13 | require "illish.patches.xr_combat_ignore" 14 | require "illish.patches.xr_combat" 15 | require "illish.patches.xr_conditions" 16 | require "illish.patches.xr_corpse_detection" 17 | require "illish.patches.xr_danger" 18 | require "illish.patches.xr_gather_items" 19 | require "illish.patches.xr_help_wounded" 20 | require "illish.patches.xr_weapon_jam" 21 | 22 | 23 | function on_game_start() 24 | SendScriptCallback("idiots_on_start") 25 | end 26 | -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xr_weapon_jam_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | ; chance = TOTAL_SHOTS_FIRED * BASE_CH / CLIP_SIZE ^ CLIP_SIZE_FACTOR 2 | ; resets after each jam 3 | ; caps at MAX_CH 4 | 5 | ![settings] 6 | enabled = true 7 | 8 | ; clip size influence on jam chance (0 = not at all) 9 | clip_size_factor = 0.8 10 | 11 | ; how much each fired bullet adds to jam chance 12 | base_ch_novice = 2.00 13 | base_ch_trainee = 1.25 14 | base_ch_experienced = 1.00 15 | base_ch_professional = 0.75 16 | base_ch_veteran = 0.50 17 | base_ch_expert = 0.25 18 | base_ch_master = 0.20 19 | base_ch_legend = 0.15 20 | 21 | ; max that jam chance can ever be 22 | max_ch_novice = 12.0 23 | max_ch_trainee = 8.0 24 | max_ch_experienced = 6.0 25 | max_ch_professional = 4.0 26 | max_ch_veteran = 3.0 27 | max_ch_expert = 2.5 28 | max_ch_master = 2.0 29 | max_ch_legend = 1.5 30 | -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xr_danger_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | ![danger_inertion] 2 | grenade = {=npc_companion} 8000, {=is_redone_combat} 120000, {=is_gamma} 6000, 40000 3 | entity_corpse = {=npc_companion} 4000, {=is_redone_combat} 360000, {=is_gamma} 60000, 120000 4 | entity_attacked = {=npc_companion} 4000, {=is_redone_combat} 150000, {=is_gamma} 30000, 30000 5 | attacked = {=npc_companion} 4000, {=is_redone_combat} 360000, {=is_gamma} 120000, 120000 6 | bullet_ricochet = {=npc_companion} 4000, {=is_redone_combat} 270000, {=is_gamma} 250000, 90000 7 | enemy_sound = {=npc_companion} 4000, {=is_redone_combat} 165000, {=is_gamma} 45000, 55000 8 | attack_sound = {=npc_companion} 4000, {=is_redone_combat} 270000, {=is_gamma} 45000, 90000 9 | entity_death = {=npc_companion} 4000, {=is_redone_combat} 150000, {=is_gamma} 30000, 50000 10 | 11 | 12 | ![danger_inertion_actor] 13 | bullet_ricochet = {!actor_enemy} 4000, {=is_redone_combat} 37000, {=is_gamma} 5000, 90000 14 | attack_sound = {!actor_enemy} 4000, {=is_redone_combat} 37000, {=is_gamma} 45000, 90000 15 | -------------------------------------------------------------------------------- /gamedata/scripts/a_idiots_config.script: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";.\\gamedata\\scripts\\?.lua;..\\gamedata\\scripts\\?.lua;" 2 | 3 | local UTIL = require "illish.lib.util" 4 | local VEC = require "illish.lib.vector" 5 | local POS = require "illish.lib.pos" 6 | 7 | 8 | -- GLOBALS -- 9 | _G.time_plus = UTIL.timePlus 10 | _G.time_plus_rand = UTIL.timePlusRandom 11 | _G.time_expired = UTIL.timeExpired 12 | _G.time_left = UTIL.timeLeft 13 | _G.vec = VEC.set 14 | _G.vec_dir = VEC.direction 15 | _G.vec_dist = VEC.distance 16 | _G.vec_offset = VEC.offset 17 | _G.vec_dot = VEC.dotProduct 18 | _G.vec_avg = VEC.average 19 | _G.vec_rot = VEC.rotate 20 | _G.vec_rot_rand = VEC.rotateRandom 21 | _G.vec_rot_range = VEC.rotateRange 22 | _G.lvid = POS.lvid 23 | _G.lvpos = POS.position 24 | _G.lvsnap = POS.snap 25 | 26 | 27 | -- STORAGE -- 28 | DATA_VER = "1.0" 29 | DATA_KEY = "USEFUL_IDIOTS" 30 | 31 | DATA_STALE_KEYS = { 32 | "IDIOTS_STATES", 33 | "ZCDS_SHARED_ITEMS", 34 | "ZCDS_STATES", 35 | "ZCDS_SHARING_NPCS", 36 | "CDHTS_STATES", 37 | } 38 | -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xrs_facer_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | @[trainee] 2 | power = 20 3 | min_delay = 6000 4 | k_mutant = 2.5 5 | k_friend = 0 6 | k_stalker = 1.05 7 | k_actor = 0.35 8 | enable_vs_actor = true 9 | enable_vs_monster = true 10 | enable_vs_stalker = true 11 | 12 | 13 | @[professional] 14 | power = 35 15 | min_delay = 2500 16 | k_mutant = 2.9 17 | k_friend = 0 18 | k_stalker = 1.15 19 | k_actor = 0.5 20 | enable_vs_actor = true 21 | enable_vs_monster = true 22 | enable_vs_stalker = true 23 | 24 | 25 | @[expert] 26 | power = 45 27 | min_delay = 2000 28 | k_mutant = 3.3 29 | k_friend = 0 30 | k_stalker = 1.25 31 | k_actor = 0.5 32 | enable_vs_actor = true 33 | enable_vs_monster = true 34 | enable_vs_stalker = true 35 | 36 | 37 | @[legend] 38 | power = 60 39 | min_delay = 2000 40 | k_mutant = 3.6 41 | k_friend = 0 42 | k_stalker = 1.4 43 | k_actor = 0.6 44 | enable_vs_actor = true 45 | enable_vs_monster = true 46 | enable_vs_stalker = true 47 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/sr_light.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | -- Adds controllable headlamps for companions 5 | local PATCH_check_light = sr_light.check_light 6 | 7 | function sr_light.check_light(npc) 8 | if not NPC.isCompanion(npc) then 9 | PATCH_check_light(npc) 10 | return 11 | end 12 | 13 | local state = NPC.getActiveState(npc, "light") 14 | local torch = npc:object("device_torch") 15 | 16 | if not (torch and state) then 17 | PATCH_check_light(npc) 18 | return 19 | end 20 | 21 | -- Override if not set to "default" 22 | if state == "off" or state == "on" then 23 | torch:enable_attachable_item(state == "on") 24 | return 25 | end 26 | 27 | local mimicActor = ui_mcm.get("idiots/options/autoLight") 28 | 29 | -- Defer to other mods if not following with "auto headlamps" enabled 30 | if not (mimicActor and NPC.isFollower(npc)) then 31 | PATCH_check_light(npc) 32 | return 33 | end 34 | 35 | -- Override with actor's headlamp state 36 | local actorTorch = db.actor:item_in_slot(10) 37 | local actorEnabled = actorTorch and actorTorch:torch_enabled() or false 38 | 39 | torch:enable_attachable_item(actorEnabled) 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/weapon.lua: -------------------------------------------------------------------------------- 1 | local WPN = { 2 | -- wmode 3 | RELOAD_ACTIVE = 0, 4 | RELOAD_ALL = 1, 5 | -- emode 6 | EMPTY = 0, 7 | HALF_EMPTY = 1, 8 | NOT_FULL = 2, 9 | } 10 | 11 | 12 | function WPN.isGun(weapon) 13 | local type = WPN.getType(weapon) 14 | return type and type ~= "melee" 15 | end 16 | 17 | 18 | function WPN.getType(weapon) 19 | if not IsWeapon(weapon) then 20 | return 21 | end 22 | 23 | local type = SYS_GetParam(0, weapon:section(), "kind") 24 | 25 | return nil 26 | or type == "w_pistol" and "pistol" 27 | or type == "w_shotgun" and "shotgun" 28 | or type == "w_smg" and "smg" 29 | or type == "w_rifle" and "rifle" 30 | or type == "w_sniper" and "sniper" 31 | or type == "w_explosive" and "rpg" 32 | or type == "w_melee" and "melee" 33 | end 34 | 35 | 36 | function WPN.getRepairType(weapon) 37 | if IsWeapon(weapon) then 38 | return SYS_GetParam(0, weapon:section(), "repair_type") 39 | end 40 | end 41 | 42 | 43 | function WPN.getCost(weapon) 44 | if IsWeapon(weapon) then 45 | return SYS_GetParam(2, weapon:section(), "cost") 46 | end 47 | end 48 | 49 | 50 | function WPN.getAmmoCount(weapon) 51 | local current = 0 52 | local total = 0 53 | 54 | if WPN.isGun(weapon) then 55 | current = weapon:get_ammo_in_magazine() or 0 56 | total = SYS_GetParam(2, weapon:section(), "ammo_mag_size", 0) 57 | end 58 | 59 | return {current = current, total = total} 60 | end 61 | 62 | 63 | return WPN 64 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_help_wounded.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | -- Change beh_companion's help_wounded_enabled to support a condlist 5 | local PATCH_help_wounded_evaluate = xr_help_wounded.evaluator_wounded_exist.evaluate 6 | 7 | function xr_help_wounded.evaluator_wounded_exist:evaluate() 8 | local npc = self.object 9 | local st = self.a 10 | 11 | if NPC.isCompanion(npc) then 12 | if not st.help_wounded_cond then 13 | st.help_wounded_cond = xr_logic.parse_condlist(npc, "beh", "help_wounded_enabled", tostring(st.help_wounded_enabled)) 14 | end 15 | 16 | st.help_wounded_enabled = xr_logic.pick_section_from_condlist(db.actor, npc, st.help_wounded_cond) == "true" 17 | end 18 | 19 | return PATCH_help_wounded_evaluate(self) 20 | end 21 | 22 | 23 | -- Change xr_help_wounded's always_help_distance to support a condlist 24 | local PATCH_help_wounded_execute = xr_help_wounded.action_help_wounded.execute 25 | 26 | function xr_help_wounded.action_help_wounded:execute() 27 | local npc = self.object 28 | local st = self.a 29 | 30 | if not st.always_help_cond then 31 | local ini = ini_file("ai_tweaks\\xr_help_wounded.ltx") 32 | st.always_help_cond = xr_logic.parse_condlist(npc, "settings", "always_help_distance", ini:r_string_ex("settings", "always_help_distance")) 33 | end 34 | 35 | xr_help_wounded.AlwaysHelpDistance = tonumber(xr_logic.pick_section_from_condlist(db.actor, npc, st.always_help_cond)) 36 | 37 | return PATCH_help_wounded_execute(self) 38 | end 39 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_weapon_jam.lua: -------------------------------------------------------------------------------- 1 | local UTIL = require "illish.lib.util" 2 | local WPN = require "illish.lib.weapon" 3 | 4 | 5 | -- Overwrite with bug fixes and new implementation 6 | function xr_weapon_jam.npc_on_update(npc) 7 | local SETTINGS = xr_weapon_jam.SETTINGS 8 | local TRACKING = xr_weapon_jam.GUN_TRACKING 9 | 10 | -- Properly evaluate SETTINGS.enabled 11 | if SETTINGS.enabled == "false" then 12 | return 13 | end 14 | 15 | if not npc:alive() or not npc:best_enemy() then 16 | return 17 | end 18 | 19 | local weapon = npc:active_item() 20 | if not IsWeapon(weapon) then 21 | return 22 | end 23 | 24 | if not TRACKING[weapon:id()] then 25 | TRACKING[weapon:id()] = {ammo_last_update = 0, rounds_since_jam = 0} 26 | end 27 | 28 | local ammo = WPN.getAmmoCount(weapon) 29 | local prevAmmo = TRACKING[weapon:id()].ammo_last_update 30 | TRACKING[weapon:id()].ammo_last_update = ammo.current 31 | 32 | if ammo.current == 0 or ammo.current >= prevAmmo then 33 | return 34 | end 35 | 36 | local totalSpent = TRACKING[weapon:id()].rounds_since_jam + prevAmmo - ammo.current 37 | TRACKING[weapon:id()].rounds_since_jam = totalSpent 38 | 39 | local rank = ranks.get_obj_rank_name(npc) 40 | local baseChance = SETTINGS["base_ch_".. rank] 41 | local maxChance = SETTINGS["max_ch_" .. rank] 42 | 43 | local chance = math.min(UTIL.round(totalSpent * baseChance / ammo.total ^ SETTINGS.clip_size_factor, 2), maxChance) 44 | 45 | -- Jam and reset jam chance values 46 | if UTIL.random(1, 100, 2) <= chance then 47 | weapon:unload_magazine() 48 | TRACKING[weapon:id()].ammo_last_update = 0 49 | TRACKING[weapon:id()].rounds_since_jam = 0 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /gamedata/configs/ui/textures_descr/idiots_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/rx_ff.lua: -------------------------------------------------------------------------------- 1 | -- Shorten time NPCs are in friendly fire scheme 2 | local PATCH_ff_eval = rx_ff.evaluator_dont_shoot.evaluate 3 | 4 | function rx_ff.evaluator_dont_shoot:evaluate() 5 | local npc = self.object 6 | local st = self.st 7 | 8 | -- Don't move immediately 9 | if not time_expired(st.__wait_until) then 10 | self.st.vertex_id = npc:level_vertex_id() 11 | end 12 | 13 | -- Shorten hold time 14 | if st.__hold_until and st.__hold_until > time_plus(500) then 15 | st.__hold_until = time_plus(500) 16 | end 17 | 18 | local eval = PATCH_ff_eval(self) 19 | 20 | -- Let custom combat types handle friendly fire 21 | local combat = db.storage[npc:id()].script_combat_type 22 | 23 | if combat == "assault" or combat == "guard" or combat == "snipe" or combat == "support" then 24 | return false 25 | end 26 | 27 | -- Shorten wait time 28 | if not eval then 29 | st.__wait_until = nil 30 | elseif not time_expired(st.__wait_until) then 31 | st.__wait_until = st.__wait_until or time_plus(1500) 32 | end 33 | 34 | return eval 35 | end 36 | 37 | 38 | -- Overwrite to shorten friend_dist 39 | function rx_ff.evaluator_dont_shoot:check_in_los(ally, enemy, enemyPos) 40 | local npc = self.object 41 | local minDist = 0.8 42 | 43 | if not (ally and ally:alive() and npc:see(ally) and npc:relation(ally) < 2) then 44 | return false 45 | end 46 | 47 | local pos = utils_obj.safe_bone_pos(npc, "bip01_r_finger02") 48 | local allyPos = utils_obj.safe_bone_pos(ally, "bip01_spine") 49 | local enemyDist = pos:distance_to(enemyPos) 50 | local allyDist = pos:distance_to(allyPos) 51 | 52 | if allyDist < minDist then 53 | return true 54 | end 55 | 56 | local enemyDir = vec_sub(enemyPos, pos):normalize() 57 | local allyDir = vec_sub(allyPos, pos):normalize() 58 | local enemyVec = enemyDir:set_length(allyDist) 59 | local allyVec = allyDir:set_length(allyDist) 60 | 61 | if allyVec:similar(enemyVec, 0) == 1 or allyVec:similar(enemyVec, 1) == 1 then 62 | return true 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/util.lua: -------------------------------------------------------------------------------- 1 | local UTIL = {} 2 | 3 | 4 | -- MATH -- 5 | function UTIL.round(val, prec) 6 | local e = 10 ^ (prec or 0) 7 | return math.floor(val * e + 0.5) / e 8 | end 9 | 10 | 11 | function UTIL.random(min, max, prec, weight) 12 | min, max, prec, weight = min or 0, max or 1, prec or 0, weight or 1 13 | local n = 0 14 | 15 | for i = 0, weight - 1 do 16 | n = n + math.random() / weight 17 | end 18 | 19 | return UTIL.round(min + n * (max - min), prec) 20 | end 21 | 22 | 23 | function UTIL.randomRange(mag, prec, weight) 24 | return UTIL.random(-mag, mag, prec, weight) 25 | end 26 | 27 | 28 | function UTIL.randomChance(percent) 29 | return UTIL.random(1, 100) <= percent 30 | end 31 | 32 | 33 | -- FUNC -- 34 | function UTIL.throttle(fn, ms1, ms2) 35 | local lastRun = 0 36 | local delay = 0 37 | local result = nil 38 | 39 | return function(...) 40 | local time = time_global() 41 | if lastRun + delay <= time then 42 | delay = math.random(ms1, ms2 or ms1) 43 | lastRun = time 44 | result = fn(...) 45 | end 46 | 47 | return result 48 | end 49 | end 50 | 51 | 52 | function UTIL.debounce(fn, ms, id, ...) 53 | local evid = "UTIL.debounce" 54 | local args = {...} 55 | 56 | if #args > 0 then 57 | id = id:format(...) 58 | end 59 | 60 | RemoveTimeEvent(evid, id) 61 | 62 | CreateTimeEvent(evid, id, ms / 1000, function() 63 | RemoveTimeEvent(evid, id) 64 | fn() 65 | end) 66 | end 67 | 68 | 69 | -- TIME -- 70 | function UTIL.timePlus(ms) 71 | return time_global() + (ms or 0) 72 | end 73 | 74 | 75 | function UTIL.timePlusRandom(ms1, ms2, weight) 76 | if type(ms1) == "table" then 77 | weight = ms2 78 | ms2 = ms1[2] 79 | ms1 = ms1[1] 80 | end 81 | 82 | return UTIL.timePlus(UTIL.random(ms1, ms2, 0, weight)) 83 | end 84 | 85 | 86 | function UTIL.timeExpired(time) 87 | return time and time <= time_global() or false 88 | end 89 | 90 | 91 | function UTIL.timeLeft(time) 92 | return math.max((time or 0) - time_global(), 0) 93 | end 94 | 95 | 96 | return UTIL 97 | -------------------------------------------------------------------------------- /gamedata/configs/ai_tweaks/mod_xr_combat_ignore_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | ![settings] 2 | ; max combat range for NPCs 3 | enemy_range = {=npc_companion =is_enemy_fighting_actor} nil, {=npc_companion} 200, {=is_enemy_actor_or_companion} 200, {=enemy_monster} 84, {=story_related} 60, {=cordon_army_vs_stalker} 60, 108 4 | ; combat range cannot fall below this regardless of night/rain/surge 5 | enemy_range_min = {=npc_companion =is_enemy_fighting_actor} nil, {=npc_companion} 90, {=is_enemy_actor_or_companion} 90, 60 6 | ; max combat range from surge cover if NPC has a surge job 7 | ; night/rain/surge does not affect this value 8 | enemy_range_surge = {=npc_companion =is_enemy_fighting_actor} nil, {=npc_companion} 108, {=is_enemy_actor_or_companion} 108, 32 9 | ; max +/- combat elevation difference vs. monsters 10 | ; keeps surface NPCs from fighting underground enemies and vice versa 11 | ; night/rain/surge does not affect this value 12 | max_elevation = {=enemy_monster} 32 13 | ; time until unseen/unheard enemies are ignored 14 | memory_time = 90000 15 | ; distance that unseen/unheard enemies are ignored 16 | memory_distance = 160 17 | ; grace period when NPC or enemy enters/leaves a safe zone 18 | safezone_expires = 8000 19 | 20 | 21 | @[night_settings] 22 | ; hour (12-23) where multipler starts to take effect (mirrored in morning) 23 | min_hour = 18 24 | ; hour (12-23) where multiplier has full effect (mirrored in morning) 25 | max_hour = 21 26 | ; combat range multiplier when at full effect (0-1) 27 | multiplier = {=enemy_monster} 0.85, {=story_related} 1, {=cordon_army_vs_stalker} 1, 0.75 28 | 29 | 30 | @[rain_settings] 31 | ; rain strength (0-1) where multiplier starts to take effect 32 | min_factor = 0 33 | ; rain strength (0-1) where multiplier has full effect 34 | max_factor = 0.5 35 | ; combat range multiplier when at full effect (0-1) 36 | multiplier = {=enemy_monster} 0.85, {=story_related} 1, {=cordon_army_vs_stalker} 1, 0.75 37 | 38 | 39 | @[surge_settings] 40 | ; combat range multiplier when at full effect (0-1) 41 | multiplier = {=enemy_monster} 0.85, {=story_related} 1, {=cordon_army_vs_stalker} 1, 0.75 42 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_corpse_detection.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | local PATCH = {} 5 | 6 | 7 | -- Enable/disable corpse looting for non-companions 8 | local PATCH_corpse_evaluate = xr_corpse_detection.evaluator_corpse.evaluate 9 | 10 | function xr_corpse_detection.evaluator_corpse:evaluate() 11 | local noGathering = ui_mcm.get("idiots/options/noNpcLooting") 12 | 13 | if noGathering and not NPC.isCompanion(self.object) then 14 | return false 15 | end 16 | 17 | return PATCH_corpse_evaluate(self) 18 | end 19 | 20 | 21 | -- Start tracking looted items for inventory visiblity 22 | local PATCH_corpse_initialize = xr_corpse_detection.action_search_corpse.initialize 23 | 24 | function xr_corpse_detection.action_search_corpse:initialize() 25 | if NPC.isCompanion(self.object) then 26 | NPC.LOOT_SHARING_NPCS[self.object:id()] = true 27 | end 28 | 29 | PATCH_corpse_initialize(self) 30 | end 31 | 32 | 33 | -- Stop tracking looted items 34 | local PATCH_corpse_finalize = xr_corpse_detection.action_search_corpse.finalize 35 | 36 | function xr_corpse_detection.action_search_corpse:finalize() 37 | NPC.LOOT_SHARING_NPCS[self.object:id()] = nil 38 | PATCH_corpse_finalize(self) 39 | end 40 | 41 | 42 | -- Track lotted item when taken 43 | function PATCH.onTakeItem(npc, item) 44 | if NPC.isCompanion(npc) and NPC.LOOT_SHARING_NPCS[npc:id()] then 45 | NPC.LOOT_SHARED_ITEMS[item:id()] = true 46 | end 47 | end 48 | 49 | 50 | -- Untrack looted item once actor takes it 51 | function PATCH.onActorTakeItem(item) 52 | NPC.LOOT_SHARED_ITEMS[item:id()] = nil 53 | end 54 | 55 | 56 | -- Untrack looted item when despawned 57 | function PATCH.onEntityUnregister(entity) 58 | NPC.LOOT_SHARED_ITEMS[entity.id] = nil 59 | end 60 | 61 | 62 | -- Callbacks 63 | RegisterScriptCallback("idiots_on_start", function() 64 | RegisterScriptCallback("npc_on_item_take", PATCH.onTakeItem) 65 | RegisterScriptCallback("actor_on_item_take", PATCH.onActorTakeItem) 66 | RegisterScriptCallback("server_entity_on_unregister", PATCH.onEntityUnregister) 67 | end) 68 | 69 | 70 | return PATCH 71 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/state_mgr.lua: -------------------------------------------------------------------------------- 1 | local UTIL = require "illish.lib.util" 2 | local VEC = require "illish.lib.vector" 3 | local NPC = require "illish.lib.npc" 4 | 5 | 6 | local PATCH = {} 7 | 8 | 9 | -- Patch with various fixes 10 | local PATCH_set_state = state_mgr.set_state 11 | 12 | function state_mgr.set_state(npc, state, callback, timeout, target, extra) 13 | -- Swap prone with prone_idle after "in" animation because otherwise 14 | -- companions get up with every direction change 15 | if NPC.isCompanion(npc) then 16 | local st = db.storage[npc:id()] 17 | 18 | if state == "prone" then 19 | st.IDIOTS_PRONE_FIX = st.IDIOTS_PRONE_FIX or time_plus(1000) 20 | if time_expired(st.IDIOTS_PRONE_FIX) then 21 | state = "prone_idle" 22 | end 23 | else 24 | st.IDIOTS_PRONE_FIX = nil 25 | end 26 | end 27 | 28 | -- Force {fast_set = true} on all companion animations because it seems to 29 | -- fix some issues with them getting stuck or being unresponsive 30 | if NPC.isCompanion(npc) then 31 | extra = extra or {} 32 | extra.fast_set = extra.fast_set ~= false 33 | end 34 | 35 | -- Validate look_position and look_dir because directions with very small 36 | -- or zero magnitudes can make NPCs/companions disappear 37 | if target and target.look_position then 38 | local dir = VEC.direction(npc:position(), target.look_position) 39 | if UTIL.round(dir:magnitude()) == 0 then 40 | target.look_position = nil 41 | end 42 | end 43 | 44 | -- Do the same for look_dir just as an extra precaution 45 | if target and target.look_dir then 46 | if UTIL.round(target.look_dir:magnitude()) == 0 then 47 | target.look_dir = nil 48 | end 49 | end 50 | 51 | return PATCH_set_state(npc, state, callback, timeout, target, extra) 52 | end 53 | 54 | 55 | -- Various fixes for prone stance 56 | state_lib.states.prone.movement = move.stand 57 | state_lib.states.prone_idle.movement = move.stand 58 | state_lib.states.prone_fire.movement = move.stand 59 | state_lib.states.prone_sniper_fire.movement = move.stand 60 | state_lib.states.prone_sniper_fire.direction = nil 61 | 62 | state_mgr_animation_list.animations.prone.prop.moving = nil 63 | state_mgr_animation_list.animations.prone_idle.prop.moving = nil 64 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/table.lua: -------------------------------------------------------------------------------- 1 | local TABLE = {} 2 | 3 | 4 | function TABLE.merge(...) 5 | local args = {...} 6 | local result = {} 7 | 8 | for _, arg in ipairs(args) do 9 | for key, value in pairs(arg) do 10 | result[key] = value 11 | end 12 | end 13 | 14 | return result 15 | end 16 | 17 | 18 | function TABLE.imerge(...) 19 | local args = {...} 20 | local result = {} 21 | 22 | for _, arg in ipairs(args) do 23 | for _, value in ipairs(arg) do 24 | result[#result + 1] = value 25 | end 26 | end 27 | 28 | return result 29 | end 30 | 31 | 32 | function TABLE.pairscb(tbl, cb) 33 | local results 34 | 35 | for key, value in pairs(tbl) do 36 | local rkey, rvalue = cb(key, value, tbl) 37 | if rkey ~= nil and rvalue ~= nil then 38 | if not results then 39 | results = {} 40 | end 41 | results[rkey] = rvalue 42 | end 43 | end 44 | 45 | return results 46 | end 47 | 48 | 49 | function TABLE.ipairscb(tbl, cb) 50 | local results 51 | 52 | for index, value in ipairs(tbl) do 53 | local rvalue = cb(value, index, tbl) 54 | if rvalue then 55 | if not results then 56 | results = {} 57 | end 58 | table.insert(results, rvalue) 59 | end 60 | end 61 | 62 | return results 63 | end 64 | 65 | 66 | function TABLE.keyof(tbl, value) 67 | for k, v in pairs(tbl) do 68 | if v == value or type(value) == "function" and value(v) then 69 | return k 70 | end 71 | end 72 | end 73 | 74 | 75 | function TABLE.reverse(tbl) 76 | local result = {} 77 | 78 | for i, v in ipairs(tbl) do 79 | result[#tbl + 1 - i] = v 80 | end 81 | 82 | return result 83 | end 84 | 85 | 86 | function TABLE.shuffle(tbl) 87 | local shuffled = dup_table(tbl) 88 | 89 | for index = #shuffled, 2, -1 do 90 | local rand = math.random(index) 91 | shuffled[index], shuffled[rand] = shuffled[rand], shuffled[index] 92 | end 93 | 94 | return shuffled 95 | end 96 | 97 | 98 | function TABLE.average(tbl) 99 | local sum = 0 100 | local count = 0 101 | 102 | for k, value in pairs(tbl) do 103 | sum = sum + value 104 | count = count + 1 105 | end 106 | 107 | return sum / count 108 | end 109 | 110 | 111 | return TABLE 112 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/utils_obj.lua: -------------------------------------------------------------------------------- 1 | local PATCH = {} 2 | 3 | 4 | -- Creatures (especially mutants) have inconsistent bone IDs for head and spine 5 | -- which trigger errors when safe_bone_pos() is called. This uses pattern 6 | -- matching on the passed ID to help return the correct position. 7 | PATCH.BONE_ALIASES = { 8 | spine = {"spine", "spine_1", "bip01_spine", "bip01_spine1"}, 9 | head = {"head", "head_boss", "bip01_head"}, 10 | } 11 | 12 | 13 | function utils_obj.safe_bone_pos(obj, bone) 14 | -- no patch needed 15 | if obj:get_bone_id(bone) ~= 65535 then 16 | return obj:bone_position(bone) 17 | end 18 | 19 | -- try to match head or spine 20 | for match, aliases in pairs(PATCH.BONE_ALIASES) do 21 | if bone:find(match) then 22 | for i, alias in ipairs(aliases) do 23 | if obj:get_bone_id(alias) ~= 65535 then 24 | return obj:bone_position(alias) 25 | end 26 | end 27 | end 28 | end 29 | 30 | -- fallback to generic position 31 | return vec(obj:position()):add(0, 0.5, 0) 32 | end 33 | 34 | 35 | --[[ TODO: refactor to better randomize direction 36 | -- (they currently favor one side too much) 37 | function utils_obj.try_go_aside_object(npc, friend, pos, old_vid) 38 | if not (friend) then 39 | return 40 | end 41 | 42 | local mypos = npc:position() 43 | 44 | if (mypos:distance_to_sqr(friend:position()) < 3) then 45 | return 46 | end 47 | 48 | local _dir = vec_sub(mypos,pos) 49 | local dir = {} 50 | dir[1] = vector_rotate_y(vec_set(_dir),-90) 51 | dir[2] = vector_rotate_y(vec_set(_dir),90) 52 | local vid 53 | local radius = 12 54 | local base_point = friend:level_vertex_id() 55 | 56 | for i=1,2 do 57 | while (radius > 0) do 58 | vid = level.vertex_in_direction(base_point,dir[i],radius) 59 | if (utils_obj.validate(npc,vid)) then 60 | return utils_obj.lmove(npc,vid,old_vid) 61 | end 62 | radius = radius - 2 63 | end 64 | end 65 | end 66 | 67 | 68 | function utils_obj.try_to_strafe(npc, old_vid) 69 | local _dir = npc:direction() 70 | local dir = {} 71 | dir[1] = vector_rotate_y(vec_set(_dir),-90) 72 | dir[2] = vector_rotate_y(vec_set(_dir),90) 73 | local vid 74 | local radius = 10 75 | local base_point = npc:level_vertex_id() 76 | 77 | for i=1,2 do 78 | while (radius > 0) do 79 | vid = level.vertex_in_direction(base_point,dir[i],radius) 80 | if (utils_obj.validate(npc,vid)) then 81 | return utils_obj.lmove(npc,vid,old_vid) 82 | end 83 | radius = radius - 2 84 | end 85 | end 86 | end 87 | --]] 88 | 89 | 90 | return PATCH 91 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_gather_items.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | local PATCH = {} 5 | 6 | 7 | -- Enable/disable gathering items for non-companions 8 | local PATCH_gather_evaluate = xr_gather_items.eva_gather_itm.evaluate 9 | 10 | function xr_gather_items.eva_gather_itm:evaluate() 11 | local noGathering = ui_mcm.get("idiots/options/noNpcLooting") 12 | 13 | if noGathering and not NPC.isCompanion(self.object) then 14 | return false 15 | end 16 | 17 | return PATCH_gather_evaluate(self) 18 | end 19 | 20 | 21 | -- Start tracking gathered items for inventory visiblity 22 | local PATCH_gather_initialize = xr_gather_items.act_gather_itm.initialize 23 | 24 | function xr_gather_items.act_gather_itm:initialize() 25 | if NPC.isCompanion(self.object) then 26 | NPC.LOOT_SHARING_NPCS[self.object:id()] = true 27 | end 28 | 29 | PATCH_gather_initialize(self) 30 | end 31 | 32 | 33 | -- Stop tracking gathered items 34 | local PATCH_gather_finalize = xr_gather_items.act_gather_itm.finalize 35 | 36 | function xr_gather_items.act_gather_itm:finalize() 37 | NPC.LOOT_SHARING_NPCS[self.object:id()] = nil 38 | PATCH_gather_finalize(self) 39 | end 40 | 41 | 42 | -- Enable/disable gathering artifacts for companions 43 | local PATCH_gather_find_item = xr_gather_items.eva_gather_itm.find_valid_item 44 | 45 | function xr_gather_items.eva_gather_itm:find_valid_item() 46 | if NPC.isCompanion(self.object) then 47 | -- Replace condlist with "false" 48 | if not self.st.ARTIFACTS_ORIGINAL then 49 | self.st.ARTIFACTS_ORIGINAL = self.st.gather_artefact_items_enabled 50 | self.st.ARTIFACTS_DISABLER = {{"false"}} 51 | end 52 | 53 | local artifactsEnabled = ui_mcm.get("idiots/options/artifacts") 54 | 55 | -- Restore original condlist 56 | if not artifactsEnabled then 57 | self.st.gather_artefact_items_enabled = self.st.ARTIFACTS_DISABLER 58 | else 59 | self.st.gather_artefact_items_enabled = self.st.ARTIFACTS_ORIGINAL 60 | end 61 | end 62 | 63 | return PATCH_gather_find_item(self) 64 | end 65 | 66 | 67 | -- Don't pickup weapons if disabled 68 | function PATCH.onItemBeforePickup(npc, item, flags) 69 | if not IsWeapon(item) then 70 | return 71 | end 72 | 73 | if NPC.isCompanion(npc) and not NPC.getState(npc, "jobs", "loot_items") then 74 | flags.ret_value = false 75 | end 76 | 77 | if not NPC.isCompanion(npc) and ui_mcm.get("idiots/options/noNpcLooting") then 78 | flags.ret_value = false 79 | end 80 | end 81 | 82 | 83 | -- NOTE: Other callbacks are in the xr_corpse_detection patch 84 | function on_game_start() 85 | RegisterScriptCallback("npc_on_item_before_pickup", PATCH.onItemBeforePickup) 86 | end 87 | 88 | 89 | return PATCH 90 | -------------------------------------------------------------------------------- /gamedata/configs/scripts/mod_beh_companion_z_idiots.ltx: -------------------------------------------------------------------------------- 1 | @[logic] 2 | on_combat = combat 3 | 4 | 5 | @[combat] 6 | combat_type = {+npcx_beh_combat_tactics_assault} assault, {+npcx_beh_combat_tactics_support} support, {+npcx_beh_combat_tactics_guard} guard, {+npcx_beh_combat_tactics_snipe} snipe, {+npcx_beh_combat_tactics_camper} camper, {+npcx_beh_combat_tactics_monolith} monolith, {+npcx_beh_combat_tactics_zombied} zombied 7 | 8 | 9 | ![beh@general] 10 | sound_idle = state 11 | 12 | ; behaviors and targets 13 | behavior_state = {+npcx_beh_substate_relax} beh_relax, {+npcx_beh_patrol_mode} beh_path, {+npcx_beh_hide_in_cover} beh_cover, {+npcx_beh_wait} beh_wait, beh_move 14 | target = {+npcx_beh_substate_relax} relax_spot, {+npcx_beh_patrol_mode} waypoint, {+npcx_beh_hide_in_cover} cover_spot, {+npcx_beh_wait} look_around, follow_actor 15 | path_end = {+npcx_beh_patrol_mode} loop 16 | 17 | ; animations 18 | walk_anim = {=is_wounded} wounded, {=follow_prone} sneak, {=follow_crouch} sneak, {+npcx_beh_substate_prone} sneak, {+npcx_beh_substate_stealth} sneak, patrol 19 | jog_anim = {=is_wounded} wounded, {=follow_prone} sneak_run, {=follow_crouch} sneak_run, {+npcx_beh_substate_prone} sneak_run, {+npcx_beh_substate_stealth} sneak_run, rush 20 | run_anim = {=is_wounded} wounded, {=follow_prone} assault, {=follow_crouch} assault, {+npcx_beh_substate_prone} assault, {+npcx_beh_substate_stealth} assault, panic 21 | sprint_anim = {=is_wounded} wounded, {=follow_prone} assault, {=follow_crouch} assault, {+npcx_beh_substate_prone} assault, {+npcx_beh_substate_stealth} assault, panic 22 | 23 | wait_anim = {=is_wounded} wounded, {+npcx_beh_substate_relax} sit_ass, {=follow_prone} prone, {=follow_crouch} hide, {+npcx_beh_substate_prone} prone, {+npcx_beh_substate_stealth} hide, guard 24 | delay_anim = {=is_wounded} wounded, {+npcx_beh_substate_relax} sit_ass, {=follow_prone} prone, {=follow_crouch} hide, {+npcx_beh_substate_prone} prone, {+npcx_beh_substate_stealth} hide, guard 25 | 26 | ; distances 27 | keep_dist = {+npcx_beh_distance_near} near, {+npcx_beh_distance_far} far, normal 28 | near_desired_dist = {+npcx_beh_patrol_mode} 1, 2.5 29 | normal_desired_dist = {+npcx_beh_patrol_mode} 1, 5 30 | far_desired_dist = {+npcx_beh_patrol_mode} 1, 10 31 | walk_dist = {+npcx_beh_substate_relax} 24, {+npcx_beh_patrol_mode} 64, {=follow_sprint} 1, {+npcx_beh_hurry} 1, 12 32 | jog_dist = {+npcx_beh_substate_relax} 48, {+npcx_beh_patrol_mode} 128, {=follow_sprint} 2, {+npcx_beh_hurry} 2, 20 33 | 34 | ; gathering/looting/etc. 35 | combat_ignore_cond = {=check_enemy_name(actor)} true, {+npcx_beh_ignore_combat} true, {+npcx_beh_ignore_actor_enemies !enemy_fighting_actor_squad} true, false 36 | gather_items_enabled = {+npcx_beh_gather_items} true, false 37 | gather_artefact_items_enabled = {+npcx_beh_gather_artifacts} true, false 38 | corpse_detection_enabled = {+npcx_beh_loot_corpses} true, false 39 | help_wounded_enabled = {+npcx_beh_help_wounded} true, false 40 | 41 | 42 | ![meet] 43 | close_anim = nil 44 | close_distance = {=is_wounded} 1, 0 45 | far_distance = 0 46 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/mcm.lua: -------------------------------------------------------------------------------- 1 | local TABLE = require "illish.lib.table" 2 | 3 | 4 | local MCM = {} 5 | 6 | 7 | function MCM.getTitle(overrides) 8 | return TABLE.merge({ 9 | id = "title", 10 | type = "slide", 11 | text = "ui_mcm_title", 12 | link = "ui_options_slider_player", 13 | size = {512, 50}, 14 | spacing = 20, 15 | }, overrides or {}) 16 | end 17 | 18 | 19 | function MCM.getSubtitle(overrides) 20 | return TABLE.merge({ 21 | id = "subtitle", 22 | type = "desc", 23 | text = "ui_mcm_subtitle", 24 | clr = {255, 255, 255, 255}, 25 | }, overrides or {}) 26 | end 27 | 28 | 29 | function MCM.getNote(overrides) 30 | return TABLE.merge({ 31 | id = "note", 32 | type = "desc", 33 | text = "ui_mcm_note", 34 | clr = {255, 112, 112, 112}, 35 | }, overrides or {}) 36 | end 37 | 38 | 39 | function MCM.getLine(overrides) 40 | return TABLE.merge({ 41 | id = "line", 42 | type = "line", 43 | }, overrides or {}) 44 | end 45 | 46 | 47 | function MCM.getScaleField(overrides) 48 | return TABLE.merge({ 49 | id = "scale", 50 | type = "track", 51 | val = 2, 52 | def = 1, 53 | min = 0.1, 54 | max = 8, 55 | step = 0.05, 56 | prec = 2, 57 | }, overrides or {}) 58 | end 59 | 60 | 61 | function MCM.getOffsetXField(overrides) 62 | return TABLE.merge({ 63 | id = "offsetX", 64 | type = "track", 65 | val = 2, 66 | def = 0, 67 | min = -1024, 68 | max = 1024, 69 | step = 1, 70 | prec = 0, 71 | }, overrides or {}) 72 | end 73 | 74 | 75 | function MCM.getOffsetYField(overrides) 76 | return TABLE.merge({ 77 | id = "offsetY", 78 | type = "track", 79 | val = 2, 80 | def = 0, 81 | min = -1024, 82 | max = 1024, 83 | step = 1, 84 | prec = 0, 85 | }, overrides or {}) 86 | end 87 | 88 | 89 | function MCM.getAlphaField(overrides) 90 | return TABLE.merge({ 91 | id = "alpha", 92 | type = "track", 93 | val = 2, 94 | def = 255, 95 | min = 0, 96 | max = 255, 97 | step = 1, 98 | prec = 0, 99 | }, overrides or {}) 100 | end 101 | 102 | 103 | function MCM.getCheckboxField(overrides) 104 | return TABLE.merge({ 105 | id = "checkbox", 106 | type = "check", 107 | val = 1, 108 | def = false, 109 | }, overrides or {}) 110 | end 111 | 112 | 113 | function MCM.getListField(overrides) 114 | return TABLE.merge({ 115 | id = "list", 116 | type = ui_mcm.kb_mod_list, 117 | val = 0, 118 | }, overrides or {}) 119 | end 120 | 121 | 122 | function MCM.getKeybindKey(overrides) 123 | return TABLE.merge({ 124 | id = "key", 125 | type = "key_bind", 126 | val = 2, 127 | def = -1, 128 | }, overrides or {}) 129 | end 130 | 131 | 132 | function MCM.getKeybindMod(overrides) 133 | return TABLE.merge({ 134 | id = "mod", 135 | type = ui_mcm.kb_mod_list, 136 | val = 2, 137 | def = 0, 138 | content = {{0, "mod_none"}, {1, "mod_shift"}, {2, "mod_ctrl"}, {3, "mod_alt"}} 139 | }, overrides or {}) 140 | end 141 | 142 | 143 | function MCM.getKeybindMode(overrides) 144 | return TABLE.merge({ 145 | id = "mode", 146 | type = ui_mcm.kb_mod_list, 147 | val = 2, 148 | def = 0, 149 | content = {{0, "mode_press"}, {1, "mode_dtap"}, {2, "mode_hold"}} 150 | }, overrides or {}) 151 | end 152 | 153 | 154 | return MCM 155 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_conditions.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | -- Should companion auto-crouch? 5 | function xr_conditions.follow_crouch(actor, npc) 6 | if not ui_mcm.get("idiots/options/autoSneak") then 7 | return false 8 | end 9 | return IsMoveState("mcCrouch") and NPC.isFollower(npc) 10 | end 11 | 12 | 13 | -- Should companion auto-prone? 14 | function xr_conditions.follow_prone(actor, npc) 15 | if not ui_mcm.get("idiots/options/autoProne") then 16 | return false 17 | end 18 | return IsMoveState("mcCrouch") and IsMoveState("mcAccel") and NPC.isFollower(npc) 19 | end 20 | 21 | 22 | -- Should companion auto-sprint? 23 | function xr_conditions.follow_sprint(actor, npc) 24 | if not ui_mcm.get("idiots/options/autoSprint") then 25 | return false 26 | end 27 | return IsMoveState("mcSprint") and NPC.isFollower(npc) 28 | end 29 | 30 | 31 | -- Is enemy is mutant or zombied? 32 | function xr_conditions.enemy_monster(enemy, npc) 33 | return IsMonster(enemy) or character_community(enemy) == "zombied" 34 | end 35 | 36 | 37 | -- Is npc or enemy story related (for xr_combat_ignore)? 38 | function xr_conditions.story_related(enemy, npc) 39 | if enemy:id() == 0 then 40 | return false 41 | end 42 | 43 | if get_object_story_id(npc:id()) or get_object_story_id(enemy:id()) then 44 | return true 45 | end 46 | 47 | local nsquad = get_object_squad(npc) 48 | local esquad = get_object_squad(enemy) 49 | 50 | if nsquad and get_object_story_id(nsquad.id) or esquad and get_object_story_id(esquad.id) then 51 | return true 52 | end 53 | 54 | return false 55 | end 56 | 57 | 58 | -- Is military vs. stalker in Cordon (for xr_combat_ignore)? 59 | function xr_conditions.cordon_army_vs_stalker(enemy, npc) 60 | if alife():has_info(npc:id(), "npcx_is_companion") then 61 | return false 62 | end 63 | 64 | if enemy:id() == 0 or alife():has_info(enemy:id(), "npcx_is_companion") then 65 | return false 66 | end 67 | 68 | if level.name() ~= "l01_escape" then 69 | return false 70 | end 71 | 72 | local ncomm = character_community(npc) 73 | local ecomm = character_community(enemy) 74 | 75 | return ncomm == "army" and ecomm == "stalker" 76 | or ncomm == "stalker" and ecomm == "army" 77 | end 78 | 79 | 80 | -- Is npc a companion? 81 | function xr_conditions.npc_companion(enemy, npc) 82 | return alife():has_info(npc:id(), "npcx_is_companion") 83 | and true 84 | or false 85 | end 86 | 87 | 88 | -- Is playing GAMMA (by checking if GAMMA manual exists)? 89 | function xr_conditions.is_gamma() 90 | return grok_gamma_manual_on_startup and true or false 91 | end 92 | 93 | 94 | -- Is using RE:Done Combat AI? 95 | function xr_conditions.is_redone_combat() 96 | return redone_ai_schemes and true or false 97 | end 98 | 99 | 100 | -- Attacked companions 101 | function xr_conditions.enemy_fighting_actor_squad(enemy, npc) 102 | if not xr_conditions.npc_companion(enemy, npc) then 103 | return false 104 | end 105 | 106 | if xr_conditions.is_enemy_fighting_actor(enemy, npc) then 107 | return true 108 | end 109 | 110 | local defendMode = ui_mcm.get("idiots/options/defendMode") 111 | if not defendMode or defendMode == "actor" then 112 | return false 113 | end 114 | 115 | local eid = db.storage[npc:id()].hitted_by 116 | if eid and NPC.get(eid) and NPC.get(eid):alive() then 117 | return true 118 | end 119 | 120 | if defendMode == "self" then 121 | return false 122 | end 123 | 124 | eid = db.storage[0].companion_hit_by 125 | 126 | return eid and NPC.get(eid) and NPC.get(eid):alive() 127 | end 128 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/vector.lua: -------------------------------------------------------------------------------- 1 | local UTIL = require "illish.lib.util" 2 | local TABLE = require "illish.lib.table" 3 | 4 | 5 | local VEC = {} 6 | 7 | 8 | function VEC.set(...) 9 | return vector():set(...) 10 | end 11 | 12 | 13 | function VEC.direction(a, b) 14 | return VEC.set(b):sub(a):normalize() 15 | end 16 | 17 | 18 | function VEC.distance(a, b) 19 | return a:distance_to(b) 20 | end 21 | 22 | 23 | function VEC.offset(point, dir, dist) 24 | return VEC.set(point):add( 25 | VEC.set(dir):normalize():mul(dist) 26 | ) 27 | end 28 | 29 | 30 | function VEC.dotProduct(a, b) 31 | return VEC.set(a):dotproduct(b) 32 | end 33 | 34 | 35 | function VEC.average(points) 36 | local total = VEC.set(0, 0, 0) 37 | 38 | for i, point in ipairs(points) do 39 | total:add(point) 40 | end 41 | 42 | return total:div(#points) 43 | end 44 | 45 | 46 | function VEC.rotate(point, angle) 47 | return vector_rotate_y(point, angle) 48 | end 49 | 50 | 51 | function VEC.rotateRandom(point, angle1, angle2, prec, weight) 52 | point = point or VEC.set(1, 0, 0) 53 | angle1 = angle1 or 180 54 | 55 | if type(angle1) == "table" then 56 | weight = prec 57 | prec = angle2 58 | angle2 = angle1[2] 59 | angle1 = angle1[1] 60 | end 61 | 62 | return VEC.rotate(point, UTIL.random(angle1, angle2, prec, weight)) 63 | end 64 | 65 | 66 | function VEC.rotateRange(point, angle, prec, weight) 67 | angle = angle or 180 68 | return VEC.rotateRandom(point, -angle, angle, prec, weight) 69 | end 70 | 71 | 72 | function VEC.pointsAlongAxis(options) 73 | options = TABLE.merge({ 74 | position = db.actor:position(), 75 | direction = db.actor:direction(), 76 | scatter = {0, 0}, 77 | arcAngle = 180, 78 | arcLength = nil, 79 | rowSpacing = nil, 80 | radius = 16, 81 | spacing = 4, 82 | rows = 1, 83 | }, options) 84 | 85 | local basePos, baseDir, spacing = 86 | options.position, 87 | options.direction, 88 | options.spacing 89 | 90 | local rowSpacing = options.rowSpacing 91 | or spacing 92 | 93 | local points = {} 94 | 95 | for r = 0, options.rows / 2 do 96 | for rf = 1, (r == 0 and 1 or -1), -2 do 97 | if rf == -1 and (2 * r + 1) > options.rows then 98 | break 99 | end 100 | 101 | local radius = options.radius + (rowSpacing * r * rf) 102 | 103 | local arcAngle = options.arcLength 104 | and math.deg(options.arcLength / radius) 105 | or options.arcAngle 106 | 107 | local arcLength = options.arcLength 108 | or math.rad(arcAngle) * radius 109 | 110 | local count = math.floor(arcLength / options.spacing) 111 | 112 | if count % 2 == 1 then 113 | count = count + 1 114 | end 115 | 116 | local angle = arcAngle % 360 == 0 117 | and arcAngle / (count + 1) 118 | or arcAngle / count 119 | 120 | local bdir = r % 2 == 0 121 | and VEC.rotate(baseDir, angle * -0.25) 122 | or VEC.rotate(baseDir, angle * 0.25) 123 | 124 | local rad = options.rows % 2 == 0 125 | and radius - rowSpacing / 2 126 | or radius 127 | 128 | for i = 0, count / 2 do 129 | for f = -1, (i == 0 and -1 or 1), 2 do 130 | local ascatter = UTIL.randomRange(angle * options.scatter[1], 1) 131 | local rscatter = UTIL.randomRange(spacing * options.scatter[2], 1) 132 | 133 | local dir = VEC.rotate(bdir, angle * i * f + ascatter) 134 | points[#points + 1] = VEC.offset(basePos, dir, rad + rscatter) 135 | end 136 | end 137 | end 138 | end 139 | 140 | return points 141 | end 142 | 143 | 144 | function VEC.serialize(point) 145 | return type(point) == "userdata" and point.x and point.y and point.z 146 | and {x = point.x, y = point.y, z = point.z} 147 | or point 148 | end 149 | 150 | 151 | function VEC.unserialize(point) 152 | return point and point.x and point.y and point.z 153 | and VEC.set(point.x, point.y, point.z) 154 | or point 155 | end 156 | 157 | 158 | return VEC 159 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/surge_manager.lua: -------------------------------------------------------------------------------- 1 | local POS = require "illish.lib.pos" 2 | local NPC = require "illish.lib.npc" 3 | local SURGE = require "illish.lib.surge" 4 | 5 | 6 | local PATCH = {} 7 | 8 | -- Temp cache for obfuscated companions 9 | PATCH.OBFUSCATED = nil 10 | 11 | 12 | -- Temporarily hide companions from scripts to allow them to die in GAMMA 13 | function PATCH.obfuscateCompanions() 14 | local companions = NPC.getCompanions() 15 | 16 | if not (companions and #companions > 0) then 17 | return 18 | end 19 | 20 | for i, npc in ipairs(companions) do 21 | npc:disable_info_portion("npcx_is_companion") 22 | end 23 | 24 | PATCH.OBFUSCATED = { 25 | squads = axr_companions.companion_squads, 26 | nonTask = axr_companions.non_task_companions, 27 | companions = companions, 28 | } 29 | 30 | axr_companions.companion_squads = {} 31 | axr_companions.non_task_companions = {} 32 | end 33 | 34 | -- Restore companions to their original state 35 | function PATCH.deobfuscateCompanions() 36 | if not PATCH.OBFUSCATED then 37 | return 38 | end 39 | 40 | for i, npc in ipairs(PATCH.OBFUSCATED.companions) do 41 | npc:give_info_portion("npcx_is_companion") 42 | end 43 | 44 | axr_companions.companion_squads = PATCH.OBFUSCATED.squads 45 | axr_companions.non_task_companions = PATCH.OBFUSCATED.nonTask 46 | 47 | PATCH.OBFUSCATED = nil 48 | end 49 | 50 | 51 | -- Temporarily add dummy story IDs for companions to prevent Anomaly from killing them 52 | function PATCH.addCompanionStoryIds() 53 | local ids = story_objects.story_id_by_object_id 54 | 55 | for i, npc in ipairs(NPC.getCompanions()) do 56 | if not ids[npc:id()] then 57 | ids[npc:id()] = "useful_idiot" 58 | end 59 | end 60 | end 61 | 62 | -- Remove dummy companion story IDs 63 | function PATCH.removeCompanionStoryIds() 64 | local ids = story_objects.story_id_by_object_id 65 | 66 | for i, npc in ipairs(NPC.getCompanions()) do 67 | if ids[npc:id()] == "useful_idiot" then 68 | ids[npc:id()] = nil 69 | end 70 | end 71 | end 72 | 73 | 74 | -- Use dynamic surge cover for companions if enabled 75 | local surge_manager_pos_in_cover = surge_manager.CSurgeManager.pos_in_cover 76 | 77 | function surge_manager.CSurgeManager:pos_in_cover(pos, byName) 78 | local dynamic = ui_mcm.get("idiots/options/dynamicSurgeCover") 79 | local result = surge_manager_pos_in_cover(self, pos, byName) 80 | 81 | if result or dynamic == "neither" then 82 | return result 83 | end 84 | 85 | return SURGE.isDynamicCover(pos) 86 | end 87 | 88 | 89 | -- Use dynamic surge cover for the player if enabled 90 | local actor_status_scan_safe_zone_old = actor_status.scan_safe_zone_old 91 | 92 | function actor_status.scan_safe_zone_old() 93 | local dynamic = ui_mcm.get("idiots/options/dynamicSurgeCover") 94 | local curr, near, num = actor_status_scan_safe_zone_old() 95 | 96 | if not curr and dynamic == "both" then 97 | curr = SURGE.isDynamicCover(db.actor:position()) 98 | end 99 | 100 | return curr, near, num 101 | end 102 | 103 | 104 | -- Kill or spare companions in emission depending on settings 105 | local surge_manager_kill_objects_at_pos = surge_manager.CSurgeManager.kill_objects_at_pos 106 | 107 | function surge_manager.CSurgeManager:kill_objects_at_pos(...) 108 | local killCompanions = ui_mcm.get("idiots/options/surgesKillCompanions") 109 | 110 | if killCompanions 111 | then PATCH.obfuscateCompanions() 112 | else PATCH.addCompanionStoryIds() 113 | end 114 | 115 | surge_manager_kill_objects_at_pos(self, ...) 116 | 117 | if killCompanions 118 | then PATCH.deobfuscateCompanions() 119 | else PATCH.removeCompanionStoryIds() 120 | end 121 | end 122 | 123 | 124 | -- Kill or spare companions in psi storms depending on settings 125 | local psi_storm_manager_kill_objects_at_pos = psi_storm_manager.CPsiStormManager.kill_objects_at_pos 126 | 127 | function psi_storm_manager.CPsiStormManager:kill_objects_at_pos(...) 128 | local killCompanions = ui_mcm.get("idiots/options/surgesKillCompanions") 129 | 130 | if killCompanions 131 | then PATCH.obfuscateCompanions() 132 | else PATCH.addCompanionStoryIds() 133 | end 134 | 135 | psi_storm_manager_kill_objects_at_pos(self, ...) 136 | 137 | if killCompanions 138 | then PATCH.deobfuscateCompanions() 139 | else PATCH.removeCompanionStoryIds() 140 | end 141 | end 142 | 143 | 144 | -- Manage the surge cover cache 145 | RegisterScriptCallback("idiots_on_start", function() 146 | RegisterScriptCallback("actor_on_first_update", function() 147 | SURGE.buildCovers() 148 | end) 149 | 150 | RegisterScriptCallback("mcm_option_change", function() 151 | if alife() then 152 | SURGE.buildCovers(true) 153 | end 154 | end) 155 | end) 156 | 157 | 158 | return PATCH 159 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_danger.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | local PATCH = {} 5 | 6 | -- Fix values in Anomaly that got incorrectly overwritten 7 | xr_danger.bd_types[danger_object.entity_attacked] = "entity_attacked" 8 | xr_danger.bd_types[danger_object.bullet_ricochet] = "bullet_ricochet" 9 | xr_danger.bd_types[danger_object.attack_sound] = "attack_sound" 10 | 11 | 12 | -- Add IsStalker() check to corpses for Anomaly 13 | function xr_danger.get_danger_time(danger) 14 | if danger:type() ~= danger_object.entity_corpse then 15 | return danger:time() 16 | end 17 | 18 | local corpse = danger:object() 19 | return IsStalker(corpse) and corpse:death_time() or 0 20 | end 21 | 22 | 23 | -- Add has_danger() check for Anomaly 24 | local PATCH_npc_on_hear_callback = xr_danger.npc_on_hear_callback 25 | 26 | function xr_danger.npc_on_hear_callback(npc, ...) 27 | if not xr_danger.has_danger(npc) then 28 | PATCH_npc_on_hear_callback(npc, ...) 29 | end 30 | end 31 | 32 | 33 | -- disable buggy hit callback in Anomaly 34 | function xr_danger.npc_on_hit_callback() 35 | end 36 | 37 | 38 | -- Overwrite with bug fixes 39 | function xr_danger.is_danger(npc, danger) 40 | if xr_wounded.is_heavy_wounded_by_id(npc:id()) then 41 | return false 42 | end 43 | 44 | if axr_task_manager.hostages_by_id[npc:id()] then 45 | return false 46 | end 47 | 48 | local dangerObject = danger:object() 49 | local dangerType = danger:type() 50 | 51 | -- Grenade 52 | if dangerType == danger_object.grenade then 53 | if danger:dependent_object() and character_community(npc) ~= "zombied" then 54 | return xr_danger.danger_in_radius(npc, danger, dangerType) 55 | end 56 | 57 | return false 58 | end 59 | 60 | if xr_combat_ignore.npc_in_safe_zone(npc) then 61 | return false 62 | end 63 | 64 | -- Corpse 65 | if dangerType == danger_object.entity_corpse then 66 | if not (dangerObject and IsStalker(dangerObject) and character_community(dangerObject) == character_community(npc)) then 67 | return false 68 | end 69 | 70 | local corpse = db.storage[dangerObject:id()] 71 | 72 | if not (corpse and corpse.death_time and corpse.death_by_id) then 73 | return false 74 | end 75 | 76 | local killer = db.storage[corpse.death_by_id] and db.storage[corpse.death_by_id].object 77 | 78 | if not (killer and (IsMonster(killer) or IsStalker(killer)) and killer:alive() and npc:relation(killer) > 0 and character_community(killer) ~= character_community(npc)) then 79 | return false 80 | end 81 | 82 | if xr_combat_ignore.ignore_enemy_by_overrides(npc, killer) then 83 | return false 84 | end 85 | 86 | if game.get_game_time():diffSec(corpse.death_time) > 120000 then 87 | return false 88 | end 89 | 90 | return xr_danger.danger_in_radius(npc, danger, dangerType) 91 | end 92 | 93 | if danger:perceive_type() == danger_object.hit then 94 | return xr_danger.danger_in_radius(npc, danger, dangerType) 95 | end 96 | 97 | if xr_corpse_detection.is_under_corpse_detection(npc) or xr_help_wounded.is_under_help_wounded(npc) or xrs_kill_wounded.is_under_kill_wounded(npc) then 98 | return false 99 | end 100 | 101 | if get_object_story_id(npc:id()) then 102 | return false 103 | end 104 | 105 | if danger:dependent_object() then 106 | dangerObject = danger:dependent_object() 107 | end 108 | 109 | if not dangerObject or not dangerObject:alive() or not IsMonster(dangerObject) and not IsStalker(dangerObject) then 110 | return false 111 | end 112 | 113 | if dangerType ~= 0 and dangerType ~= 1 and not xr_combat_ignore.is_enemy(npc, dangerObject, true) then 114 | return false 115 | end 116 | 117 | if dangerObject:id() == 0 and npc:relation(dangerObject) < 1 or dangerObject:id() ~= 0 and npc:relation(dangerObject) < 2 then 118 | return false 119 | end 120 | 121 | return xr_danger.danger_in_radius(npc, danger, dangerType) 122 | end 123 | 124 | 125 | -- Custom danger state for companions 126 | function PATCH.onEvalDanger(npc, flags) 127 | if not NPC.isCompanion(npc) then 128 | return 129 | end 130 | 131 | local danger = npc:best_danger() 132 | if not danger then 133 | return 134 | end 135 | 136 | -- Always react to grenades 137 | if danger:type() == danger_object.grenade then 138 | return 139 | end 140 | 141 | local enemy = danger:dependent_object() or danger:object() 142 | 143 | if enemy and xr_combat_ignore.is_enemy(npc, enemy) then 144 | -- Always react when hit 145 | if danger:perceive_type() == danger_object.hit then 146 | return 147 | end 148 | 149 | local hitBy = db.storage[npc:id()].hitted_by 150 | if hitBy and enemy and hitBy == enemy:id() then 151 | return 152 | end 153 | 154 | -- Otherwise turn towards enemy danger 155 | NPC.lookAtPoint(npc, enemy:position()) 156 | end 157 | 158 | -- Ignore all other danger types 159 | flags.ret_value = false 160 | end 161 | 162 | 163 | RegisterScriptCallback("idiots_on_start", function() 164 | RegisterScriptCallback("npc_on_eval_danger", PATCH.onEvalDanger) 165 | end) 166 | 167 | 168 | return PATCH 169 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/axr_beh.lua: -------------------------------------------------------------------------------- 1 | local WP = world_property 2 | local UTIL = require "illish.lib.util" 3 | local NPC = require "illish.lib.npc" 4 | local BEH = require "illish.lib.beh" 5 | local WPN = require "illish.lib.weapon" 6 | 7 | 8 | local PATCH = {} 9 | 10 | -- Additional targets added by Useful Idiots to beh_companion.ltx and the 11 | -- corresponding functions for their behavior. 12 | PATCH.CUSTOM_TARGETS = { 13 | cover_spot = BEH.setTargetCoverSpot, 14 | cover_actor = BEH.setTargetFollowActor, 15 | follow_actor = BEH.setTargetFollowActor, 16 | look_around = BEH.setTargetLookAround, 17 | relax_spot = BEH.setTargetRelaxSpot, 18 | } 19 | 20 | 21 | -- Add a "normal" desired distance setting 22 | local PATCH_init_custom_data = axr_beh.init_custom_data 23 | 24 | function axr_beh.init_custom_data(npc, ini, section, st, scheme) 25 | PATCH_init_custom_data(npc, ini, section, st, scheme) 26 | st.normal_desired_dist = ini:r_string_to_condlist(section, "normal_desired_dist", "4") 27 | end 28 | 29 | 30 | -- Check if weapon needs to be reloading when entering BEH scheme 31 | local PATCH_initialize = axr_beh.action_beh.initialize 32 | 33 | function axr_beh.action_beh:initialize() 34 | PATCH_initialize(self) 35 | local npc = self.object 36 | 37 | if not NPC.isCompanion(npc) then 38 | return 39 | end 40 | 41 | local wmode = ui_mcm.get("idiots/options/autoReloadAll") 42 | and WPN.RELOAD_ALL 43 | or WPN.RELOAD_ACTIVE 44 | 45 | NPC.setReloadModes(self.object, wmode, WPN.NOT_FULL) 46 | end 47 | 48 | 49 | -- Inject custom targets into BEH scheme 50 | local PATCH_set_desired_target = axr_beh.action_beh.set_desired_target 51 | 52 | function axr_beh.action_beh:set_desired_target() 53 | local npc = self.object 54 | local st = self.st 55 | 56 | if not NPC.isCompanion(npc) then 57 | return PATCH_set_desired_target(self) 58 | end 59 | 60 | local target = xr_logic.pick_section_from_condlist(db.actor, npc, st.goto_target) 61 | 62 | -- Remember desired_target values between calls 63 | if st.target == target and st.desired_target then 64 | st.savedTarget = dup_table(st.desired_target) 65 | else 66 | st.lookTimer = nil 67 | st.lookPoint = nil 68 | st.savedTarget = {} 69 | end 70 | 71 | -- Retain previous values to detect changes 72 | st.lastTarget = st.target 73 | st.lastKeepType = st.keepType 74 | 75 | -- Defer to original function first 76 | local success = PATCH_set_desired_target(self) 77 | local targetFn = PATCH.CUSTOM_TARGETS[target] 78 | 79 | -- Run custom target function only if original did not match 80 | if success or not targetFn then 81 | return success 82 | end 83 | 84 | -- globally store this result for use in custom target functions 85 | st.keepType = xr_logic.pick_section_from_condlist(db.actor, npc, st.keep_distance) 86 | 87 | return targetFn(self) 88 | end 89 | 90 | 91 | -- Override beh_move 92 | local PATCH_beh_move = axr_beh.action_beh.beh_move 93 | 94 | function axr_beh.action_beh:beh_move() 95 | local npc = self.object 96 | local st = self.st 97 | 98 | -- Backward compatibility 99 | if not (NPC.isCompanion(npc) and PATCH.CUSTOM_TARGETS[st.target]) then 100 | return PATCH_beh_move(self) 101 | end 102 | 103 | if not st.setStateFn then 104 | st.setStateFn = state_mgr.set_state 105 | end 106 | 107 | local move = BEH.getBehMoveState(self) 108 | local look = BEH.getBehLookState(self) 109 | 110 | st.setStateFn(npc, move, nil, nil, look, {fast_set = true}) 111 | end 112 | 113 | 114 | -- Override beh_wait 115 | local PATCH_beh_wait = axr_beh.action_beh.beh_wait 116 | 117 | function axr_beh.action_beh:beh_wait() 118 | local npc = self.object 119 | local st = self.st 120 | 121 | -- Backward compatibility 122 | if not (NPC.isCompanion(npc) and PATCH.CUSTOM_TARGETS[st.target]) then 123 | return PATCH_beh_wait(self) 124 | end 125 | 126 | self:beh_move() 127 | end 128 | 129 | 130 | -- Override beh_cover 131 | local PATCH_beh_cover = axr_beh.action_beh.beh_cover 132 | 133 | function axr_beh.action_beh:beh_cover() 134 | local npc = self.object 135 | local st = self.st 136 | 137 | -- Backward compatibility 138 | if not (NPC.isCompanion(npc) and PATCH.CUSTOM_TARGETS[st.target]) then 139 | return PATCH_beh_cover(self) 140 | end 141 | 142 | self:beh_move() 143 | end 144 | 145 | 146 | -- New behavior 147 | function axr_beh.action_beh:beh_relax() 148 | local npc = self.object 149 | local st = self.st 150 | 151 | -- Fallack to beh_wait if something went wrong 152 | if not (NPC.isCompanion(npc) and PATCH.CUSTOM_TARGETS[st.target]) then 153 | return PATCH_beh_wait(self) 154 | end 155 | 156 | self:beh_move() 157 | end 158 | 159 | 160 | -- Let gather items, loot corpses, and heal wounded to interrupt scheme 161 | local PATCH_beh_add_to_binder = axr_beh.add_to_binder 162 | 163 | function axr_beh.add_to_binder(npc, ...) 164 | PATCH_beh_add_to_binder(npc, ...) 165 | 166 | local manager = npc:motivation_action_manager() 167 | local action = manager:action(axr_beh.beh_actid) 168 | 169 | if (schemes.gather_items) then 170 | action:add_precondition(WP(xr_gather_items.evaid, false)) 171 | end 172 | 173 | if (schemes.corpse_detection) then 174 | action:add_precondition(WP(xr_evaluators_id.corpse_exist, false)) 175 | end 176 | 177 | if (schemes.help_wounded) then 178 | action:add_precondition(WP(xr_evaluators_id.wounded_exist, false)) 179 | end 180 | 181 | action:add_precondition(WP(stalker_ids.property_items, false)) 182 | end 183 | 184 | 185 | return PATCH 186 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/surge.lua: -------------------------------------------------------------------------------- 1 | local TABLE = require "illish.lib.table" 2 | local VEC = require "illish.lib.vector" 3 | local POS = require "illish.lib.pos" 4 | local RAY = require "illish.lib.ray" 5 | 6 | local SURGE = {} 7 | 8 | 9 | -- CONSTANTS -- 10 | SURGE.UNSAFE_MATERIALS = { 11 | ["materials\\fake"] = true, 12 | ["materials\\bush"] = true, 13 | ["materials\\cloth"] = true, 14 | ["materials\\water"] = true, 15 | ["materials\\bush_sux"] = true, 16 | ["materials\\tree_trunk"] = true, 17 | ["materials\\setka_rabica"] = true, 18 | } 19 | 20 | SURGE.SEARCH_AREAS = { 21 | camp_zone = true, 22 | climable_object = true, 23 | space_restrictor = true, 24 | smart_terrain = true, 25 | level_door = true, 26 | } 27 | 28 | SURGE.COVER_CACHE = nil 29 | -- 30 | 31 | 32 | -- UTILS -- 33 | function SURGE.isActive() 34 | return surge_manager.is_loaded() and surge_manager.is_started() 35 | or psi_storm_manager.is_loaded() and psi_storm_manager.is_started() 36 | end 37 | 38 | 39 | function SURGE.isDynamicCover(pos) 40 | local headPos = VEC.set(pos):add(0, 2, 0) 41 | local dist = 64 42 | local score = 0 43 | local __debug = {} 44 | 45 | local points = VEC.pointsAlongAxis({ 46 | direction = VEC.set(1, 0, 0), 47 | position = headPos, 48 | arcAngle = 360, 49 | rowSpacing = 0.3, 50 | spacing = 1.2, 51 | radius = 0.6, 52 | rows = 2, 53 | }) 54 | 55 | table.insert(points, headPos) 56 | 57 | for i, point in ipairs(points) do 58 | local dir = VEC.direction(pos, VEC.set(point):add(0, 1, 0)) 59 | local ray = RAY.cast(point, dir, dist) 60 | 61 | ray:query() 62 | 63 | local result = ray:get_result() 64 | local covered = result.range > 0 and not SURGE.UNSAFE_MATERIALS[result.material_name] 65 | 66 | if covered then 67 | score = score + 1 / #points 68 | end 69 | 70 | table.insert(__debug, { 71 | covered = covered, 72 | result = result, 73 | pos = point, 74 | dist = dist, 75 | dir = dir, 76 | }) 77 | end 78 | 79 | return score > 0.8, score, __debug 80 | end 81 | 82 | 83 | function SURGE.buildCovers(force) 84 | if force or SURGE.COVER_CACHE and SURGE.COVER_CACHE.level ~= level.name() then 85 | SURGE.COVER_CACHE = nil 86 | end 87 | 88 | if SURGE.COVER_CACHE and SURGE.COVER_CACHE.areas then 89 | return SURGE.COVER_CACHE.areas 90 | end 91 | 92 | local sm = surge_manager.get_surge_manager() 93 | local objects = {} 94 | local areas = {} 95 | 96 | alife():iterate_objects(function(obj) 97 | if obj.online and SURGE.SEARCH_AREAS[obj:section_name()] then 98 | table.insert(objects, { 99 | name = obj:section_name(), 100 | pos = obj.position, 101 | id = obj.id, 102 | covers = nil, 103 | }) 104 | end 105 | end) 106 | 107 | if SURGE.SEARCH_AREAS.level_door then 108 | for id, pos in pairs(db.level_doors) do 109 | table.insert(objects, { 110 | name = "level_door", 111 | pos = pos, 112 | id = id, 113 | covers = nil, 114 | }) 115 | end 116 | end 117 | 118 | for i, obj in ipairs(objects) do 119 | obj.covers = SURGE.nearbyCovers(obj.pos) 120 | if obj.covers then 121 | table.insert(areas, obj) 122 | end 123 | end 124 | 125 | SURGE.COVER_CACHE = {areas = areas, level = level.name()} 126 | return areas, objects 127 | end 128 | 129 | 130 | function SURGE.nearbyCovers(pos) 131 | local sm = surge_manager.get_surge_manager() 132 | local covers = {} 133 | 134 | local points = VEC.pointsAlongAxis({ 135 | position = pos, 136 | direction = VEC.set(1, 0, 0), 137 | scatter = {0.3, 0.3}, 138 | arcAngle = 360, 139 | radius = 18, 140 | rows = 5, 141 | rowSpacing = 6, 142 | spacing = 6, 143 | }) 144 | 145 | table.insert(points, pos) 146 | 147 | for i, point in ipairs(points) do 148 | local pos, vid = POS.snap(point) 149 | if vid ~= POS.INVALID_LVID and sm:pos_in_cover(pos) then 150 | table.insert(covers, vid) 151 | end 152 | end 153 | 154 | if #covers > 0 then 155 | return covers 156 | end 157 | end 158 | 159 | 160 | function SURGE.pickBestCover(npc) 161 | local pos = npc:position() 162 | local areas = dup_table(SURGE.buildCovers()) 163 | 164 | local actorArea = { 165 | name = "actor", 166 | pos = db.actor:position(), 167 | id = db.actor:id(), 168 | covers = SURGE.nearbyCovers(db.actor:position()), 169 | } 170 | 171 | if actorArea.covers then 172 | table.insert(areas, actorArea) 173 | end 174 | 175 | table.sort(areas, function(a, b) 176 | local apos = POS.position(a.covers[1]) 177 | local bpos = POS.position(b.covers[1]) 178 | 179 | return VEC.distance(apos, pos) < VEC.distance(bpos, pos) 180 | end) 181 | 182 | for i, area in ipairs(areas) do 183 | for i, vid in ipairs(TABLE.shuffle(area.covers)) do 184 | local cpos = POS.position(vid) 185 | local cvid = POS.bestInsideUnoccupiedLVID(npc, cpos, cpos, 3) 186 | if POS.isValidLVID(npc, cvid) then 187 | return cvid 188 | end 189 | end 190 | end 191 | 192 | return POS.INVALID_LVID 193 | end 194 | -- 195 | 196 | 197 | return SURGE 198 | -------------------------------------------------------------------------------- /gamedata/configs/ui/idiots_ui.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 9 | 2 10 | 3 11 | 4 12 | 5 13 | 6 14 | 7 15 | 8 16 | 9 17 | 10 18 | 19 | 20 | 21 | st_idiots_tab_all 22 | 1 23 | 2 24 | 3 25 | 4 26 | 5 27 | 6 28 | 7 29 | 8 30 | 9 31 | 10 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /gamedata/scripts/a_idiots_mdata.script: -------------------------------------------------------------------------------- 1 | local CONFIG = a_idiots_config 2 | local TABLE = require "illish.lib.table" 3 | local NPC = require "illish.lib.npc" 4 | local BEH = require "illish.lib.beh" 5 | 6 | 7 | -- reconfigure on MCM changes 8 | function reconfigureStates() 9 | -- legacy combat disabled 10 | local camperCombat = ui_mcm.get("idiots/options/camperCombat") 11 | local monolithCombat = ui_mcm.get("idiots/options/monolithCombat") 12 | local zombiedCombat = ui_mcm.get("idiots/options/zombiedCombat") 13 | 14 | local gstate = NPC.getActiveState(nil, "combat") 15 | 16 | if false 17 | or not camperCombat and gstate == "camper" 18 | or not monolithCombat and gstate == "monolith" 19 | or not zombiedCombat and gstate == "zombied" 20 | then 21 | NPC.setState(nil, "combat", "default", true) 22 | end 23 | 24 | for i, npc in ipairs(NPC.getCompanions()) do 25 | local state = NPC.getActiveState(npc, "combat") 26 | 27 | if false 28 | or not camperCombat and state == "camper" 29 | or not monolithCombat and state == "monolith" 30 | or not zombiedCombat and state == "zombied" 31 | then 32 | NPC.setState(npc, "combat", "default", true) 33 | end 34 | end 35 | 36 | -- artifact gathering disabled 37 | if not ui_mcm.get("idiots/options/artifacts") then 38 | NPC.setState(nil, "jobs", "loot_artifacts", false) 39 | 40 | for i, npc in ipairs(NPC.getCompanions()) do 41 | NPC.setState(npc, "jobs", "loot_artifacts", false) 42 | end 43 | end 44 | end 45 | 46 | 47 | -- prep storage 48 | function initStorage() 49 | local mdata = alife_storage_manager.get_state() 50 | 51 | if not mdata[CONFIG.DATA_KEY] then 52 | mdata[CONFIG.DATA_KEY] = {} 53 | end 54 | 55 | return mdata[CONFIG.DATA_KEY] 56 | end 57 | 58 | 59 | -- load data 60 | function onLoadState(mdata) 61 | local data = initStorage() 62 | 63 | -- purge old mod keys 64 | for i, k in ipairs(CONFIG.DATA_STALE_KEYS) do 65 | mdata[k] = nil 66 | end 67 | 68 | -- migrate old jobs keys 69 | if data.IDIOTS_SHARED_ITEMS then 70 | data.sharedItems = data.IDIOTS_SHARED_ITEMS 71 | end 72 | if data.IDIOTS_SHARING_NPCS then 73 | data.sharingNPCs = data.IDIOTS_SHARING_NPCS 74 | end 75 | 76 | data.IDIOTS_SHARED_ITEMS = nil 77 | data.IDIOTS_SHARING_NPCS = nil 78 | 79 | -- check version 80 | if data.VER ~= CONFIG.DATA_VER then 81 | data.VER = CONFIG.DATA_VER 82 | data.companionStates = nil 83 | data.globalState = nil 84 | data.beh = nil 85 | end 86 | 87 | -- load jobs data 88 | if data.sharedItems then 89 | NPC.LOOT_SHARED_ITEMS = data.sharedItems 90 | end 91 | if data.sharingNPCs then 92 | NPC.LOOT_SHARING_NPCS = data.sharingNPCs 93 | end 94 | 95 | -- load global state 96 | if data.globalState then 97 | NPC.GLOBAL_STATE = TABLE.merge(NPC.GLOBAL_STATE, data.globalState) 98 | else 99 | NPC.GLOBAL_STATE = dup_table(NPC.DEFAULT_STATE) 100 | end 101 | 102 | -- legacy combat disabled 103 | local camperCombat = ui_mcm.get("idiots/options/camperCombat") 104 | local monolithCombat = ui_mcm.get("idiots/options/monolithCombat") 105 | local zombiedCombat = ui_mcm.get("idiots/options/zombiedCombat") 106 | 107 | local gstate = NPC.getActiveState(nil, "combat") 108 | 109 | if false 110 | or not camperCombat and gstate == "camper" 111 | or not monolithCombat and gstate == "monolith" 112 | or not zombiedCombat and gstate == "zombied" 113 | then 114 | NPC.setState(nil, "combat", "default", true) 115 | end 116 | 117 | -- artifact gathering disabled 118 | if not ui_mcm.get("idiots/options/artifacts") then 119 | NPC.setState(nil, "jobs", "loot_artifacts", false) 120 | end 121 | end 122 | 123 | 124 | -- save data 125 | function onSaveState() 126 | local data = initStorage() 127 | 128 | data.globalState = NPC.GLOBAL_STATE 129 | data.sharedItems = NPC.LOOT_SHARED_ITEMS 130 | data.sharingNPCs = NPC.LOOT_SHARING_NPCS 131 | 132 | if not data.companionStates then 133 | data.companionStates = {} 134 | end 135 | 136 | if not data.beh then 137 | data.beh = {} 138 | end 139 | 140 | for i, npc in ipairs(NPC.getCompanions()) do 141 | data.companionStates[npc:id()] = NPC.getAllStates(npc) 142 | BEH.saveStorage(npc:id(), data.beh) 143 | end 144 | end 145 | 146 | 147 | -- load npc states 148 | function onNpcSpawn(npc) 149 | if not NPC.isCompanion(npc) then 150 | return 151 | end 152 | 153 | local data = initStorage() 154 | 155 | -- reset other flags 156 | save_var(npc, "fight_from_point", nil) 157 | 158 | -- load companion states 159 | if data.companionStates and data.companionStates[npc:id()] then 160 | NPC.setStates(npc, data.companionStates[npc:id()]) 161 | else 162 | NPC.setStates(npc, NPC.GLOBAL_STATE) 163 | end 164 | 165 | -- load beh storage 166 | BEH.loadStorage(npc:id(), data.beh) 167 | 168 | -- legacy combat disabled 169 | local camperCombat = ui_mcm.get("idiots/options/camperCombat") 170 | local monolithCombat = ui_mcm.get("idiots/options/monolithCombat") 171 | local zombiedCombat = ui_mcm.get("idiots/options/zombiedCombat") 172 | 173 | local state = NPC.getActiveState(npc, "combat") 174 | 175 | if false 176 | or not camperCombat and state == "camper" 177 | or not monolithCombat and state == "monolith" 178 | or not zombiedCombat and state == "zombied" 179 | then 180 | NPC.setState(npc, "combat", "default", true) 181 | end 182 | 183 | -- artifact gathering disabled 184 | if not ui_mcm.get("idiots/options/artifacts") then 185 | NPC.setState(npc, "jobs", "loot_artifacts", false) 186 | end 187 | 188 | -- (v1.5) restore original for backwards compatibility 189 | for i, npc in ipairs(NPC.getCompanions()) do 190 | if npc:has_info("npcx_beh_cover") then 191 | npc:disable_info_portion("npcx_beh_cover") 192 | npc:give_info_portion("npcx_beh_hide_in_cover") 193 | end 194 | end 195 | end 196 | 197 | 198 | -- manage saved data 199 | function on_game_start() 200 | RegisterScriptCallback("load_state", onLoadState) 201 | RegisterScriptCallback("save_state", onSaveState) 202 | RegisterScriptCallback("npc_on_net_spawn", onNpcSpawn) 203 | RegisterScriptCallback("mcm_option_change", reconfigureStates) 204 | end 205 | -------------------------------------------------------------------------------- /gamedata/scripts/idiots_surge.script: -------------------------------------------------------------------------------- 1 | local WP = world_property 2 | local SM = surge_manager 3 | local POS = require "illish.lib.pos" 4 | local NPC = require "illish.lib.npc" 5 | local COMBAT = require "illish.lib.combat" 6 | local SURGE = require "illish.lib.surge" 7 | 8 | EVAL_FACER = xrs_facer.evid_facer 9 | EVAL_STEAL_UP = xrs_facer.evid_steal_up_facer 10 | 11 | EVAL_ID = 188200 12 | ACTION_ID = 188210 13 | 14 | 15 | -- EVALUATOR -- 16 | class "evaluator_surge" (property_evaluator) 17 | 18 | 19 | function evaluator_surge:__init(name, storage) super (nil, name) 20 | self.st = storage 21 | end 22 | 23 | 24 | function evaluator_surge:evaluate() 25 | local npc = self.object 26 | local comm = npc:character_community() 27 | 28 | if not SURGE.isActive() then 29 | return false 30 | end 31 | 32 | if IsWounded(npc) then 33 | return false 34 | end 35 | 36 | if not NPC.isCompanion(npc) then 37 | return false 38 | end 39 | 40 | if not comm or comm == "zombied" or comm == "monolith" then 41 | return false 42 | end 43 | 44 | if get_object_story_id(npc:id()) then 45 | return false 46 | end 47 | 48 | return true 49 | end 50 | -- 51 | 52 | 53 | -- ACTION -- 54 | class "action_surge" (action_base) 55 | 56 | 57 | function action_surge:__init(name, storage) super(nil, name) 58 | self.st = storage 59 | end 60 | 61 | 62 | function action_surge:initialize() 63 | action_base.initialize(self) 64 | local npc = self.object 65 | 66 | self.st = { 67 | sm = SM.get_surge_manager(), 68 | reached = false, 69 | expires = nil, 70 | vid = nil, 71 | turn = nil, 72 | dist = nil, 73 | } 74 | end 75 | 76 | 77 | function action_surge:execute() 78 | action_base.execute(self) 79 | 80 | local st = self.st 81 | local npc = self.object 82 | local pos = npc:position() 83 | local safe = st.sm:pos_in_cover(pos) 84 | 85 | if time_expired(st.expires) then 86 | st.expires = nil 87 | st.vid = nil 88 | end 89 | 90 | if st.reached and not safe or st.safe and not safe then 91 | st.expires = nil 92 | st.vid = nil 93 | end 94 | 95 | if not st.vid and safe then 96 | st.vid = npc:level_vertex_id() 97 | st.expires = nil 98 | end 99 | 100 | if not st.vid then 101 | st.vid = SURGE.pickBestCover(npc) 102 | end 103 | 104 | if not POS.isValidLVID(npc, st.vid) then 105 | st.vid = npc:level_vertex_id() 106 | st.expires = time_plus(1000) 107 | end 108 | 109 | st.reached = st.vid == npc:level_vertex_id() 110 | st.dist = vec_dist(pos, lvpos(st.vid)) 111 | st.safe = safe 112 | 113 | POS.setLVID(npc, st.vid) 114 | 115 | local move = self:getMoveState() 116 | local look = self:getLookState() 117 | 118 | state_mgr.set_state(npc, move, nil, nil, look, {fast_set = true}) 119 | end 120 | 121 | 122 | function action_surge:getMoveState() 123 | local npc = self.object 124 | local st = self.st 125 | 126 | local states = { 127 | safe = { 128 | normal = "sneak", 129 | combat = "sneak_fire", 130 | }, 131 | near = { 132 | normal = "patrol", 133 | combat = "raid_fire", 134 | }, 135 | normal = { 136 | normal = "rush", 137 | combat = "assault_fire", 138 | }, 139 | far = { 140 | normal = "panic", 141 | combat = "panic", 142 | } 143 | } 144 | 145 | local distKey = st.reached and "safe" 146 | or st.dist < 3 and "near" 147 | or st.dist < 6 and "normal" 148 | or "far" 149 | 150 | local be = npc:best_enemy() 151 | 152 | local fireKey = be and (npc:see(be) or COMBAT.hasLineOfSight(npc, be)) 153 | and "combat" 154 | or "normal" 155 | 156 | return states[distKey][fireKey] 157 | end 158 | 159 | 160 | function action_surge:getLookState() 161 | local st = self.st 162 | local npc = self.object 163 | local dir = npc:direction() 164 | 165 | if st.dist < 1 then 166 | st.turn = st.turn or vec(dir):invert() 167 | end 168 | 169 | local be = npc:best_enemy() 170 | local bd = npc:best_danger() 171 | 172 | if st.dist >= 6 then 173 | return nil 174 | end 175 | 176 | if be and (npc:see(be) or COMBAT.hasLineOfSight(npc, be)) then 177 | return {look_object = be:id()} 178 | end 179 | 180 | if bd then 181 | return {look_dir = vec_dir(npc:position(), bd:position())} 182 | end 183 | 184 | return {look_dir = st.turn} 185 | end 186 | -- 187 | 188 | 189 | -- SETUP -- 190 | function setup_generic_scheme(npc, ini, scheme, section, stype, temp) 191 | xr_logic.assign_storage_and_bind(npc, ini, "idiots_surge", section, temp) 192 | end 193 | 194 | 195 | function add_to_binder(npc, ini, scheme, section, storage, temp) 196 | local manager = npc:motivation_action_manager() 197 | manager:add_evaluator(EVAL_ID, evaluator_surge("idiots_surge", storage)) 198 | 199 | temp.action = action_surge("idiots_surge", storage) 200 | 201 | if temp.action then 202 | temp.action:add_precondition(WP(stalker_ids.property_alive, true)) 203 | temp.action:add_precondition(WP(xr_evaluators_id.sidor_wounded_base, false)) 204 | temp.action:add_precondition(WP(EVAL_ID, true)) 205 | temp.action:add_precondition(WP(EVAL_FACER, false)) 206 | temp.action:add_precondition(WP(EVAL_STEAL_UP, false)) 207 | 208 | temp.action:add_effect(WP(EVAL_ID, false)) 209 | 210 | manager:add_action(ACTION_ID, temp.action) 211 | end 212 | end 213 | 214 | 215 | function configure_actions(npc, ini, scheme, section, stype, temp) 216 | local manager = npc:motivation_action_manager() 217 | 218 | local otherActions = { 219 | xr_danger.actid, 220 | stalker_ids.action_combat_planner, 221 | stalker_ids.action_danger_planner, 222 | xr_actions_id.state_mgr + 2, 223 | xr_actions_id.alife, 224 | } 225 | 226 | for i, id in ipairs(otherActions) do 227 | local action = manager:action(id) 228 | 229 | if action then 230 | action:add_precondition(WP(EVAL_ID, false)) 231 | end 232 | end 233 | end 234 | 235 | 236 | function disable_generic_scheme(npc, scheme, stype) 237 | local st = db.storage[npc:id()][scheme] 238 | if st then 239 | st.enabled = false 240 | end 241 | end 242 | 243 | 244 | function npc_add_precondition(action) 245 | action:add_precondition(world_property(EVAL_ID, false)) 246 | end 247 | 248 | 249 | LoadScheme("idiots_surge", "idiots_surge", modules.stype_stalker) 250 | -- 251 | -------------------------------------------------------------------------------- /gamedata/configs/text/eng/idiots.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | All 6 | 7 | 8 | All Companions 9 | 10 | 11 | 12 | 13 | Moving %s companion(s) to point. 14 | 15 | 16 | Moving all companions to point. 17 | 18 | 19 | Moving %s companion(s) out of the way. 20 | 21 | 22 | Waypoint added. 23 | 24 | 25 | You must select exactly 1 companion to add or remove waypoints. 26 | 27 | 28 | All waypoints cleared. 29 | 30 | 31 | Synced %s companion(s) with global state. 32 | 33 | 34 | Synced all companions with global state. 35 | 36 | 37 | Selected companion #%s. %s companion(s) selected. 38 | 39 | 40 | Deselected companion #%s. %s companion(s) selected. 41 | 42 | 43 | All companions deselected. 44 | 45 | 46 | Forcing %s companion(s) to retreat. 47 | 48 | 49 | Forcing all companions to retreat. 50 | 51 | 52 | Enabled 53 | 54 | 55 | Disabled 56 | 57 | 58 | 59 | 60 | Sync All 61 | 62 | 63 | Re-Sync 64 | 65 | 66 | Reload 67 | 68 | 69 | Unstick 70 | 71 | 72 | Inventory 73 | 74 | 75 | Clear Waypoints 76 | 77 | 78 | Add Waypoint 79 | 80 | 81 | Quick Retreat 82 | 83 | 84 | 85 | 86 | Movement 87 | 88 | 89 | Follow 90 | 91 | 92 | Wait 93 | 94 | 95 | Find Cover 96 | 97 | 98 | Relax 99 | 100 | 101 | Patrol 102 | 103 | 104 | 105 | 106 | Default Light 107 | 108 | 109 | Light Off 110 | 111 | 112 | Light On 113 | 114 | 115 | 116 | 117 | Hurry Up 118 | 119 | 120 | 121 | 122 | Stance 123 | 124 | 125 | Stand 126 | 127 | 128 | Sneak 129 | 130 | 131 | Prone 132 | 133 | 134 | 135 | 136 | Distance 137 | 138 | 139 | Stay Near 140 | 141 | 142 | Normal 143 | 144 | 145 | Stay Far 146 | 147 | 148 | 149 | 150 | Combat 151 | 152 | 153 | Default Combat 154 | 155 | 156 | Assault Combat 157 | 158 | 159 | Support Combat 160 | 161 | 162 | Guard Combat 163 | 164 | 165 | Sniper Combat 166 | 167 | 168 | Monolith Combat 169 | 170 | 171 | Camper Combat 172 | 173 | 174 | Zombied Combat 175 | 176 | 177 | 178 | 179 | Weapon 180 | 181 | 182 | Best 183 | 184 | 185 | Pistol 186 | 187 | 188 | Shotgun 189 | 190 | 191 | SMG 192 | 193 | 194 | Rifle 195 | 196 | 197 | Sniper 198 | 199 | 200 | 201 | 202 | Readiness 203 | 204 | 205 | Attack Enemies 206 | 207 | 208 | Defend Only 209 | 210 | 211 | Ignore Combat 212 | 213 | 214 | 215 | 216 | Jobs 217 | 218 | 219 | Gather Items 220 | 221 | 222 | And Artifacts 223 | 224 | 225 | Loot Corpses 226 | 227 | 228 | Help Wounded 229 | 230 | 231 | 232 | 233 | Formation 234 | 235 | 236 | Bunch Formation 237 | 238 | 239 | Spread Formation 240 | 241 | 242 | Line Formation 243 | 244 | 245 | Covered Formation 246 | 247 | 248 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/dxml.lua: -------------------------------------------------------------------------------- 1 | local TABLE = require "illish.lib.table" 2 | 3 | 4 | local NO_EXTEND = { 5 | extendXMLObject = true, 6 | mergeBounds = true, 7 | } 8 | 9 | local DXML = {} 10 | 11 | 12 | -- utils -- 13 | function DXML.isValid(XML, el) 14 | return el and el.el and XML:isElement(el) 15 | end 16 | 17 | 18 | function DXML.queryOne(XML, query, where) 19 | local results = XML:query(query, where) 20 | 21 | if results and DXML.isValid(XML, results[1]) then 22 | return results[1] 23 | end 24 | end 25 | 26 | 27 | function DXML.iterateElements(XML, el, cb) 28 | local results 29 | 30 | XML:iterateChildren(el, function(child, index) 31 | if not DXML.isValid(XML, child) then 32 | return 33 | end 34 | 35 | local result = cb(child, index) 36 | 37 | if result then 38 | if not results then 39 | results = {} 40 | end 41 | 42 | table.insert(results, result) 43 | end 44 | end) 45 | 46 | return results 47 | end 48 | 49 | 50 | function DXML.fixAspectRatio(XML, el) 51 | local ratio = 1024 / 768 * device().height / device().width 52 | 53 | local attrs = XML:getElementAttr(el) 54 | 55 | if not (attrs.x or tonumber(attrs.width)) then 56 | return 57 | end 58 | 59 | local width = tonumber(attrs.width) and attrs.width * ratio 60 | local x = attrs.x 61 | 62 | local xalign = el.parent and XML:getElementAttr(el.parent).xalign 63 | local parent = el.parent and XML:getElementName(el.parent) 64 | 65 | if parent == "w" and xalign == "c" and x and width then 66 | x = x + (attrs.width - width) / 2 67 | elseif parent == "w" and xalign == "r" and x and width then 68 | x = x + (attrs.width - width) 69 | elseif x and width then 70 | x = x * ratio 71 | end 72 | 73 | XML:setElementAttr(el, {x = x, width = width}) 74 | end 75 | 76 | 77 | function DXML.scaleElement(XML, el, xscale, yscale) 78 | xscale = (xscale or 1) * math.min(1080 / device().height, 1) 79 | yscale = (yscale or 1) * math.min(1080 / device().height, 1) 80 | 81 | local attrs = XML:getElementAttr(el) 82 | 83 | if not (attrs.x or attrs.y or attrs.width or attrs.height) then 84 | return 85 | end 86 | 87 | local width = tonumber(attrs.width) and attrs.width * xscale 88 | local height = tonumber(attrs.height) and attrs.height * yscale 89 | local x, y = attrs.x, attrs.y 90 | 91 | local xalign = el.parent and XML:getElementAttr(el.parent).xalign 92 | local yalign = el.parent and XML:getElementAttr(el.parent).yalign 93 | local parent = el.parent and XML:getElementName(el.parent) 94 | 95 | if parent == "w" and xalign == "c" and x and width then 96 | x = x + (attrs.width - width) / 2 97 | elseif parent == "w" and xalign == "r" and x and width then 98 | x = x + (attrs.width - width) 99 | elseif x and width then 100 | x = x * xscale 101 | end 102 | 103 | if parent == "w" and yalign == "c" and y and height then 104 | y = y + (attrs.height - height) / 2 105 | elseif parent == "w" and yalign == "b" and y and height then 106 | y = y + (attrs.height - height) 107 | elseif y and height then 108 | y = y * yscale 109 | end 110 | 111 | XML:setElementAttr(el, {width = width, height = height, x = x, y = y}) 112 | end 113 | 114 | 115 | function DXML.fixAndScaleAll(XML, el, xscale, yscale) 116 | DXML.iterateElements(XML, el, function(child) 117 | DXML.fixAndScaleAll(XML, child, xscale, yscale) 118 | end) 119 | 120 | if XML:getRoot() ~= el then 121 | if DXML.getNearestAttr(XML, el, "compact") == "1" then 122 | xscale = math.min(xscale, yscale) 123 | yscale = xscale 124 | end 125 | 126 | DXML.fixAspectRatio(XML, el) 127 | DXML.scaleElement(XML, el, xscale, yscale) 128 | end 129 | end 130 | 131 | 132 | function DXML.mergeBounds(...) 133 | local bounds 134 | 135 | for i, b in ipairs({...}) do 136 | if not bounds then 137 | bounds = dup_table(b) 138 | else 139 | bounds = { 140 | l = math.min(bounds.l, b.l), 141 | t = math.min(bounds.t, b.t), 142 | r = math.max(bounds.r, b.r), 143 | b = math.max(bounds.b, b.b), 144 | } 145 | end 146 | end 147 | 148 | bounds.w = bounds.r - bounds.l 149 | bounds.h = bounds.b - bounds.t 150 | 151 | return bounds 152 | end 153 | 154 | 155 | function DXML.getElementBounds(XML, el) 156 | local attrs = XML:getElementAttr(el) 157 | 158 | local x, y, w, h = 159 | attrs.x or 0, 160 | attrs.y or 0, 161 | tonumber(attrs.width) or 0, 162 | tonumber(attrs.height) or 0 163 | 164 | return { 165 | l = x, t = y, 166 | w = w, h = h, 167 | r = x + w, 168 | b = y + h, 169 | } 170 | end 171 | 172 | 173 | function DXML.getChildrenBounds(XML, el) 174 | local bounds 175 | 176 | DXML.iterateElements(XML, el, function(child) 177 | local b = DXML.getElementBounds(XML, child) 178 | 179 | if not bounds then 180 | bounds = b 181 | else 182 | bounds = DXML.mergeBounds(bounds, b) 183 | end 184 | end) 185 | 186 | return bounds 187 | end 188 | 189 | 190 | function DXML.getNearestAttr(XML, el, attr, default) 191 | while DXML.isValid(XML, el) do 192 | if el.el == "!doc" then 193 | break 194 | end 195 | 196 | local value = XML:getElementAttr(el)[attr] 197 | if value ~= nil then 198 | return value 199 | end 200 | 201 | el = el.parent 202 | end 203 | 204 | return default 205 | end 206 | 207 | 208 | function DXML.renameElement(XML, el, name) 209 | local renamed = XML:convertElement({ 210 | attr = XML:getElementAttr(el), 211 | name = name, 212 | }) 213 | 214 | el.el = renamed.el 215 | end 216 | 217 | 218 | function DXML.wrapChildren(XML, el, wrapper) 219 | wrapper.parent = el 220 | wrapper.kids = el.kids 221 | 222 | el.kids = {wrapper} 223 | 224 | for i, kid in ipairs(wrapper.kids) do 225 | kid.parent = wrapper 226 | end 227 | end 228 | 229 | 230 | function DXML.wrapElement(XML, el, wrapper) 231 | local index = XML:getElementPosition(el) 232 | local parent = el.parent 233 | local kids = el.kids 234 | 235 | parent.kids[index] = wrapper 236 | 237 | wrapper.parent = parent 238 | wrapper.kids = {el} 239 | 240 | el.parent = wrapper 241 | end 242 | 243 | 244 | function DXML.unwrapElement(XML, el) 245 | if not (el.kids and #el.kids > 0) then 246 | XML:removeElement(el) 247 | return 248 | end 249 | 250 | local index = XML:getElementPosition(el) 251 | local before = {} 252 | local after = {} 253 | 254 | if not index then 255 | return 256 | end 257 | 258 | if index > 1 then 259 | before = {unpack(el.parent.kids, 1, index - 1)} 260 | end 261 | if index < #el.parent.kids then 262 | after = {unpack(el.parent.kids, index + 1, #el.parent.kids)} 263 | end 264 | 265 | el.parent.kids = TABLE.imerge(before, el.kids, after) 266 | 267 | for i, kid in ipairs(el.parent.kids) do 268 | kid.parent = el.parent 269 | end 270 | end 271 | 272 | 273 | function DXML.extendXMLObject(XML) 274 | if XML.extended then 275 | return 276 | end 277 | 278 | XML.extended = true 279 | 280 | for name, fn in pairs(DXML) do 281 | if not NO_EXTEND[name] then 282 | XML[name] = fn 283 | end 284 | end 285 | 286 | -- bug fix 287 | local insert = XML.insertElement 288 | 289 | function XML:insertElement(args, where, pos) 290 | local el, pos = insert(self, args, where, pos) 291 | el.parent = where 292 | return el, pos 293 | end 294 | end 295 | -- 296 | 297 | 298 | return DXML 299 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_combat.lua: -------------------------------------------------------------------------------- 1 | local TABLE = require "illish.lib.table" 2 | local NPC = require "illish.lib.npc" 3 | local WPN = require "illish.lib.weapon" 4 | local COMBAT = require "illish.lib.combat" 5 | local SURGE = require "illish.lib.surge" 6 | 7 | 8 | local PATCH = {} 9 | 10 | -- Custom combat types to inject 11 | PATCH.COMBAT_MODES = { 12 | idiots_combat_assault, 13 | idiots_combat_support, 14 | idiots_combat_snipe, 15 | idiots_combat_guard, 16 | } 17 | 18 | 19 | -- Force combat type to update to fix an issue where companions can get stuck 20 | -- in legacy scripted combat (because the original was probably not written to 21 | -- work with condlists) 22 | local PATCH_evaluate = xr_combat.evaluator_check_combat.evaluate 23 | 24 | function xr_combat.evaluator_check_combat:evaluate() 25 | xr_combat.set_combat_type(self.object, db.actor, self.st) 26 | 27 | return PATCH_evaluate(self) 28 | end 29 | 30 | 31 | -- Inject custom combat types 32 | local PATCH_add_to_binder = xr_combat.add_to_binder 33 | 34 | function xr_combat.add_to_binder(npc, ini, scheme, section, storage, temp) 35 | PATCH_add_to_binder(npc, ini, scheme, section, storage, temp) 36 | 37 | local manager = npc:motivation_action_manager() 38 | 39 | if manager and temp.section then 40 | for i, scheme in ipairs(PATCH.COMBAT_MODES) do 41 | if scheme and scheme.add_to_binder then 42 | scheme.add_to_binder(npc, ini, storage, manager, temp) 43 | end 44 | end 45 | end 46 | end 47 | 48 | 49 | -- Patch zombied combat type to allow companions to use it 50 | local PATCH_zombied_evaluate = xr_combat_zombied.evaluator_combat_zombied.evaluate 51 | 52 | function xr_combat_zombied.evaluator_combat_zombied:evaluate() 53 | local enabled = ui_mcm.get("idiots/options/zombiedCombat") 54 | local npc = self.object 55 | 56 | if enabled and NPC.isCompanion(npc) and NPC.getState(npc, "combat", "zombied") then 57 | return true 58 | end 59 | 60 | return PATCH_zombied_evaluate(self) 61 | end 62 | 63 | 64 | -- Override weapon selection for companions 65 | -- NOTE: don't use CWeapon consts because lua_help is wrong 66 | function PATCH.onChooseWeapon(npc, wpn, flags) 67 | local item = npc:active_item() 68 | 69 | -- Do nothing if inventory is open 70 | if NPC.isInventoryOpen(npc) then 71 | if WPN.isGun(item) then 72 | flags.gun_id = item:id() 73 | if item:get_state() == 7 then 74 | item:switch_state(0) 75 | end 76 | end 77 | return 78 | end 79 | 80 | -- Fix reload animation for everyone 81 | if WPN.isGun(item) and item:get_state() == 7 then 82 | local ammo = WPN.getAmmoCount(item) 83 | if ammo.current == ammo.total then 84 | item:switch_state(0) 85 | end 86 | end 87 | 88 | -- The rest is only for companions 89 | if not NPC.isCompanion(npc) then 90 | return 91 | end 92 | 93 | local preferred = NPC.getActiveState(npc, "weapon") 94 | local reload = NPC.getReloadModes(npc) 95 | local weapons = NPC.getGuns(npc) 96 | 97 | if WPN.isGun(item) then 98 | local ammo = WPN.getAmmoCount(item) 99 | 100 | -- don't switch weapon if reloading 101 | if item:get_state() == 7 then 102 | if ammo.current < ammo.total then 103 | flags.gun_id = item:id() 104 | return 105 | end 106 | -- reload if needed 107 | elseif 108 | reload.emode == WPN.EMPTY and ammo.current == 0 109 | or reload.emode == WPN.HALF_EMPTY and ammo.current < ammo.total / 2 110 | or reload.emode == WPN.NOT_FULL and ammo.current < ammo.total 111 | then 112 | flags.gun_id = item:id() 113 | item:switch_state(7) 114 | return 115 | end 116 | end 117 | 118 | -- Sort weapons in inventory 119 | table.sort(weapons, function(w1, w2) 120 | -- Selected in UI 121 | local t1 = WPN.getType(w1) 122 | local t2 = WPN.getType(w2) 123 | 124 | if t1 ~= t2 and t1 == preferred then 125 | return true 126 | elseif t1 ~= t2 and t2 == preferred then 127 | return false 128 | end 129 | 130 | -- Repair kit type 131 | local kits = {"pistol", "shotgun", "rifle_5", "rifle_7"} 132 | local p1 = TABLE.keyof(kits, WPN.getRepairType(w1)) or 5 133 | local p2 = TABLE.keyof(kits, WPN.getRepairType(w2)) or 5 134 | 135 | if p1 > p2 then 136 | return true 137 | elseif p1 < p2 then 138 | return false 139 | end 140 | 141 | -- Cost 142 | return WPN.getCost(w2) < WPN.getCost(w1) 143 | end) 144 | 145 | -- Switch to next unloaded weapon if reloading all 146 | if reload.wmode == WPN.RELOAD_ALL then 147 | for i, weapon in ipairs(weapons) do 148 | local ammo = WPN.getAmmoCount(weapon) 149 | 150 | if false 151 | or reload.emode == WPN.EMPTY and ammo.current == 0 152 | or reload.emode == WPN.HALF_EMPTY and ammo.current < ammo.total / 2 153 | or reload.emode == WPN.NOT_FULL and ammo.current < ammo.total 154 | then 155 | flags.gun_id = weapon:id() 156 | if weapon:get_state() == 0 then 157 | weapon:switch_state(7) 158 | end 159 | return 160 | end 161 | end 162 | end 163 | 164 | -- Nothing left to reload 165 | NPC.setReloadModes(npc, WPN.RELOAD_ACTIVE, WPN.EMPTY) 166 | 167 | -- Don't force if there's an active item or no weapons 168 | if item or not weapons[1] then 169 | NPC.setForcingWeapon(npc, false) 170 | -- Force if no active item 171 | elseif not NPC.getForcingWeapon(npc) then 172 | NPC.setForcingWeapon(npc, true) 173 | NPC.forceWeapon(npc) 174 | end 175 | 176 | if weapons[1] then 177 | flags.gun_id = weapons[1]:id() 178 | end 179 | end 180 | 181 | 182 | -- Prevent error from companion_anti_awol accessing this before it's defined 183 | if companion_anti_awol then 184 | companion_anti_awol.companion_retreat = {} 185 | end 186 | 187 | 188 | -- Disable "cover" because it is not a valid scripted combat scheme 189 | if schemes_ai_gamma and schemes_ai_gamma.scheme_cover then 190 | function schemes_ai_gamma.scheme_cover() 191 | return false 192 | end 193 | end 194 | 195 | 196 | -- Track companion ammo while inventory is open 197 | PATCH.AMMO_COUNTS = {} 198 | 199 | 200 | -- Save companion ammo counts and unload weapons before opening inventory 201 | function PATCH.onShowGUI(name) 202 | if name ~= "UIInventory" or not ui_inventory.GUI then 203 | return 204 | end 205 | 206 | local npc = ui_inventory.GUI:GetPartner() 207 | if not NPC.isCompanion(npc) then 208 | return 209 | end 210 | 211 | npc:iterate_inventory(function(npc, item) 212 | if item and IsWeapon(item) then 213 | PATCH.AMMO_COUNTS[item:id()] = item:get_ammo_in_magazine() 214 | item:set_ammo_elapsed(0) 215 | end 216 | end, npc) 217 | end 218 | 219 | 220 | -- Restore companion ammo counts after closing inventory 221 | function PATCH.onHideGUI(name) 222 | if name ~= "UIInventory" or not ui_inventory.GUI then 223 | return 224 | end 225 | 226 | local npc = ui_inventory.GUI:GetPartner() 227 | if not NPC.isCompanion(npc) then 228 | return 229 | end 230 | 231 | npc:iterate_inventory(function(npc, item) 232 | if item and IsWeapon(item) then 233 | local ammoCount = PATCH.AMMO_COUNTS[item:id()] 234 | 235 | if ammoCount then 236 | item:set_ammo_elapsed(ammoCount) 237 | end 238 | 239 | PATCH.AMMO_COUNTS[item:id()] = nil 240 | end 241 | end, npc) 242 | end 243 | 244 | 245 | -- Disable GAMMA's version of the above 246 | if grok_companions_no_ammo then 247 | grok_companions_no_ammo.unload_ammo = function() end 248 | end 249 | 250 | 251 | -- Callbacks 252 | RegisterScriptCallback("idiots_on_start", function() 253 | RegisterScriptCallback("npc_on_hit_callback", COMBAT.combatHitCallback) 254 | RegisterScriptCallback("npc_on_hear_callback", COMBAT.combatHearCallback) 255 | RegisterScriptCallback("npc_on_choose_weapon", PATCH.onChooseWeapon) 256 | RegisterScriptCallback("GUI_on_show", PATCH.onShowGUI) 257 | RegisterScriptCallback("GUI_on_hide", PATCH.onHideGUI) 258 | end) 259 | 260 | 261 | return PATCH 262 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/axr_companions.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | local PATCH = {} 5 | 6 | 7 | -- Split each companion into their own squad for better control 8 | function PATCH.splitCompanionSquads() 9 | if not ui_mcm.get("idiots/options/splitSquads") then 10 | return 11 | end 12 | 13 | for id in pairs(axr_companions.non_task_companions) do 14 | local se = alife():object(id) 15 | local squad = get_object_squad(se) 16 | 17 | if not se or not squad then 18 | axr_companions.non_task_companions[id] = nil 19 | 20 | -- skip guide task companions too 21 | elseif tasks_guide.curr_guid.squad_id ~= squad.id then 22 | for member in squad:squad_members() do 23 | if squad:npc_count() > 1 then 24 | local newSquad = NPC.createOwnSquad(member.id) 25 | if newSquad then 26 | axr_companions.companion_squads[newSquad.id] = newSquad 27 | SIMBOARD:setup_squad_and_group(se) 28 | newSquad:set_squad_relation() 29 | newSquad:refresh() 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | 37 | 38 | -- Track when a companion is hit for "defend only" 39 | function PATCH.onHitCompanion(npc, amount, local_direction, who, bone_index) 40 | if NPC.isCompanion(npc) and amount > 0 then 41 | db.storage[0].companion_hit_by = who:id() 42 | end 43 | end 44 | 45 | 46 | -- Split each companion into own squad when joining 47 | local PATCH_become_actor_companion = dialogs_axr_companion.become_actor_companion 48 | 49 | function dialogs_axr_companion.become_actor_companion(actor, npc) 50 | PATCH_become_actor_companion(actor, npc) 51 | PATCH.splitCompanionSquads() 52 | end 53 | 54 | 55 | -- Split each companion into own squad when warfare stuff happens 56 | local PATCH_add_companion_squad = sim_squad_warfare.add_companion_squad 57 | 58 | function sim_squad_warfare.add_companion_squad(squad) 59 | PATCH_add_companion_squad(squad) 60 | PATCH.splitCompanionSquads() 61 | end 62 | 63 | 64 | -- Overwrite to sync with global state 65 | function axr_companions.add_to_actor_squad(npc) 66 | axr_companions.non_task_companions[npc:id()] = true 67 | se_save_var(npc:id(), npc:name(), "companion", true) 68 | npc:inactualize_patrol_path() 69 | 70 | axr_companions.setup_companion_logic(npc, db.storage[npc:id()], false) 71 | 72 | -- Reset vanilla flags that might interfere 73 | save_var(npc, "fight_from_point", nil) 74 | 75 | -- Sync with global state 76 | NPC.setStates(npc, NPC.GLOBAL_STATE) 77 | end 78 | 79 | 80 | -- Overwrite to prevent teleporting for additional behaviors 81 | function axr_companions.companion_squad_can_teleport(squad) 82 | local sim = alife() 83 | local id = squad:commander_id() 84 | local se = sim:object(id) 85 | 86 | if se and se_load_var(se.id, se:name(), "companion_cannot_teleport") then 87 | return false 88 | end 89 | 90 | for ig, group in ipairs(NPC.ACTIONS) do 91 | for ia, action in ipairs(group.actions) do 92 | if action.teleport == false and sim:has_info(id, action.info) then 93 | return false 94 | end 95 | end 96 | end 97 | 98 | return true 99 | end 100 | 101 | 102 | -- Disable vanilla "move to point" keybind 103 | function axr_companions.move_to_point() 104 | end 105 | 106 | 107 | -- Show/hide all inventory items or just looted/gathered items 108 | local PATCH_is_assigned_item = axr_companions.is_assigned_item 109 | 110 | function axr_companions.is_assigned_item(npcID, itemID) 111 | local showAll = ui_mcm.get("idiots/options/showAllItems") 112 | 113 | if NPC.isCompanion(npcID) and (showAll or NPC.LOOT_SHARED_ITEMS[itemID]) then 114 | return true 115 | end 116 | 117 | return PATCH_is_assigned_item(npcID, itemID) 118 | end 119 | 120 | 121 | -- Call old functions in axr_companions for mod compatibility 122 | function PATCH.callLegacyStateSetters(id, group, action, enabled) 123 | local npc = id and NPC.getCompanion(id) 124 | if not npc then 125 | return 126 | end 127 | 128 | if group == "jobs" and action == "loot_corpses" then 129 | local lootingItems = NPC.getState(id, "jobs", "loot_items") 130 | if enabled and lootingItems then 131 | axr_companions.set_companion_to_loot_items_and_corpses(npc) 132 | elseif enabled then 133 | axr_companions.set_companion_to_loot_corpses_only(npc) 134 | elseif lootingItems then 135 | axr_companions.set_companion_to_loot_items_only(npc) 136 | else 137 | axr_companions.set_companion_to_loot_nothing(npc) 138 | end 139 | end 140 | 141 | if group == "jobs" and action == "loot_items" then 142 | local lootingCorpses = NPC.getState(id, "jobs", "loot_corpses") 143 | if enabled and lootingCorpses then 144 | axr_companions.set_companion_to_loot_items_and_corpses(npc) 145 | elseif enabled then 146 | axr_companions.set_companion_to_loot_items_only(npc) 147 | elseif lootingCorpses then 148 | axr_companions.set_companion_to_loot_corpses_only(npc) 149 | else 150 | axr_companions.set_companion_to_loot_nothing(npc) 151 | end 152 | end 153 | 154 | if not enabled then 155 | return 156 | end 157 | 158 | if group == "movement" and action == "follow" then 159 | axr_companions.set_companion_to_follow_state(npc) 160 | 161 | elseif group == "movement" and action == "wait" then 162 | axr_companions.set_companion_to_wait_state(npc) 163 | save_var(npc, "fight_from_point", nil) 164 | 165 | elseif group == "movement" and action == "cover" then 166 | axr_companions.set_companion_hide_in_cover(npc) 167 | 168 | elseif group == "movement" and action == "relax" then 169 | axr_companions.set_companion_to_relax_substate(npc) 170 | 171 | elseif group == "movement" and action == "patrol" then 172 | axr_companions.set_companion_to_patrol_state(npc) 173 | 174 | elseif group == "stance" and action == "stand" then 175 | local relaxing = NPC.getState(id, "movement", "relax") 176 | axr_companions.set_companion_to_default_substate(npc) 177 | if relaxing then 178 | npc:give_info_portion("npcx_beh_substate_relax") 179 | end 180 | 181 | elseif group == "stance" and action == "sneak" then 182 | axr_companions.set_companion_to_stealth_substate(npc) 183 | 184 | elseif group == "distance" and action == "near" then 185 | axr_companions.set_companion_to_stay_close(npc) 186 | 187 | elseif group == "distance" and action == "normal" then 188 | axr_companions.set_companion_to_stay_close(npc) 189 | 190 | elseif group == "distance" and action == "far" then 191 | axr_companions.set_companion_to_stay_far(npc) 192 | 193 | elseif group == "readiness" and action == "attack" then 194 | axr_companions.set_companion_to_attack_state(npc) 195 | 196 | elseif group == "readiness" and action == "defend" then 197 | axr_companions.set_companion_to_attack_only_actor_combat_enemy_state(npc) 198 | npc:disable_info_portion("npcx_beh_ignore_combat") 199 | 200 | elseif group == "readiness" and action == "ignore" then 201 | axr_companions.set_companion_to_ignore_combat_state(npc) 202 | npc:disable_info_portion("npcx_beh_ignore_actor_enemies") 203 | end 204 | end 205 | 206 | 207 | -- Prevent sleeping from teleporting offline companions 208 | local PATCH_get_script_target = sim_squad_scripted.sim_squad_scripted.get_script_target 209 | 210 | function sim_squad_scripted.sim_squad_scripted:get_script_target() 211 | local target = PATCH_get_script_target(self) 212 | 213 | if axr_companions.companion_squads[self.id] and target == 0 and self.online ~= true then 214 | return self.id 215 | end 216 | 217 | return target 218 | end 219 | 220 | 221 | RegisterScriptCallback("idiots_on_start", function() 222 | RegisterScriptCallback("npc_on_hit_callback", PATCH.onHitCompanion) 223 | RegisterScriptCallback("actor_on_first_update", PATCH.splitCompanionSquads) 224 | RegisterScriptCallback("idiots_on_state_change", PATCH.callLegacyStateSetters) 225 | 226 | -- Disable vanilla companion wheel 227 | UnregisterScriptCallback("on_key_release", axr_companions.on_key_release) 228 | end) 229 | 230 | 231 | return PATCH 232 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/patches/xr_combat_ignore.lua: -------------------------------------------------------------------------------- 1 | local NPC = require "illish.lib.npc" 2 | 3 | 4 | local PATCH = {} 5 | PATCH.CONFIG = {} 6 | 7 | 8 | -- Overwrite with new implementation 9 | function xr_combat_ignore.is_enemy(npc, enemy, noMemory) 10 | if device().precache_frame > 1 then 11 | return false 12 | end 13 | 14 | if not npc:alive() or not enemy:alive() then 15 | return false 16 | end 17 | 18 | if npc:clsid() == clsid.crow or enemy:clsid() == clsid.crow then 19 | return false 20 | end 21 | 22 | if DEV_DEBUG and xrs_debug_tools.debug_invis and enemy:id() == 0 then 23 | return false 24 | end 25 | 26 | -- callback override 27 | local flags = { 28 | override = false, 29 | result = false 30 | } 31 | 32 | SendScriptCallback("on_enemy_eval", npc, enemy, flags) 33 | 34 | if flags.override then 35 | return flags.result 36 | end 37 | 38 | local pos1 = npc:position() 39 | local pos2 = enemy:position() 40 | local dist = pos2:distance_to(pos1) 41 | local fac1 = character_community(npc) 42 | local fac2 = character_community(enemy) 43 | local cfg = PATCH.getCombatIgnoreConfig(enemy, npc) 44 | 45 | -- ignore bribes 46 | if enemy:id() == 0 and xr_bribe.at_peace(fac1, fac2, dist * dist) then 47 | return false 48 | end 49 | 50 | -- ignore stale enemies 51 | if IsStalker(npc) and not noMemory then 52 | -- time-based 53 | if enemy:id() == 0 and time_global() > npc:memory_time(enemy) + cfg.memoryTime then 54 | return false 55 | end 56 | -- distance-based 57 | if dist > cfg.memoryDistance and not enemy:see(npc) then 58 | return false 59 | end 60 | end 61 | 62 | -- respect combat_ignore_keep_when_attacked 63 | if enemy:id() == 0 and load_var(npc, "xr_combat_ignore_enabled") == false then 64 | db.storage[npc:id()].enemy_id = enemy:id() 65 | return true 66 | end 67 | 68 | -- ignore hostages 69 | if axr_task_manager.hostages_by_id[enemy:id()] then 70 | return false 71 | end 72 | 73 | -- ignore enemies when npc has far surge job 74 | if xr_conditions.surge_started() and cfg.enemyRangeSurge then 75 | local smart = xr_gulag.get_npc_smart(npc) 76 | local task = smart 77 | and smart.npc_info 78 | and smart.npc_info[npc:id()] 79 | and smart.npc_info[npc:id()].job 80 | and smart.npc_info[npc:id()].job.job_type_id == 2 81 | and smart.npc_info[npc:id()].job.alife_task 82 | 83 | if task and pos2:distance_to(task:position()) > cfg.enemyRangeSurge then 84 | return false 85 | end 86 | end 87 | 88 | -- ignore enemies in safe zones 89 | if enemy:id() ~= 0 and IsStalker(npc) and fac2 ~= "zombied" then 90 | local safeTimes = xr_combat_ignore.safe_zone_npcs 91 | local ignoredZones = xr_combat_ignore.ignored_zone 92 | 93 | local se = alife():object(npc:id()) 94 | local id = se and se.group_id ~= 65535 and se.group_id or npc:id() 95 | 96 | if safeTimes[id] then 97 | db.storage[npc:id()].heli_enemy_flag = nil 98 | 99 | if time_global() - safeTimes[id] < cfg.safezoneExpires then 100 | return false 101 | else 102 | safeTimes[id] = nil 103 | end 104 | 105 | elseif id then 106 | for i, zone in ipairs(ignoredZones) do 107 | if utils_obj.npc_in_zone(npc, zone) then 108 | safeTimes[id] = time_global() 109 | return false 110 | end 111 | end 112 | end 113 | 114 | local squad = get_object_squad(enemy) 115 | id = squad and squad.id or enemy:id() 116 | 117 | if safeTimes[id] then 118 | return false 119 | else 120 | for i, zone in ipairs(ignoredZones) do 121 | if utils_obj.npc_in_zone(enemy, zone) then 122 | safeTimes[id] = time_global() 123 | return false 124 | end 125 | end 126 | end 127 | end 128 | 129 | -- ignore underground vs. above-ground fights 130 | if cfg.maxElevation 131 | and math.abs(pos1.y - pos2.y) > cfg.maxElevation 132 | and not npc:see(enemy) 133 | and not enemy:see(npc) 134 | then 135 | return false 136 | end 137 | 138 | -- ignore based on distance 139 | if cfg.enemyRange and dist > cfg.enemyRange then 140 | return false 141 | end 142 | 143 | -- save enemy before overriding 144 | if npc:relation(enemy) >= game_object.enemy then 145 | db.storage[npc:id()].enemy_id = enemy:id() 146 | end 147 | 148 | -- ignore based on overrides 149 | if xr_combat_ignore.ignore_enemy_by_overrides(npc, enemy) then 150 | return false 151 | end 152 | 153 | return true 154 | end 155 | 156 | 157 | -- Gather LTX settings 158 | -- (Unused in vanilla so values in original xr_combat_ignore.ltx do nothing) 159 | function PATCH.initCombatIgnoreConfig() 160 | local ini = ini_file("ai_tweaks\\xr_combat_ignore.ltx") 161 | 162 | PATCH.CONFIG = { 163 | enemyRange = ini:r_string_to_condlist("settings", "enemy_range", "nil"), 164 | enemyRangeMin = ini:r_string_to_condlist("settings", "enemy_range_min", "nil"), 165 | enemyRangeSurge = ini:r_string_to_condlist("settings", "enemy_range_surge", "nil"), 166 | maxElevation = ini:r_string_to_condlist("settings", "max_elevation", "nil"), 167 | memoryTime = ini:r_string_to_condlist("settings", "memory_time", "nil"), 168 | memoryDistance = ini:r_string_to_condlist("settings", "memory_distance", "nil"), 169 | nightMultiplier = ini:r_string_to_condlist("night_settings", "multiplier", "1"), 170 | rainMultiplier = ini:r_string_to_condlist("rain_settings", "multiplier", "1"), 171 | surgeMultiplier = ini:r_string_to_condlist("surge_settings", "multiplier", "1"), 172 | safezoneExpires = ini:r_float_ex("settings", "safezone_expires", 0), 173 | nightMinHour = ini:r_float_ex("night_settings", "min_hour", 18), 174 | nightMaxHour = ini:r_float_ex("night_settings", "max_hour", 21), 175 | rainMinFactor = ini:r_float_ex("rain_settings", "min_factor", 0), 176 | rainMaxFactor = ini:r_float_ex("rain_settings", "max_factor", 1), 177 | } 178 | end 179 | 180 | 181 | -- Update settings, parse condlists and calculate vision multipliers 182 | function PATCH.getCombatIgnoreConfig(enemy, npc) 183 | local cfg = dup_table(PATCH.CONFIG) 184 | 185 | for k, v in pairs(cfg) do 186 | if type(v) == "table" then 187 | local value = xr_logic.pick_section_from_condlist(enemy, npc, v) 188 | cfg[k] = value and value ~= "nil" and tonumber(value) or nil 189 | end 190 | end 191 | 192 | cfg.nightMultiplier = PATCH.getNightMultiplier(cfg.nightMinHour, cfg.nightMaxHour, cfg.nightMultiplier) 193 | cfg.rainMultiplier = PATCH.getRainMultiplier(cfg.rainMinFactor, cfg.rainMaxFactor, cfg.rainMultiplier) 194 | cfg.surgeMultiplier = PATCH.getSurgeMultiplier(cfg.surgeMultiplier) 195 | 196 | if cfg.enemyRange and cfg.enemyRangeMin then 197 | cfg.enemyRange = math.max(cfg.enemyRangeMin, cfg.enemyRange * cfg.nightMultiplier * cfg.rainMultiplier * cfg.surgeMultiplier) 198 | end 199 | 200 | return cfg 201 | end 202 | 203 | 204 | -- How much to adjust vision range for time of day 205 | function PATCH.getNightMultiplier(hr1, hr2, multiplier) 206 | if hr1 > hr2 or multiplier < 0 or multiplier > 1 then 207 | return 1 208 | end 209 | 210 | local hour = level.get_time_hours() 211 | local mins = level.get_time_minutes() 212 | local diff1 = math.abs(hr1 - 12) 213 | local diff2 = math.abs(hr2 - 12) 214 | 215 | local modifier = hour + (mins / 60) 216 | modifier = math.abs(modifier - 12) 217 | modifier = math.min(math.max(modifier, diff1), diff2) 218 | modifier = 1 - (modifier - diff1) / (hr2 - hr1) * (1 - multiplier) 219 | 220 | return math.min(1, math.max(1 - multiplier, modifier)) 221 | end 222 | 223 | 224 | -- How much to adjust vision range for rain 225 | function PATCH.getRainMultiplier(low, high, multiplier) 226 | local rain = level.rain_factor() 227 | 228 | if low > high or multiplier < 0 or multiplier > 1 or rain < 0 then 229 | return 1 230 | end 231 | 232 | local modifier = 1 - (rain / (high - low) - low) * (1 - multiplier) 233 | modifier = math.min(modifier, 1) 234 | modifier = math.max(modifier, 1 - multiplier) 235 | 236 | return math.min(1, math.max(1 - multiplier, modifier)) 237 | end 238 | 239 | 240 | -- How much to adjust vision range for surge 241 | function PATCH.getSurgeMultiplier(multiplier) 242 | return xr_conditions.surge_started() 243 | and multiplier 244 | or 1 245 | end 246 | 247 | 248 | -- Replacement for "He is With Me" 249 | function PATCH.onEvalEnemy(npc, enemy, flags) 250 | if NPC.isCompanion(npc) and NPC.isCompanion(enemy) then 251 | flags.override, flags.result = true, false 252 | 253 | elseif NPC.isCompanion(npc) and enemy:relation(db.actor) < game_object.enemy then 254 | flags.override, flags.result = true, false 255 | 256 | elseif NPC.isCompanion(enemy) and npc:relation(db.actor) < game_object.enemy then 257 | flags.override, flags.result = true, false 258 | end 259 | end 260 | 261 | 262 | -- Replace "He is With Me" callback if it exists 263 | if he_is_with_me and he_is_with_me.escorteval then 264 | he_is_with_me.escorteval = PATCH.onEvalEnemy 265 | end 266 | 267 | 268 | RegisterScriptCallback("idiots_on_start", function() 269 | PATCH.initCombatIgnoreConfig() 270 | 271 | -- Add new callback if "He is With Me" doesn't exist 272 | if not (he_is_with_me and he_is_with_me.escorteval) then 273 | RegisterScriptCallback("on_enemy_eval", PATCH.onEvalEnemy) 274 | end 275 | end) 276 | 277 | 278 | return PATCH 279 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/combat.lua: -------------------------------------------------------------------------------- 1 | local UTIL = require "illish.lib.util" 2 | local RAY = require "illish.lib.ray" 3 | local VEC = require "illish.lib.vector" 4 | local NPC = require "illish.lib.npc" 5 | 6 | 7 | local COMBAT = {} 8 | 9 | 10 | -- CONSTS -- 11 | COMBAT.COMBAT_ANIMATIONS = { 12 | stand = { 13 | idle = { 14 | snipe = "threat_sniper_fire", 15 | fire = "threat_fire", 16 | reload = "hide_fire", 17 | hold = "threat_na", 18 | }, 19 | move = { 20 | snipe = "assault_fire", 21 | fire = "assault_fire", 22 | reload = "sneak_fire", 23 | hold = "assault", 24 | } 25 | }, 26 | sneak = { 27 | idle = { 28 | snipe = "hide_sniper_fire", 29 | fire = "hide_fire", 30 | reload = "hide_fire", 31 | hold = "hide_na", 32 | }, 33 | move = { 34 | snipe = "assault_fire", 35 | fire = "assault_fire", 36 | reload = "sneak_fire", 37 | hold = "assault", 38 | } 39 | }, 40 | prone = { 41 | idle = { 42 | snipe = "prone_sniper_fire", 43 | fire = "prone_fire", 44 | reload = "prone_fire", 45 | hold = "prone", 46 | }, 47 | move = { 48 | snipe = "sneak_fire", 49 | fire = "sneak_fire", 50 | reload = "sneak_fire", 51 | hold = "sneak_run", 52 | } 53 | } 54 | } 55 | 56 | COMBAT.COVER_STANCES = { 57 | peek = { 58 | [0] = false, 59 | [1] = "prone", 60 | [2] = "sneak", 61 | [3] = "sneak", 62 | [4] = "stand", 63 | [5] = "stand", 64 | [6] = false, 65 | }, 66 | shoot = { 67 | [0] = false, 68 | [1] = "sneak", 69 | [2] = "sneak", 70 | [3] = "stand", 71 | [4] = "stand", 72 | [5] = "stand", 73 | [6] = false, 74 | } 75 | } 76 | 77 | COMBAT.COVER_ORDER = { 78 | peek = {5, 3, 1, 4, 2, 0, 6}, 79 | shoot = {4, 2, 3, 1, 0, 5, 6}, 80 | } 81 | -- 82 | 83 | 84 | -- SIGHT -- 85 | function COMBAT.squadSeesEnemy(npc, enemy) 86 | local squad = get_object_squad(npc) 87 | 88 | if squad then 89 | for member in squad:squad_members() do 90 | local squaddie = NPC.get(member.id) 91 | if squaddie and squaddie:see(enemy) then 92 | return true 93 | end 94 | end 95 | end 96 | 97 | return false 98 | end 99 | 100 | 101 | function COMBAT.teamSeesEnemy(npc, enemy) 102 | if not NPC.isCompanion(npc) then 103 | return COMBAT.squadSeesEnemy(npc, enemy) 104 | end 105 | 106 | for i, companion in ipairs(NPC.getCompanions()) do 107 | if companion:see(enemy) then 108 | return true 109 | end 110 | end 111 | 112 | return false 113 | end 114 | 115 | 116 | function COMBAT.hasLineOfSight(npc, enemy) 117 | local edir = VEC.direction(npc:position(), enemy:position()) 118 | local angle = VEC.dotProduct(npc:direction(), edir) 119 | 120 | if angle < 0 then 121 | return false, {} 122 | end 123 | 124 | local pos = utils_obj.safe_bone_pos(npc, "bip01_r_finger02") 125 | local epos = utils_obj.safe_bone_pos(enemy, "bip01_head") 126 | 127 | local dir = VEC.direction(pos, epos) 128 | local dist = VEC.distance(pos, epos) 129 | local cast = RAY.distance(pos, dir, dist) 130 | 131 | return math.floor(dist - cast) <= 0 132 | , {pos = pos, dir = dir, cast = cast} 133 | end 134 | 135 | 136 | function COMBAT.getActorMovement(self) 137 | local state = self.st.state 138 | local npc = self.object 139 | 140 | local pos, savedActorPos = lvpos(state.vid), state.actorPos 141 | 142 | if not (pos and savedActorPos) then 143 | return 0 144 | end 145 | 146 | local actorPos = db.actor:position() 147 | local actorDir = VEC.direction(pos, actorPos) 148 | local moveDir = VEC.direction(savedActorPos, actorPos) 149 | local moveDist = VEC.distance(savedActorPos, actorPos) 150 | 151 | if VEC.dotProduct(actorDir, moveDir) < 0 then 152 | moveDist = -moveDist 153 | end 154 | 155 | return moveDist 156 | end 157 | -- 158 | 159 | 160 | -- ZONES -- 161 | function COMBAT.getCurrentZone(self) 162 | local config = self.st.config 163 | local state = self.st.state 164 | local enemy = self.st.enemy 165 | 166 | local zones = config.zones[state.weapon] or config.zones.other 167 | 168 | for i = 1, #zones do 169 | if enemy.dist < zones[i] then 170 | return i - 1 171 | end 172 | end 173 | 174 | return #zones 175 | end 176 | 177 | 178 | function COMBAT.getTargetZone(self) 179 | local config = self.st.config 180 | local state = self.st.state 181 | local enemy = self.st.enemy 182 | local npc = self.object 183 | 184 | local be = NPC.get(enemy.id) 185 | local targetZone = config.targetZone 186 | local zoneConfig = config.zones[state.weapon] or config.zones.other 187 | 188 | if state.recovering then 189 | targetZone = targetZone + 1 190 | end 191 | 192 | return clamp(targetZone, 1, #zoneConfig - 1) 193 | end 194 | 195 | 196 | function COMBAT.updateNextZone(self) 197 | local config = self.st.config 198 | local state = self.st.state 199 | 200 | local nextZone, currentZone, targetZone = 201 | state.nextZone, 202 | state.currentZone, 203 | state.targetZone 204 | 205 | local zones = config.zones[state.weapon] or config.zones.other 206 | 207 | if not nextZone or currentZone == targetZone then 208 | nextZone = currentZone 209 | elseif currentZone < targetZone then 210 | nextZone = currentZone + 1 211 | else 212 | nextZone = currentZone - 1 213 | end 214 | 215 | state.nextZone = clamp(nextZone, 1, #zones - 1) 216 | 217 | return (zones[state.nextZone] + zones[state.nextZone + 1]) / 2 218 | end 219 | -- 220 | 221 | 222 | -- ANIMATIONS -- 223 | function COMBAT.getCombatMoveState(self) 224 | if self.st.moveState then 225 | return self.st.moveState 226 | end 227 | 228 | local config = self.st.config 229 | local enemy = self.st.enemy 230 | local state = self.st.state 231 | local npc = self.object 232 | 233 | if state.action == "dodge" then 234 | return "panic" 235 | end 236 | 237 | local body = state.reached 238 | and COMBAT.COVER_STANCES[state.coverOrder][state.cover] 239 | or NPC.getActiveState(npc, "stance") 240 | 241 | local move = state.reached 242 | and "idle" 243 | or "move" 244 | 245 | local fire = nil 246 | or state.reloading and "reload" 247 | or state.dontShoot and "hold" 248 | or state.action == "idle" and "hold" 249 | or enemy.seen and state.weapon == "sniper" and "snipe" 250 | or enemy.seen and "fire" 251 | or "hold" 252 | 253 | return COMBAT.COMBAT_ANIMATIONS[body][move][fire] 254 | end 255 | 256 | 257 | function COMBAT.getCombatLookState(self) 258 | local npc = self.object 259 | local config = self.st.config 260 | local state = self.st.state 261 | local enemy = self.st.enemy 262 | 263 | local lookDir = state.action == "search" 264 | and VEC.set(enemy.dir):invert() 265 | or enemy.dir 266 | 267 | if enemy.seen then 268 | return {look_object = NPC.get(enemy.id)} 269 | end 270 | 271 | if not state.reached or not UTIL.timeExpired(enemy.spottedUntil) then 272 | return {look_dir = lookDir} 273 | end 274 | 275 | if NPC.getState(npc, "stance", "prone") then 276 | return {look_dir = lookDir} 277 | end 278 | 279 | if not state.lookAround then 280 | if not state.__looksign then 281 | state.__looksign = UTIL.randomChance(50) and -1 or 1 282 | end 283 | 284 | state.lookAround = UTIL.throttle(function(lookDir) 285 | local dir = VEC.rotate(lookDir, UTIL.random(30, 60) * state.__looksign) 286 | state.__looksign = -state.__looksign 287 | return dir 288 | end, config.lookTimeout[1], config.lookTimeout[2]) 289 | end 290 | 291 | return {look_dir = state.lookAround(lookDir)} 292 | end 293 | -- 294 | 295 | 296 | -- CALLBACKS -- 297 | function COMBAT.combatHitCallback(obj, amount, direction, who, bone) 298 | local st = db.storage[obj:id()] 299 | 300 | if 301 | not st 302 | or not st.enemy 303 | or not st.combat 304 | or not st.combat.enemy 305 | or not st.combat.state 306 | or ( 307 | st.script_combat_type ~= "assault" 308 | and st.script_combat_type ~= "support" 309 | and st.script_combat_type ~= "snipe" 310 | and st.script_combat_type ~= "guard" 311 | ) 312 | then 313 | return 314 | end 315 | 316 | local enemy = st.combat.enemy 317 | local state = st.combat.state 318 | 319 | if not who or who:id() ~= enemy.id then 320 | return 321 | end 322 | 323 | enemy.spotted = true 324 | 325 | if state.reached then 326 | state.expires = 0 327 | end 328 | end 329 | 330 | 331 | function COMBAT.combatHearCallback(obj, whoid, type, distance, power, position) 332 | local st = db.storage[obj:id()] 333 | 334 | if 335 | not st 336 | or not st.enemy 337 | or not st.combat 338 | or not st.combat.enemy 339 | or not st.combat.state 340 | or ( 341 | st.script_combat_type ~= "assault" 342 | and st.script_combat_type ~= "support" 343 | and st.script_combat_type ~= "snipe" 344 | and st.script_combat_type ~= "guard" 345 | ) 346 | then 347 | return 348 | end 349 | 350 | local enemy = st.combat.enemy 351 | local state = st.combat.state 352 | 353 | if whoid ~= enemy.id then 354 | return 355 | end 356 | 357 | enemy.spotted = true 358 | 359 | if UTIL.timeExpired(enemy.spottedUntil) then 360 | state.expires = 0 361 | end 362 | end 363 | -- 364 | 365 | 366 | return COMBAT 367 | -------------------------------------------------------------------------------- /gamedata/scripts/idiots_combat_snipe.script: -------------------------------------------------------------------------------- 1 | local WP = world_property 2 | local UTIL = require "illish.lib.util" 3 | local POS = require "illish.lib.pos" 4 | local NPC = require "illish.lib.npc" 5 | local COMBAT = require "illish.lib.combat" 6 | 7 | EVAL_FF = rx_ff.evaid 8 | EVAL_FACER = xrs_facer.evid_facer 9 | EVAL_STEAL_UP = xrs_facer.evid_steal_up_facer 10 | EVAL_ENEMY = stalker_ids.property_enemy 11 | EVAL_LOGIC = xr_evaluators_id.state_mgr + 4 12 | EVAL_ID = stalker_ids.property_script + 3160 13 | ACTION_ID = xr_actions_id.chugai_actions + 160 14 | 15 | 16 | -- EVALUATOR -- 17 | class "evaluator_combat_type" (property_evaluator) 18 | 19 | 20 | function evaluator_combat_type:__init(name, storage, type) super(nil, name) 21 | self.st = storage 22 | self.type = type 23 | end 24 | 25 | 26 | function evaluator_combat_type:evaluate() 27 | local npc = self.object 28 | return db.storage[npc:id()].script_combat_type == self.type 29 | end 30 | -- 31 | 32 | 33 | -- ACTION METHODS -- 34 | class "action_combat_snipe" (action_base) 35 | 36 | 37 | function action_combat_snipe:__init(name, storage) super(nil, name) 38 | self.st = storage 39 | end 40 | 41 | 42 | function action_combat_snipe:initialize() 43 | action_base.initialize(self) 44 | self:initConfig() 45 | self:initEnemy() 46 | self:initState() 47 | self:updateEnemy() 48 | self:updateState() 49 | end 50 | 51 | 52 | function action_combat_snipe:execute() 53 | action_base.execute(self) 54 | 55 | self:updateEnemy() 56 | self:updateState() 57 | 58 | local config = self.st.config 59 | local state = self.st.state 60 | local npc = self.object 61 | 62 | if state.action == "dodge" 63 | then self:dodge() 64 | elseif state.action == "ffstrafe" 65 | then self:ffstrafe() 66 | elseif state.action == "attack" 67 | then self:attack() 68 | elseif state.action == "idle" 69 | then state.vid = npc:level_vertex_id() 70 | end 71 | 72 | if not POS.isValidLVID(npc, state.vid) then 73 | state.vid = npc:level_vertex_id() 74 | state.expires = time_plus(config.vidRetry) 75 | end 76 | 77 | state.reached = state.vid == npc:level_vertex_id() 78 | POS.setLVID(npc, state.vid) 79 | 80 | local move = COMBAT.getCombatMoveState(self) 81 | local look = COMBAT.getCombatLookState(self) 82 | 83 | state_mgr.set_state(npc, move, nil, nil, look, {fast_set = true}) 84 | end 85 | -- 86 | 87 | 88 | -- STATE -- 89 | function action_combat_snipe:initConfig() 90 | local st = self.st 91 | 92 | st.config = { 93 | alwaysSee = 2, 94 | vidRetry = 1000, 95 | useTeamSight = true, 96 | recoverHealth = {0.32, 0.48}, 97 | spottedTimeout = {800, 1200}, 98 | lookTimeout = {2500, 5000}, 99 | ffDelay = {1200, 1600}, 100 | } 101 | end 102 | 103 | 104 | function action_combat_snipe:initEnemy() 105 | local config = self.st.config 106 | local npc = self.object 107 | local st = self.st 108 | 109 | self.enemySpace = POS.assessSpace 110 | 111 | st.enemy = { 112 | id = nil, 113 | pos = nil, 114 | dist = nil, 115 | dir = nil, 116 | spottedUntil = nil, 117 | spotted = true, 118 | seen = false, 119 | wounded = false, 120 | } 121 | end 122 | 123 | 124 | function action_combat_snipe:updateEnemy() 125 | self.st.lastEnemy = dup_table(self.st.enemy) 126 | 127 | local config = self.st.config 128 | local enemy = self.st.enemy 129 | local npc = self.object 130 | 131 | local be = npc:best_enemy() 132 | if not be then 133 | return 134 | end 135 | 136 | if enemy.id ~= be:id() then 137 | enemy.spotted = true 138 | end 139 | 140 | enemy.seen = npc:see(be) or COMBAT.hasLineOfSight(npc, be) 141 | enemy.id = be:id() 142 | 143 | local reset = false 144 | local pos = false 145 | 146 | if enemy.seen or enemy.spotted then 147 | reset = true 148 | pos = true 149 | 150 | elseif distance_between(be, npc) <= config.alwaysSee then 151 | reset = true 152 | pos = true 153 | 154 | elseif config.useTeamSight and COMBAT.teamSeesEnemy(npc, be) then 155 | reset = true 156 | pos = true 157 | 158 | elseif not time_expired(enemy.spottedUntil) then 159 | pos = true 160 | end 161 | 162 | if pos then 163 | enemy.pos = utils_obj.safe_bone_pos(be, "bip01_head") 164 | enemy.wounded = IsWounded(be) 165 | end 166 | 167 | if reset then 168 | enemy.spottedUntil = time_plus_rand(config.spottedTimeout) 169 | end 170 | 171 | if time_expired(enemy.spottedUntil) then 172 | enemy.holdUntil = enemy.holdUntil or time_plus_rand(config.holdDelay) 173 | else 174 | enemy.holdUntil = nil 175 | end 176 | 177 | enemy.dir = vec_dir(utils_obj.safe_bone_pos(npc, "bip01_r_finger02"), enemy.pos) 178 | enemy.dist = vec_dist(npc:position(), enemy.pos) 179 | 180 | enemy.spotted = false 181 | end 182 | 183 | 184 | function action_combat_snipe:initState() 185 | local config = self.st.config 186 | local st = self.st 187 | 188 | self.isReloading = NPC.isReloading 189 | self.assessCover = POS.assessCover 190 | self.nearbyGrenades = POS.nearbyGrenades 191 | self.getWeaponType = NPC.getWeaponType 192 | 193 | st.state = { 194 | action = nil, 195 | vid = nil, 196 | expires = nil, 197 | grenades = nil, 198 | weapon = nil, 199 | cover = nil, 200 | coverOrder = nil, 201 | startPos = nil, 202 | ffDelay = nil, 203 | reached = false, 204 | reloading = false, 205 | recovering = false, 206 | dontShoot = false, 207 | } 208 | end 209 | 210 | 211 | function action_combat_snipe:updateState() 212 | self.st.lastState = dup_table(self.st.state) 213 | 214 | local lastState = self.st.lastState 215 | local config = self.st.config 216 | local state = self.st.state 217 | local enemy = self.st.enemy 218 | local npc = self.object 219 | 220 | if not state.startPos then 221 | state.startPos = npc:position() 222 | end 223 | 224 | state.grenades = self.nearbyGrenades(npc:position(), config.dodgeDist) 225 | state.cover = self.assessCover(npc:position(), enemy.pos) 226 | state.dontShoot = db.storage[npc:id()].rx_dont_shoot 227 | state.weapon = self.getWeaponType(npc) 228 | state.reloading = self.isReloading(npc) 229 | 230 | state.recovering = npc.health <= config.recoverHealth[1] 231 | or lastState.recovering and npc.health <= config.recoverHealth[2] 232 | 233 | state.coverOrder = (state.recovering or state.reloading) 234 | and "peek" 235 | or "shoot" 236 | 237 | if state.dontShoot then 238 | state.ffTimeout = time_plus(500) 239 | if state.reached and not state.ffDelay then 240 | state.ffDelay = time_plus_rand(config.ffDelay) 241 | end 242 | end 243 | 244 | if state.grenades then 245 | state.action = "dodge" 246 | elseif time_expired(state.ffDelay) and not time_expired(state.ffTimeout) then 247 | state.action = "ffstrafe" 248 | else 249 | state.action = "attack" 250 | end 251 | 252 | if self.st.movePoint then 253 | state.startPos = lvpos(self.st.movePoint) 254 | self.st.movePoint = nil 255 | state.expires = 0 256 | 257 | elseif lastState.action ~= state.action then 258 | if lastState.action == "ffstrafe" and state.action ~= "dodge" then 259 | state.vid = npc:level_vertex_id() 260 | state.expires = nil 261 | else 262 | state.expires = 0 263 | end 264 | end 265 | 266 | if time_expired(state.expires) then 267 | state.expires = nil 268 | state.vid = nil 269 | end 270 | 271 | state.reached = state.vid == npc:level_vertex_id() 272 | 273 | if state.reached then 274 | self.st.moveState = nil 275 | end 276 | end 277 | -- 278 | 279 | 280 | -- ACTIONS -- 281 | function action_combat_snipe:dodge() 282 | local config = self.st.config 283 | local state = self.st.state 284 | local npc = self.object 285 | 286 | if state.vid then 287 | local dir = vec_dir(npc:position(), lvpos(state.vid)) 288 | if vec_dot(dir, state.grenades.avgDir) > 0 then 289 | state.vid, state.expires = nil, nil 290 | end 291 | end 292 | 293 | if not state.vid then 294 | local dist = UTIL.random(10, 14, 1) 295 | local rot = 180 + UTIL.randomRange(45) 296 | local dir = vec_rot(state.grenades.avgDir, rot) 297 | local pos = vec_offset(npc:position(), dir, dist) 298 | 299 | state.vid = POS.bestOutsideValidLVID(npc, npc:position(), pos) 300 | end 301 | end 302 | 303 | 304 | function action_combat_snipe:ffstrafe() 305 | local config = self.st.config 306 | local state = self.st.state 307 | local enemy = self.st.enemy 308 | local npc = self.object 309 | 310 | if state.reached and not state.expires then 311 | state.expires = time_plus_rand(config.ffDelay) 312 | end 313 | 314 | if not state.vid then 315 | state.vid = POS.getStrafePos(npc, {enemyPos = enemy.pos}) 316 | end 317 | end 318 | 319 | 320 | function action_combat_snipe:attack() 321 | local lastState = self.st.lastState 322 | local state = self.st.state 323 | local npc = self.object 324 | 325 | if not state.vid then 326 | state.vid = POS.bestOutsideUnclaimedLVID(npc, npc:position(), state.startPos) 327 | end 328 | end 329 | -- 330 | 331 | 332 | -- BINDER -- 333 | function add_to_binder(npc, ini, storage, planner, temp) 334 | planner:add_evaluator(EVAL_ID, 335 | evaluator_combat_type("combat_snipe", storage, "snipe") 336 | ) 337 | 338 | local action = action_combat_snipe("combat_snipe", storage) 339 | 340 | if action then 341 | action:add_precondition(WP(EVAL_ENEMY, true)) 342 | action:add_precondition(WP(EVAL_ID, true)) 343 | action:add_precondition(WP(EVAL_FACER, false)) 344 | action:add_precondition(WP(EVAL_STEAL_UP, false)) 345 | 346 | action:add_effect(WP(EVAL_LOGIC, false)) 347 | action:add_effect(WP(EVAL_ENEMY, false)) 348 | action:add_effect(WP(EVAL_ID, false)) 349 | 350 | planner:add_action(ACTION_ID, action) 351 | end 352 | end 353 | -- 354 | -------------------------------------------------------------------------------- /gamedata/scripts/idiots_mcm.script: -------------------------------------------------------------------------------- 1 | local MCM = require "illish.lib.mcm" 2 | local TABLE = require "illish.lib.table" 3 | local NPC = require "illish.lib.npc" 4 | 5 | 6 | -- CONSTS -- 7 | local isGAMMA = grok_gamma_manual_on_startup 8 | and true 9 | or false 10 | 11 | ANOMALY_KEYBINDS = { 12 | cycle_movement = 80, 13 | cycle_stance = 75, 14 | cycle_readiness = 79, 15 | loot_corpses = 76, 16 | } 17 | -- 18 | 19 | 20 | -- CONFIG -- 21 | function getGeneralOptions() 22 | return { 23 | id = "options", 24 | text = "ui_mcm_menu_idiots_options", 25 | sh = true, 26 | gr = { 27 | MCM.getTitle({text = "ui_mcm_idiots_title_options"}), 28 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_options_general"}), 29 | MCM.getCheckboxField({id = "splitSquads", def = true}), 30 | MCM.getCheckboxField({id = "autoSneak", def = true}), 31 | MCM.getCheckboxField({id = "autoProne", def = true}), 32 | MCM.getCheckboxField({id = "autoSprint", def = true}), 33 | MCM.getCheckboxField({id = "autoLight", def = true}), 34 | MCM.getCheckboxField({id = "autoWait"}), 35 | MCM.getCheckboxField({id = "autoGuard"}), 36 | MCM.getCheckboxField({id = "autoDeselect", def = true}), 37 | MCM.getCheckboxField({id = "autoReloadAll"}), 38 | MCM.getCheckboxField({id = "manualReloadAll"}), 39 | MCM.getLine(), 40 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_options_combat"}), 41 | MCM.getListField({ 42 | id = "defendMode", 43 | def = "actor", 44 | content = { 45 | {"actor", "idiots_actor"}, 46 | {"self", "idiots_self"}, 47 | {"anyone", "idiots_anyone"}, 48 | } 49 | }), 50 | MCM.getCheckboxField({id = "camperCombat"}), 51 | MCM.getCheckboxField({id = "monolithCombat"}), 52 | MCM.getCheckboxField({id = "zombiedCombat"}), 53 | MCM.getLine(), 54 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_options_surge"}), 55 | { 56 | id = "dynamicSurgeCover", 57 | type = "list", 58 | hint = "idiots_options_dynamicSurgeCover", 59 | val = 0, 60 | def = "both", 61 | content = { 62 | {"both", "idiots_both"}, 63 | {"companions", "idiots_companions"}, 64 | {"neither", "idiots_neither"}, 65 | } 66 | }, 67 | MCM.getCheckboxField({id = "surgesKillCompanions", def = not isGAMMA}), 68 | MCM.getNote({text = "ui_mcm_idiots_options_surge_note", clr = {255, 200, 175, 75}}), 69 | MCM.getLine(), 70 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_options_jobs"}), 71 | MCM.getCheckboxField({id = "noNpcLooting", def = isGAMMA}), 72 | MCM.getNote({text = "ui_mcm_idiots_options_jobs_note", clr = {255, 200, 175, 75}}), 73 | MCM.getLine(), 74 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_options_cheats"}), 75 | MCM.getNote({text = "ui_mcm_idiots_options_cheats_note", clr = {255, 200, 175, 75}}), 76 | MCM.getCheckboxField({id = "artifacts"}), 77 | MCM.getCheckboxField({id = "showAllItems"}), 78 | } 79 | } 80 | end 81 | 82 | 83 | function getInterfaceOptions() 84 | return { 85 | id = "ui", 86 | text = "ui_mcm_menu_idiots_ui", 87 | sh = true, 88 | gr = { 89 | MCM.getTitle({text = "ui_mcm_idiots_title_ui"}), 90 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_options_general"}), 91 | MCM.getCheckboxField({id = "indexers", def = true}), 92 | MCM.getCheckboxField({id = "showReload"}), 93 | MCM.getCheckboxField({id = "showRetreat"}), 94 | MCM.getCheckboxField({id = "showUnstick"}), 95 | MCM.getCheckboxField({id = "showTooltips", def = true}), 96 | MCM.getLine(), 97 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_ui_customization"}), 98 | MCM.getScaleField({id = "scaleX", max = 4}), 99 | MCM.getScaleField({id = "scaleY", max = 4}), 100 | MCM.getOffsetXField({id = "offsetX", min = -2048, max = 2048, def = 0}), 101 | MCM.getOffsetYField({id = "offsetY", min = -2048, max = 0, def = 0}), 102 | MCM.getAlphaField({id = "alpha", def = 208}), 103 | { 104 | id = "scheme", 105 | type = "list", 106 | hint = "idiots_scheme", 107 | val = 0, 108 | def = "faction", 109 | content = { 110 | {"faction", "idiots_faction"}, 111 | {"army", "idiots_army"}, 112 | {"bandit", "idiots_bandit"}, 113 | {"csky", "idiots_csky"}, 114 | {"dolg", "idiots_dolg"}, 115 | {"ecolog", "idiots_ecolog"}, 116 | {"freedom", "idiots_freedom"}, 117 | {"greh", "idiots_greh"}, 118 | {"isg", "idiots_isg"}, 119 | {"killer", "idiots_killer"}, 120 | {"monolith", "idiots_monolith"}, 121 | {"renegade", "idiots_renegade"}, 122 | {"stalker", "idiots_stalker"}, 123 | } 124 | }, 125 | { 126 | id = "font", 127 | type = ui_mcm.kb_mod_list, 128 | val = 0, 129 | def = "2", 130 | content = { 131 | {"1", "font_small"}, 132 | {"2", "font_medium"}, 133 | {"3", "font_large"}, 134 | {"4", "font_xlarge"} 135 | } 136 | }, 137 | } 138 | } 139 | end 140 | 141 | 142 | function getKeybindFields(name, toggle) 143 | local def = ANOMALY_KEYBINDS[name] 144 | 145 | local gr = { 146 | MCM.getLine(), 147 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_" .. name}), 148 | MCM.getKeybindKey({ id = name .."_key", hint = "idiots_keybinds_key", def = def}), 149 | MCM.getKeybindMod({ id = name .."_mod", hint = "idiots_keybinds_mod"}), 150 | MCM.getKeybindMode({id = name .."_mode", hint = "idiots_keybinds_mode"}), 151 | } 152 | 153 | if toggle then 154 | gr[#gr + 1] = MCM.getCheckboxField({id = name .."_toggle", def = true, hint = "idiots_keybinds_toggle"}) 155 | end 156 | 157 | return gr 158 | end 159 | 160 | 161 | function getKeybindSection(group, actions, cycle, toggle) 162 | local options = { 163 | MCM.getTitle({text = "ui_mcm_idiots_title_keybinds"}), 164 | MCM.getSubtitle({text = "ui_mcm_menu_idiots_" .. group}), 165 | } 166 | 167 | if cycle then 168 | options = TABLE.imerge(options, getKeybindFields("cycle_" .. group)) 169 | end 170 | 171 | for i, action in ipairs(actions) do 172 | options = TABLE.imerge(options, getKeybindFields(action, toggle)) 173 | end 174 | 175 | return { 176 | text = "ui_mcm_menu_idiots_" .. group, 177 | gr = options, 178 | id = group, 179 | sh = true, 180 | } 181 | end 182 | 183 | 184 | function mcmGetOptions() 185 | local gr = { 186 | { 187 | id = "point", 188 | text = "ui_mcm_menu_idiots_point", 189 | sh = true, 190 | gr = { 191 | MCM.getTitle({text = "ui_mcm_idiots_title_keybinds"}), 192 | MCM.getSubtitle({text = "ui_mcm_menu_idiots_point"}), 193 | MCM.getLine(), 194 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_select"}), 195 | MCM.getKeybindKey({ id = "select_key", hint = "idiots_keybinds_key"}), 196 | MCM.getKeybindMod({ id = "select_mod", hint = "idiots_keybinds_mod"}), 197 | MCM.getKeybindMode({id = "select_mode", hint = "idiots_keybinds_mode"}), 198 | MCM.getLine(), 199 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_deselect"}), 200 | MCM.getKeybindKey({ id = "deselect_key", hint = "idiots_keybinds_key"}), 201 | MCM.getKeybindMod({ id = "deselect_mod", hint = "idiots_keybinds_mod"}), 202 | MCM.getKeybindMode({id = "deselect_mode", hint = "idiots_keybinds_mode"}), 203 | MCM.getLine(), 204 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_move"}), 205 | MCM.getKeybindKey({ id = "move_key", hint = "idiots_keybinds_key", def = 81}), 206 | MCM.getKeybindMod({ id = "move_mod", hint = "idiots_keybinds_mod"}), 207 | MCM.getKeybindMode({id = "move_mode", hint = "idiots_keybinds_mode"}), 208 | MCM.getLine(), 209 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_look"}), 210 | MCM.getKeybindKey({ id = "look_key", hint = "idiots_keybinds_key"}), 211 | MCM.getKeybindMod({ id = "look_mod", hint = "idiots_keybinds_mod"}), 212 | MCM.getKeybindMode({id = "look_mode", hint = "idiots_keybinds_mode"}), 213 | MCM.getLine(), 214 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_away"}), 215 | MCM.getKeybindKey({ id = "away_key", hint = "idiots_keybinds_key"}), 216 | MCM.getKeybindMod({ id = "away_mod", hint = "idiots_keybinds_mod"}), 217 | MCM.getKeybindMode({id = "away_mode", hint = "idiots_keybinds_mode"}), 218 | MCM.getLine(), 219 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_addWaypoint"}), 220 | MCM.getKeybindKey({ id = "addWaypoint_key", hint = "idiots_keybinds_key"}), 221 | MCM.getKeybindMod({ id = "addWaypoint_mod", hint = "idiots_keybinds_mod"}), 222 | MCM.getKeybindMode({id = "addWaypoint_mode", hint = "idiots_keybinds_mode"}), 223 | MCM.getLine(), 224 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_clearWaypoints"}), 225 | MCM.getKeybindKey({ id = "clearWaypoints_key", hint = "idiots_keybinds_key"}), 226 | MCM.getKeybindMod({ id = "clearWaypoints_mod", hint = "idiots_keybinds_mod"}), 227 | MCM.getKeybindMode({id = "clearWaypoints_mode", hint = "idiots_keybinds_mode"}), 228 | MCM.getLine(), 229 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_inventory"}), 230 | MCM.getKeybindKey({ id = "inventory_key", hint = "idiots_keybinds_key"}), 231 | MCM.getKeybindMod({ id = "inventory_mod", hint = "idiots_keybinds_mod"}), 232 | MCM.getKeybindMode({id = "inventory_mode", hint = "idiots_keybinds_mode"}), 233 | MCM.getNote({text = "ui_mcm_idiots_subtitle_point_inventory_note", clr = {255, 200, 175, 75}}), 234 | MCM.getLine(), 235 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_reload"}), 236 | MCM.getKeybindKey({ id = "reload_key", hint = "idiots_keybinds_key"}), 237 | MCM.getKeybindMod({ id = "reload_mod", hint = "idiots_keybinds_mod"}), 238 | MCM.getKeybindMode({id = "reload_mode", hint = "idiots_keybinds_mode"}), 239 | MCM.getLine(), 240 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_reset"}), 241 | MCM.getKeybindKey({ id = "reset_key", hint = "idiots_keybinds_key"}), 242 | MCM.getKeybindMod({ id = "reset_mod", hint = "idiots_keybinds_mod"}), 243 | MCM.getKeybindMode({id = "reset_mode", hint = "idiots_keybinds_mode"}), 244 | MCM.getLine(), 245 | MCM.getSubtitle({text = "ui_mcm_idiots_subtitle_point_retreat"}), 246 | MCM.getKeybindKey({ id = "retreat_key", hint = "idiots_keybinds_key"}), 247 | MCM.getKeybindMod({ id = "retreat_mod", hint = "idiots_keybinds_mod"}), 248 | MCM.getKeybindMode({id = "retreat_mode", hint = "idiots_keybinds_mode"}), 249 | } 250 | } 251 | } 252 | 253 | for ig, group in ipairs(NPC.ACTIONS) do 254 | local actions = {} 255 | 256 | for ia, action in ipairs(group.actions) do 257 | actions[#actions + 1] = action.name 258 | end 259 | 260 | gr[#gr + 1] = getKeybindSection(group.name, actions, group.cycle, group.toggle) 261 | end 262 | 263 | return { 264 | id = "idiots", 265 | gr = { 266 | getGeneralOptions(), 267 | getInterfaceOptions(), 268 | { 269 | id = "keybinds", 270 | text = "ui_mcm_menu_idiots_keybinds", 271 | gr = gr 272 | }, 273 | } 274 | } 275 | end 276 | -- 277 | 278 | 279 | function on_mcm_load() 280 | return mcmGetOptions() 281 | end 282 | -------------------------------------------------------------------------------- /gamedata/scripts/idiots_combat_guard.script: -------------------------------------------------------------------------------- 1 | local WP = world_property 2 | local UTIL = require "illish.lib.util" 3 | local VEC = require "illish.lib.vector" 4 | local POS = require "illish.lib.pos" 5 | local NPC = require "illish.lib.npc" 6 | local COMBAT = require "illish.lib.combat" 7 | 8 | EVAL_FF = rx_ff.evaid 9 | EVAL_FACER = xrs_facer.evid_facer 10 | EVAL_STEAL_UP = xrs_facer.evid_steal_up_facer 11 | EVAL_ENEMY = stalker_ids.property_enemy 12 | EVAL_LOGIC = xr_evaluators_id.state_mgr + 4 13 | EVAL_ID = stalker_ids.property_script + 3150 14 | ACTION_ID = xr_actions_id.chugai_actions + 150 15 | 16 | 17 | -- EVALUATOR -- 18 | class "evaluator_combat_type" (property_evaluator) 19 | 20 | 21 | function evaluator_combat_type:__init(name, storage, type) super(nil, name) 22 | self.st = storage 23 | self.type = type 24 | end 25 | 26 | 27 | function evaluator_combat_type:evaluate() 28 | local npc = self.object 29 | return db.storage[npc:id()].script_combat_type == self.type 30 | end 31 | -- 32 | 33 | 34 | -- ACTION METHODS -- 35 | class "action_combat_guard" (action_base) 36 | 37 | 38 | function action_combat_guard:__init(name, storage) super(nil, name) 39 | self.st = storage 40 | end 41 | 42 | 43 | function action_combat_guard:initialize() 44 | action_base.initialize(self) 45 | self:initConfig() 46 | self:initEnemy() 47 | self:initState() 48 | self:updateEnemy() 49 | self:updateState() 50 | end 51 | 52 | 53 | function action_combat_guard:execute() 54 | action_base.execute(self) 55 | 56 | self:updateEnemy() 57 | self:updateState() 58 | 59 | local config = self.st.config 60 | local state = self.st.state 61 | local npc = self.object 62 | 63 | if state.action == "dodge" 64 | then self:dodge() 65 | elseif state.action == "ffstrafe" 66 | then self:ffstrafe() 67 | elseif state.action == "push" 68 | then self:push() 69 | elseif state.action == "attack" 70 | then self:attack() 71 | elseif state.action == "idle" 72 | then state.vid = npc:level_vertex_id() 73 | end 74 | 75 | if not POS.isValidLVID(npc, state.vid) then 76 | state.vid = npc:level_vertex_id() 77 | state.expires = time_plus(config.vidRetry) 78 | end 79 | 80 | state.reached = state.vid == npc:level_vertex_id() 81 | POS.setLVID(npc, state.vid) 82 | 83 | local move = COMBAT.getCombatMoveState(self) 84 | local look = COMBAT.getCombatLookState(self) 85 | 86 | state_mgr.set_state(npc, move, nil, nil, look, {fast_set = true}) 87 | end 88 | -- 89 | 90 | 91 | -- STATE -- 92 | function action_combat_guard:initConfig() 93 | local st = self.st 94 | 95 | st.config = { 96 | alwaysSee = 2, 97 | maxDist = 12, 98 | mutantDist = 12, 99 | vidRetry = 1000, 100 | useTeamSight = true, 101 | recoverHealth = {0.32, 0.48}, 102 | spottedTimeout = {800, 1200}, 103 | lookTimeout = {2500, 5000}, 104 | moveDelay = {2800, 4800}, 105 | ffDelay = {1200, 1600}, 106 | } 107 | end 108 | 109 | 110 | function action_combat_guard:initEnemy() 111 | local config = self.st.config 112 | local npc = self.object 113 | local st = self.st 114 | 115 | self.enemySpace = POS.assessSpace 116 | 117 | st.enemy = { 118 | id = nil, 119 | pos = nil, 120 | dist = nil, 121 | dir = nil, 122 | spottedUntil = nil, 123 | spotted = true, 124 | seen = false, 125 | wounded = false, 126 | mutant = false, 127 | } 128 | end 129 | 130 | 131 | function action_combat_guard:updateEnemy() 132 | self.st.lastEnemy = dup_table(self.st.enemy) 133 | 134 | local config = self.st.config 135 | local enemy = self.st.enemy 136 | local npc = self.object 137 | 138 | local be = npc:best_enemy() 139 | if not be then 140 | return 141 | end 142 | 143 | if enemy.id ~= be:id() then 144 | enemy.spotted = true 145 | end 146 | 147 | enemy.seen = npc:see(be) or COMBAT.hasLineOfSight(npc, be) 148 | enemy.mutant = IsMonster(be) 149 | enemy.id = be:id() 150 | 151 | local reset = false 152 | local pos = false 153 | 154 | if enemy.seen or enemy.spotted then 155 | reset = true 156 | pos = true 157 | 158 | elseif distance_between(be, npc) <= config.alwaysSee then 159 | reset = true 160 | pos = true 161 | 162 | elseif config.useTeamSight and COMBAT.teamSeesEnemy(npc, be) then 163 | reset = true 164 | pos = true 165 | 166 | elseif not time_expired(enemy.spottedUntil) then 167 | pos = true 168 | end 169 | 170 | if pos then 171 | enemy.pos = utils_obj.safe_bone_pos(be, "bip01_head") 172 | enemy.wounded = IsWounded(be) 173 | end 174 | 175 | if reset then 176 | enemy.spottedUntil = time_plus_rand(config.spottedTimeout) 177 | end 178 | 179 | enemy.dir = vec_dir(utils_obj.safe_bone_pos(npc, "bip01_r_finger02"), enemy.pos) 180 | enemy.dist = vec_dist(npc:position(), enemy.pos) 181 | 182 | enemy.spotted = false 183 | end 184 | 185 | 186 | function action_combat_guard:initState() 187 | local config = self.st.config 188 | local st = self.st 189 | 190 | self.isReloading = NPC.isReloading 191 | self.assessCover = POS.assessCover 192 | self.nearbyGrenades = POS.nearbyGrenades 193 | self.getWeaponType = NPC.getWeaponType 194 | 195 | st.state = { 196 | action = nil, 197 | vid = nil, 198 | expires = nil, 199 | grenades = nil, 200 | weapon = nil, 201 | cover = nil, 202 | coverOrder = nil, 203 | startPos = nil, 204 | ffDelay = nil, 205 | reached = false, 206 | reloading = false, 207 | recovering = false, 208 | dontShoot = false, 209 | } 210 | end 211 | 212 | 213 | function action_combat_guard:updateState() 214 | self.st.lastState = dup_table(self.st.state) 215 | 216 | local lastState = self.st.lastState 217 | local config = self.st.config 218 | local state = self.st.state 219 | local enemy = self.st.enemy 220 | local npc = self.object 221 | 222 | if not state.startPos then 223 | state.startPos = npc:position() 224 | end 225 | 226 | state.grenades = self.nearbyGrenades(npc:position(), config.dodgeDist) 227 | state.cover = self.assessCover(npc:position(), enemy.pos) 228 | state.dontShoot = db.storage[npc:id()].rx_dont_shoot 229 | state.weapon = self.getWeaponType(npc) 230 | state.reloading = self.isReloading(npc) 231 | 232 | state.recovering = npc.health <= config.recoverHealth[1] 233 | or lastState.recovering and npc.health <= config.recoverHealth[2] 234 | 235 | state.coverOrder = (state.recovering or state.reloading) 236 | and "peek" 237 | or "shoot" 238 | 239 | if state.dontShoot then 240 | state.ffTimeout = time_plus(500) 241 | if state.reached and not state.ffDelay then 242 | state.ffDelay = time_plus_rand(config.ffDelay) 243 | end 244 | end 245 | 246 | if state.grenades then 247 | state.action = "dodge" 248 | elseif time_expired(state.ffDelay) and not time_expired(state.ffTimeout) then 249 | state.action = "ffstrafe" 250 | elseif enemy.wounded and not enemy.mutant and enemy.dist <= config.maxDist then 251 | state.action = "push" 252 | else 253 | state.action = "attack" 254 | end 255 | 256 | if self.st.movePoint then 257 | state.startPos = lvpos(self.st.movePoint) 258 | self.st.movePoint = nil 259 | state.expires = 0 260 | 261 | elseif lastState.action ~= state.action then 262 | if lastState.action == "ffstrafe" and state.action ~= "dodge" then 263 | state.vid = npc:level_vertex_id() 264 | state.expires = nil 265 | else 266 | state.expires = 0 267 | end 268 | end 269 | 270 | if time_expired(state.expires) then 271 | state.expires = nil 272 | state.vid = nil 273 | end 274 | 275 | state.reached = state.vid == npc:level_vertex_id() 276 | 277 | if state.reached then 278 | self.st.moveState = nil 279 | end 280 | end 281 | -- 282 | 283 | 284 | -- ACTIONS -- 285 | function action_combat_guard:dodge() 286 | local config = self.st.config 287 | local state = self.st.state 288 | local npc = self.object 289 | 290 | if state.vid then 291 | local dir = vec_dir(npc:position(), lvpos(state.vid)) 292 | if vec_dot(dir, state.grenades.avgDir) > 0 then 293 | state.vid, state.expires = nil, nil 294 | end 295 | end 296 | 297 | if not state.vid then 298 | local dist = UTIL.random(10, 14, 1) 299 | local rot = 180 + UTIL.randomRange(45) 300 | local dir = vec_rot(state.grenades.avgDir, rot) 301 | local pos = vec_offset(npc:position(), dir, dist) 302 | 303 | state.vid = POS.bestOutsideValidLVID(npc, npc:position(), pos) 304 | end 305 | end 306 | 307 | 308 | function action_combat_guard:ffstrafe() 309 | local config = self.st.config 310 | local state = self.st.state 311 | local enemy = self.st.enemy 312 | local npc = self.object 313 | 314 | if state.reached and not state.expires then 315 | state.expires = time_plus_rand(config.ffDelay) 316 | end 317 | 318 | if not state.vid then 319 | state.vid = POS.getStrafePos(npc, {enemyPos = enemy.pos}) 320 | end 321 | end 322 | 323 | 324 | function action_combat_guard:push() 325 | local state = self.st.state 326 | local enemy = self.st.enemy 327 | local npc = self.object 328 | 329 | if state.reached and not state.expires then 330 | state.expires = time_plus_rand(2500, 4000) 331 | end 332 | 333 | if state.reached and time_expired(state.expires) then 334 | enemy.wounded = false 335 | end 336 | 337 | if not state.vid then 338 | local dist = UTIL.random(2, 4, 1) 339 | local dir = vec_rot_range(enemy.dir, 180) 340 | local pos = vec_offset(enemy.pos, dir, dist) 341 | 342 | state.vid = POS.bestOutsideUnclaimedLVID(npc, npc:position(), pos) 343 | end 344 | end 345 | 346 | 347 | function action_combat_guard:attack() 348 | local lastState = self.st.lastState 349 | local config = self.st.config 350 | local enemy = self.st.enemy 351 | local state = self.st.state 352 | local npc = self.object 353 | 354 | if state.reached and enemy.mutant and enemy.dist < config.mutantDist then 355 | state.vid, state.expires = nil, nil 356 | 357 | elseif state.reached and state.reloading and state.cover < 3 then 358 | state.vid, state.expires = nil, nil 359 | 360 | elseif state.reached and not state.expires and (state.cover < 2 or state.cover > 4) then 361 | state.expires = time_plus_rand(config.moveDelay) 362 | end 363 | 364 | if not state.vid then 365 | local points = VEC.pointsAlongAxis({ 366 | direction = vec(enemy.dir):invert(), 367 | position = state.startPos, 368 | arcAngle = 360, 369 | rows = 3, 370 | radius = 4, 371 | spacing = 2, 372 | rowSpacing = 3, 373 | }) 374 | 375 | local best = POS.pickByBestCover(npc, points, { 376 | order = COMBAT.COVER_ORDER[state.coverOrder], 377 | findFn = POS.bestOutsideUnclaimedLVID, 378 | findFrom = state.startPos, 379 | enemyPos = enemy.pos, 380 | pickMethod = "random", 381 | }) 382 | 383 | if best then 384 | state.vid = best.vid 385 | end 386 | end 387 | end 388 | -- 389 | 390 | 391 | -- BINDER -- 392 | function add_to_binder(npc, ini, storage, planner, temp) 393 | planner:add_evaluator(EVAL_ID, 394 | evaluator_combat_type("combat_guard", storage, "guard") 395 | ) 396 | 397 | local action = action_combat_guard("combat_guard", storage) 398 | 399 | if action then 400 | action:add_precondition(WP(EVAL_ENEMY, true)) 401 | action:add_precondition(WP(EVAL_ID, true)) 402 | action:add_precondition(WP(EVAL_FACER, false)) 403 | action:add_precondition(WP(EVAL_STEAL_UP, false)) 404 | 405 | action:add_effect(WP(EVAL_LOGIC, false)) 406 | action:add_effect(WP(EVAL_ENEMY, false)) 407 | action:add_effect(WP(EVAL_ID, false)) 408 | 409 | planner:add_action(ACTION_ID, action) 410 | end 411 | end 412 | -- 413 | -------------------------------------------------------------------------------- /gamedata/scripts/idiots_keybinds.script: -------------------------------------------------------------------------------- 1 | local UI = idiots_ui 2 | local WPN = require "illish.lib.weapon" 3 | local NPC = require "illish.lib.npc" 4 | local BEH = require "illish.lib.beh" 5 | 6 | 7 | local flash = particles_object("_samples_particles_\\flash_light") 8 | 9 | 10 | function playParticle(pos) 11 | if flash:playing() then 12 | RemoveTimeEvent("idiots", "move_to_point") 13 | flash:stop() 14 | end 15 | 16 | CreateTimeEvent("idiots", "move_to_point", 1, function() 17 | RemoveTimeEvent("idiots", "move_to_point") 18 | flash:stop() 19 | end) 20 | 21 | flash:play_at_pos(vec(pos):add(0, -0.5, 0)) 22 | end 23 | 24 | 25 | function playVoiceover(group, action, GUI) 26 | if not GUI.PlayVoiceOver then 27 | return 28 | end 29 | 30 | local enabled = NPC.getState(GUI.ID, group, action) 31 | 32 | if group == "movement" and action == "follow" and not enabled then 33 | GUI:PlayVoiceOver("movement", 0) 34 | end 35 | 36 | if group == "movement" and action == "wait" and not enabled then 37 | GUI:PlayVoiceOver("movement", 2) 38 | end 39 | 40 | if group == "readiness" and action == "ignore" and not enabled then 41 | GUI:PlayVoiceOver("combat", 1) 42 | end 43 | 44 | if group == "readiness" and action == "attack" and not enabled then 45 | GUI:PlayVoiceOver("combat", 0) 46 | end 47 | 48 | if group == "jobs" and action == "loot_corpses" then 49 | GUI:PlayVoiceOver("loot", enabled and 1 or 0) 50 | end 51 | 52 | if group == "distance" and action == "far" then 53 | GUI:PlayVoiceOver("distance", enabled and 0 or 1) 54 | end 55 | 56 | if group == "stance" and action == "stand" and not enabled then 57 | GUI:PlayVoiceOver("stealth", 1) 58 | end 59 | 60 | if group == "stance" and action == "sneak" and not enabled then 61 | GUI:PlayVoiceOver("stealth", 0) 62 | end 63 | end 64 | 65 | 66 | function showMessage(key, ...) 67 | local message = game.translate_string(key) 68 | actor_menu.set_msg(1, string.format(message, ...), 5) 69 | end 70 | 71 | 72 | function showStateMessage(group, action, enabled, toggle) 73 | local groupLabel = group 74 | and game.translate_string("st_idiots_" .. group) 75 | 76 | local actionLabel = group 77 | and game.translate_string("st_idiots_" .. group .. "_" .. action) 78 | or game.translate_string("st_idiots_" .. action) 79 | 80 | local statusLabel = enabled 81 | and game.translate_string("st_idiots_enabled_message") 82 | or game.translate_string("st_idiots_disabled_message") 83 | 84 | if group and not toggle 85 | then showMessage(groupLabel .. ": " .. actionLabel) 86 | else showMessage(actionLabel .. ": " .. statusLabel) 87 | end 88 | end 89 | 90 | 91 | function onKeybindEvent(event, dik) 92 | local companions = NPC.getCompanions() 93 | 94 | if #companions == 0 then 95 | return 96 | end 97 | 98 | local selected = #companions == 1 99 | and companions 100 | or table.keys(NPC.SELECTED_IDS) 101 | 102 | if checkKeybind(event, dik, "point", "select") then 103 | if #companions > 1 then 104 | local target = NPC.getTargetCompanion() 105 | 106 | if target then 107 | local result = NPC.select(target) 108 | local index = NPC.indexOfCompanion(target) 109 | local count = #table.keys(NPC.SELECTED_IDS) 110 | 111 | if result 112 | then showMessage("st_idiots_select_message", index, count) 113 | elseif count > 0 114 | then showMessage("st_idiots_deselect_message", index, count) 115 | else showMessage("st_idiots_deselect_all_message") 116 | end 117 | end 118 | end 119 | end 120 | 121 | if checkKeybind(event, dik, "point", "deselect") then 122 | if #companions > 1 then 123 | if NPC.deselectAll() then 124 | showMessage("st_idiots_deselect_all_message") 125 | end 126 | end 127 | end 128 | 129 | if checkKeybind(event, dik, "point", "move") then 130 | local autoWait = ui_mcm.get("idiots/options/autoWait") 131 | local autoGuard = ui_mcm.get("idiots/options/autoGuard") 132 | 133 | local npcs = #selected > 0 and selected or companions 134 | local pos = level.get_target_pos() 135 | 136 | for i, npc in ipairs(npcs) do 137 | if type(npc) == "number" then 138 | npc = NPC.getCompanion(npc) 139 | end 140 | 141 | if npc then 142 | NPC.moveToPoint(npc, pos) 143 | if autoWait and #selected > 0 then 144 | NPC.setState(npc, "movement", "wait", true) 145 | end 146 | if autoGuard and #selected > 0 then 147 | NPC.setState(npc, "combat", "guard", true) 148 | end 149 | end 150 | end 151 | 152 | if autoWait and #selected == 0 then 153 | NPC.setState(nil, "movement", "wait", true) 154 | end 155 | if autoGuard and #selected == 0 then 156 | NPC.setState(nil, "combat", "guard", true) 157 | end 158 | 159 | if #selected > 0 160 | then showMessage("st_idiots_move_point_message", #selected) 161 | else showMessage("st_idiots_move_all_point_message") 162 | end 163 | 164 | if ui_mcm.get("idiots/options/autoDeselect") then 165 | NPC.SELECTED_IDS = {} 166 | end 167 | 168 | -- gestures mod 169 | if gesture and gesture.play_stuff then 170 | gesture.play_stuff("gesture_sound", "point_forward", "anm_point", true, true) 171 | end 172 | 173 | playParticle(pos) 174 | end 175 | 176 | if checkKeybind(event, dik, "point", "look") then 177 | local pos = level.get_target_pos() 178 | local npcs = #selected > 0 and selected or companions 179 | 180 | for i, npc in ipairs(npcs) do 181 | if type(npc) == "number" then 182 | npc = NPC.getCompanion(npc) 183 | end 184 | if npc then 185 | NPC.lookAtPoint(npc, pos) 186 | end 187 | end 188 | 189 | if ui_mcm.get("idiots/options/autoDeselect") then 190 | NPC.SELECTED_IDS = {} 191 | end 192 | 193 | playParticle(pos) 194 | end 195 | 196 | if checkKeybind(event, dik, "point", "away") then 197 | local npcs = NPC.getBlockingCompanions() 198 | 199 | if #npcs > 0 then 200 | for i, npc in ipairs(npcs) do 201 | NPC.moveOutOfTheWay(npc) 202 | end 203 | showMessage("st_idiots_move_away_message", #npcs) 204 | end 205 | end 206 | 207 | if checkKeybind(event, dik, "point", "addWaypoint") then 208 | if #selected == 1 then 209 | local pos = level.get_target_pos() 210 | local npc = selected[1] 211 | 212 | if NPC.isCompanion(npc) then 213 | axr_companions.companion_add_waypoints(npc, pos) 214 | end 215 | 216 | showMessage("st_idiots_add_waypoint_message") 217 | playParticle(pos) 218 | else 219 | showMessage("st_idiots_waypoint_error") 220 | end 221 | end 222 | 223 | if checkKeybind(event, dik, "point", "clearWaypoints") then 224 | if #selected == 1 then 225 | local npc = selected[1] 226 | local waypoints = BEH.getAllWaypoints(npc) 227 | 228 | if waypoints and #waypoints > 0 then 229 | axr_companions.companion_remove_waypoints(npc) 230 | showMessage("st_idiots_clear_waypoints_message") 231 | end 232 | else 233 | showMessage("st_idiots_waypoint_error") 234 | end 235 | end 236 | 237 | if checkKeybind(event, dik, "point", "inventory") then 238 | local npc = NPC.getTargetCompanion(8) 239 | if npc then 240 | ui_companion_inv.start(npc) 241 | end 242 | end 243 | 244 | if checkKeybind(event, dik, "point", "reset") then 245 | local npcs = #selected > 0 and selected or companions 246 | 247 | for i, npc in ipairs(npcs) do 248 | if NPC.isCompanion(npc) then 249 | NPC.setStates(npc, NPC.GLOBAL_STATE) 250 | end 251 | end 252 | 253 | if #selected > 0 254 | then showMessage("st_idiots_resync_message", #selected) 255 | else showMessage("st_idiots_resync_all_message") 256 | end 257 | end 258 | 259 | if checkKeybind(event, dik, "point", "reload") then 260 | local npcs = #selected > 0 and selected or companions 261 | 262 | local wmode = ui_mcm.get("idiots/options/manualReloadAll") 263 | and WPN.RELOAD_ALL 264 | or WPN.RELOAD_ACTIVE 265 | 266 | for i, npc in ipairs(npcs) do 267 | if NPC.isCompanion(npc) then 268 | NPC.setReloadModes(npc, wmode, WPN.NOT_FULL) 269 | end 270 | end 271 | end 272 | 273 | if checkKeybind(event, dik, "point", "retreat") then 274 | if #selected > 0 then 275 | for i, npc in ipairs(selected) do 276 | if NPC.isCompanion(npc) then 277 | NPC.setState(npc, "movement", "follow", true) 278 | NPC.setState(npc, "readiness", "ignore", true) 279 | NPC.setState(npc, "speed", "hurry", true) 280 | NPC.setState(npc, "distance", "near", true) 281 | end 282 | end 283 | else 284 | NPC.setState(nil, "movement", "follow", true) 285 | NPC.setState(nil, "readiness", "ignore", true) 286 | NPC.setState(nil, "speed", "hurry", true) 287 | NPC.setState(nil, "distance", "near", true) 288 | end 289 | 290 | if #selected > 0 291 | then showMessage("st_idiots_retreat_message", #selected) 292 | else showMessage("st_idiots_retreat_all_message") 293 | end 294 | end 295 | 296 | for ig, group in ipairs(NPC.ACTIONS) do 297 | if group.cycle then 298 | checkKeybind(event, dik, group.name, "cycle_" .. group.name) 299 | end 300 | 301 | for ia, action in ipairs(group.actions) do 302 | checkKeybind(event, dik, group.name, action.name) 303 | end 304 | end 305 | end 306 | 307 | 308 | function checkKeybind(event, dik, group, action) 309 | local companions = NPC.getCompanions() 310 | if #companions == 0 then 311 | return 312 | end 313 | 314 | local uuid = "idiots" 315 | local kformat = "idiots/keybinds/%s/%s_%s" 316 | 317 | local key = ui_mcm.get(kformat:format(group, action, "key")) 318 | local mod = ui_mcm.get(kformat:format(group, action, "mod")) 319 | local mode = ui_mcm.get(kformat:format(group, action, "mode")) 320 | 321 | if not (key and key > 0 and key == dik and ui_mcm.get_mod_key(mod)) then 322 | return false 323 | end 324 | 325 | local trigger = false 326 | 327 | if event == "hold" and mode == 2 and ui_mcm.key_hold(uuid, key) 328 | then trigger = true 329 | elseif event == "press" and mode == 1 and ui_mcm.double_tap(uuid, key) 330 | then trigger = true 331 | elseif event == "press" and mode == 0 332 | then trigger = true 333 | end 334 | 335 | if not trigger then 336 | return false 337 | end 338 | 339 | local selected = #companions == 1 340 | and companions 341 | or table.keys(NPC.SELECTED_IDS) 342 | 343 | if action == "cycle_" .. group then 344 | local nextState 345 | 346 | if #selected > 0 then 347 | for i, npc in ipairs(selected) do 348 | if i == 1 then 349 | nextState = NPC.cycleActiveState(npc, group) 350 | else 351 | NPC.setState(npc, group, nextState, true) 352 | end 353 | end 354 | else 355 | nextState = NPC.cycleActiveState(nil, group) 356 | end 357 | 358 | if nextState then 359 | showStateMessage(group, nextState, true) 360 | end 361 | end 362 | 363 | if not NPC.isStateful(group, action) then 364 | return true 365 | end 366 | 367 | if group == "jobs" then 368 | local artifacts = ui_mcm.get("idiots/options/artifacts") 369 | 370 | if action == "artifacts" and not artifacts then 371 | return true 372 | end 373 | end 374 | 375 | if action == "patrol" then 376 | if #selected ~= 1 then 377 | return true 378 | end 379 | 380 | local waypoints = BEH.getAllWaypoints(selected[1]) 381 | 382 | if not (waypoints and #waypoints >= 2) then 383 | return true 384 | end 385 | end 386 | 387 | local toggleableGroup = NPC.ACTIONS_KEYED[group].toggle 388 | 389 | local toggleableAction = toggleableGroup 390 | and ui_mcm.get(kformat:format(group, action, "toggle")) 391 | 392 | local nextState 393 | 394 | if #selected > 0 then 395 | for i, npc in ipairs(selected) do 396 | if i == 1 then 397 | if toggleableAction then 398 | nextState = NPC.toggleState(npc, group, action) 399 | elseif not NPC.getState(npc, group, action) then 400 | NPC.setState(npc, group, action, true) 401 | nextState = true 402 | end 403 | else 404 | NPC.setState(npc, group, action, nextState) 405 | end 406 | end 407 | 408 | elseif toggleableAction then 409 | nextState = NPC.toggleState(nil, group, action) 410 | 411 | elseif not NPC.getState(nil, group, action) then 412 | NPC.setState(nil, group, action, true) 413 | nextState = true 414 | end 415 | 416 | if nextState ~= nil then 417 | showStateMessage(group, action, nextState, toggleableGroup) 418 | end 419 | 420 | return true 421 | end 422 | 423 | 424 | function onKeyRelease(key) 425 | if (dik_to_bind(key) == key_bindings.kCUSTOM18) then 426 | UI.openUI() 427 | end 428 | end 429 | 430 | 431 | function on_game_start() 432 | RegisterScriptCallback("on_key_release", onKeyRelease) 433 | RegisterScriptCallback("idiots_on_use_button", playVoiceover) 434 | 435 | RegisterScriptCallback("on_key_press", function(dik) 436 | onKeybindEvent("press", dik) 437 | end) 438 | 439 | RegisterScriptCallback("on_key_hold", function(dik) 440 | onKeybindEvent("hold", dik) 441 | end) 442 | end 443 | -------------------------------------------------------------------------------- /gamedata/scripts/idiots_combat_support.script: -------------------------------------------------------------------------------- 1 | local WP = world_property 2 | local UTIL = require "illish.lib.util" 3 | local VEC = require "illish.lib.vector" 4 | local POS = require "illish.lib.pos" 5 | local NPC = require "illish.lib.npc" 6 | local COMBAT = require "illish.lib.combat" 7 | 8 | EVAL_FF = rx_ff.evaid 9 | EVAL_FACER = xrs_facer.evid_facer 10 | EVAL_STEAL_UP = xrs_facer.evid_steal_up_facer 11 | EVAL_ENEMY = stalker_ids.property_enemy 12 | EVAL_LOGIC = xr_evaluators_id.state_mgr + 4 13 | EVAL_ID = stalker_ids.property_script + 3180 14 | ACTION_ID = xr_actions_id.chugai_actions + 180 15 | 16 | 17 | -- EVALUATOR -- 18 | class "evaluator_combat_type" (property_evaluator) 19 | 20 | 21 | function evaluator_combat_type:__init(name, storage, type) super(nil, name) 22 | self.st = storage 23 | self.type = type 24 | end 25 | 26 | 27 | function evaluator_combat_type:evaluate() 28 | local npc = self.object 29 | return db.storage[npc:id()].script_combat_type == self.type 30 | end 31 | -- 32 | 33 | 34 | -- ACTION METHODS -- 35 | class "action_combat_support" (action_base) 36 | 37 | 38 | function action_combat_support:__init(name, storage) super(nil, name) 39 | self.st = storage 40 | end 41 | 42 | 43 | function action_combat_support:initialize() 44 | action_base.initialize(self) 45 | self:initConfig() 46 | self:initEnemy() 47 | self:initState() 48 | self:updateEnemy() 49 | self:updateState() 50 | end 51 | 52 | 53 | function action_combat_support:execute() 54 | action_base.execute(self) 55 | 56 | self:updateEnemy() 57 | self:updateState() 58 | 59 | local config = self.st.config 60 | local state = self.st.state 61 | local npc = self.object 62 | 63 | if state.action == "dodge" 64 | then self:dodge() 65 | elseif state.action == "movePoint" 66 | then self:movePoint() 67 | elseif state.action == "ffstrafe" 68 | then self:ffstrafe() 69 | elseif state.action == "push" 70 | then self:push() 71 | elseif state.action == "attack" 72 | then self:attack() 73 | elseif state.action == "idle" 74 | then state.vid = npc:level_vertex_id() 75 | end 76 | 77 | if not POS.isValidLVID(npc, state.vid) then 78 | state.vid = npc:level_vertex_id() 79 | state.expires = time_plus(config.vidRetry) 80 | end 81 | 82 | state.reached = state.vid == npc:level_vertex_id() 83 | POS.setLVID(npc, state.vid) 84 | 85 | local move = COMBAT.getCombatMoveState(self) 86 | local look = COMBAT.getCombatLookState(self) 87 | 88 | state_mgr.set_state(npc, move, nil, nil, look, {fast_set = true}) 89 | end 90 | -- 91 | 92 | 93 | -- STATE -- 94 | function action_combat_support:initConfig() 95 | local st = self.st 96 | 97 | st.config = { 98 | alwaysSee = 2, 99 | moveDist = 8, 100 | mutantDist = 12, 101 | vidRetry = 1000, 102 | useTeamSight = true, 103 | maxDist = {8, 16, 24}, 104 | recoverHealth = {0.32, 0.48}, 105 | spottedTimeout = {800, 1200}, 106 | lookTimeout = {2500, 5000}, 107 | moveDelay = {2800, 4800}, 108 | ffDelay = {1200, 1600}, 109 | } 110 | end 111 | 112 | 113 | function action_combat_support:initEnemy() 114 | local config = self.st.config 115 | local npc = self.object 116 | local st = self.st 117 | 118 | self.enemySpace = POS.assessSpace 119 | 120 | st.enemy = { 121 | id = nil, 122 | pos = nil, 123 | dist = nil, 124 | dir = nil, 125 | spottedUntil = nil, 126 | spotted = true, 127 | seen = false, 128 | wounded = false, 129 | mutant = false, 130 | } 131 | end 132 | 133 | 134 | function action_combat_support:updateEnemy() 135 | self.st.lastEnemy = dup_table(self.st.enemy) 136 | 137 | local config = self.st.config 138 | local enemy = self.st.enemy 139 | local npc = self.object 140 | 141 | local be = npc:best_enemy() 142 | if not be then 143 | return 144 | end 145 | 146 | if enemy.id ~= be:id() then 147 | enemy.spotted = true 148 | end 149 | 150 | enemy.seen = npc:see(be) or COMBAT.hasLineOfSight(npc, be) 151 | enemy.mutant = IsMonster(be) 152 | enemy.id = be:id() 153 | 154 | local reset = false 155 | local pos = false 156 | 157 | if enemy.seen or enemy.spotted then 158 | reset = true 159 | pos = true 160 | 161 | elseif distance_between(be, npc) <= config.alwaysSee then 162 | reset = true 163 | pos = true 164 | 165 | elseif config.useTeamSight and COMBAT.teamSeesEnemy(npc, be) then 166 | reset = true 167 | pos = true 168 | 169 | elseif not time_expired(enemy.spottedUntil) then 170 | pos = true 171 | end 172 | 173 | if pos then 174 | enemy.pos = utils_obj.safe_bone_pos(be, "bip01_head") 175 | enemy.wounded = IsWounded(be) 176 | end 177 | 178 | if reset then 179 | enemy.spottedUntil = time_plus_rand(config.spottedTimeout) 180 | end 181 | 182 | enemy.dir = vec_dir(utils_obj.safe_bone_pos(npc, "bip01_r_finger02"), enemy.pos) 183 | enemy.dist = vec_dist(npc:position(), enemy.pos) 184 | 185 | enemy.spotted = false 186 | end 187 | 188 | 189 | function action_combat_support:initState() 190 | local config = self.st.config 191 | local st = self.st 192 | 193 | self.isReloading = NPC.isReloading 194 | self.assessCover = POS.assessCover 195 | self.nearbyGrenades = POS.nearbyGrenades 196 | self.getWeaponType = NPC.getWeaponType 197 | self.getActorMovement = COMBAT.getActorMovement 198 | 199 | st.state = { 200 | action = nil, 201 | vid = nil, 202 | expires = nil, 203 | grenades = nil, 204 | weapon = nil, 205 | cover = nil, 206 | coverOrder = nil, 207 | actorPos = nil, 208 | maxDist = nil, 209 | keepType = nil, 210 | moveDist = nil, 211 | ffDelay = nil, 212 | reached = false, 213 | reloading = false, 214 | recovering = false, 215 | dontShoot = false, 216 | } 217 | end 218 | 219 | 220 | function action_combat_support:updateState() 221 | self.st.lastState = dup_table(self.st.state) 222 | 223 | local lastState = self.st.lastState 224 | local config = self.st.config 225 | local state = self.st.state 226 | local enemy = self.st.enemy 227 | local npc = self.object 228 | 229 | state.moveDist = self:getActorMovement() 230 | 231 | if not state.actorPos or state.moveDist >= config.moveDist then 232 | state.actorPos = db.actor:position() 233 | end 234 | 235 | state.keepType = NPC.getActiveState(npc, "distance") 236 | state.grenades = self.nearbyGrenades(npc:position(), config.dodgeDist) 237 | state.cover = self.assessCover(npc:position(), enemy.pos) 238 | state.dontShoot = db.storage[npc:id()].rx_dont_shoot 239 | state.weapon = self.getWeaponType(npc) 240 | state.reloading = self.isReloading(npc) 241 | 242 | state.maxDist = nil 243 | or state.keepType == "near" and config.maxDist[1] 244 | or state.keepType == "far" and config.maxDist[3] 245 | or config.maxDist[2] 246 | 247 | state.recovering = npc.health <= config.recoverHealth[1] 248 | or lastState.recovering and npc.health <= config.recoverHealth[2] 249 | 250 | state.coverOrder = (state.recovering or state.reloading) 251 | and "peek" 252 | or "shoot" 253 | 254 | if state.dontShoot then 255 | state.ffTimeout = time_plus(500) 256 | if state.reached and not state.ffDelay then 257 | state.ffDelay = time_plus_rand(config.ffDelay) 258 | end 259 | end 260 | 261 | if state.grenades then 262 | state.action = "dodge" 263 | 264 | elseif self.st.movePoint then 265 | state.action = "movePoint" 266 | 267 | elseif time_expired(state.ffDelay) and not time_expired(state.ffTimeout) then 268 | state.action = "ffstrafe" 269 | 270 | elseif enemy.wounded and not enemy.mutant and enemy.dist <= state.maxDist then 271 | state.action = "push" 272 | 273 | else 274 | state.action = "attack" 275 | end 276 | 277 | if lastState.action ~= state.action then 278 | if (lastState.action == "ffstrafe" or lastState.action == "movePoint") and state.action ~= "dodge" then 279 | state.vid = npc:level_vertex_id() 280 | state.expires = nil 281 | else 282 | state.expires = 0 283 | end 284 | end 285 | 286 | if time_expired(state.expires) then 287 | state.expires = nil 288 | state.vid = nil 289 | end 290 | 291 | state.reached = state.vid == npc:level_vertex_id() 292 | 293 | if state.reached then 294 | self.st.moveState = nil 295 | end 296 | end 297 | -- 298 | 299 | 300 | -- ACTIONS -- 301 | function action_combat_support:dodge() 302 | local config = self.st.config 303 | local state = self.st.state 304 | local npc = self.object 305 | 306 | if state.vid then 307 | local dir = vec_dir(npc:position(), lvpos(state.vid)) 308 | if vec_dot(dir, state.grenades.avgDir) > 0 then 309 | state.vid, state.expires = nil, nil 310 | end 311 | end 312 | 313 | if not state.vid then 314 | local dist = UTIL.random(10, 14, 1) 315 | local rot = 180 + UTIL.randomRange(45) 316 | local dir = vec_rot(state.grenades.avgDir, rot) 317 | local pos = vec_offset(npc:position(), dir, dist) 318 | 319 | state.vid = POS.bestOutsideValidLVID(npc, npc:position(), pos) 320 | end 321 | end 322 | 323 | 324 | function action_combat_support:movePoint() 325 | local state = self.st.state 326 | 327 | if state.reached then 328 | self.st.movePoint = nil 329 | end 330 | 331 | if not state.vid then 332 | state.vid = self.st.movePoint 333 | end 334 | end 335 | 336 | 337 | function action_combat_support:ffstrafe() 338 | local config = self.st.config 339 | local state = self.st.state 340 | local enemy = self.st.enemy 341 | local npc = self.object 342 | 343 | if state.reached and not state.expires then 344 | state.expires = time_plus_rand(config.ffDelay) 345 | end 346 | 347 | if not state.vid then 348 | state.vid = POS.getStrafePos(npc, {enemyPos = enemy.pos}) 349 | end 350 | end 351 | 352 | 353 | function action_combat_support:push() 354 | local state = self.st.state 355 | local enemy = self.st.enemy 356 | local npc = self.object 357 | 358 | if state.reached and not state.expires then 359 | state.expires = time_plus_rand(2500, 4000) 360 | end 361 | 362 | if state.reached and time_expired(state.expires) then 363 | enemy.wounded = false 364 | end 365 | 366 | if not state.vid then 367 | local dist = UTIL.random(2, 4, 1) 368 | local dir = vec_rot_range(enemy.dir, 180) 369 | local pos = vec_offset(enemy.pos, dir, dist) 370 | 371 | state.vid = POS.bestOutsideUnclaimedLVID(npc, npc:position(), pos) 372 | end 373 | end 374 | 375 | 376 | function action_combat_support:attack() 377 | local lastState = self.st.lastState 378 | local config = self.st.config 379 | local enemy = self.st.enemy 380 | local state = self.st.state 381 | local npc = self.object 382 | 383 | if state.moveDist > config.moveDist then 384 | state.vid, state.expires = nil, nil 385 | 386 | elseif state.reached and enemy.mutant and enemy.dist < config.mutantDist then 387 | state.vid, state.expires = nil, nil 388 | 389 | elseif state.keepType ~= lastState.keepType then 390 | state.vid, state.expires = nil, nil 391 | 392 | elseif state.reached and state.reloading and state.cover < 3 then 393 | state.vid, state.expires = nil, nil 394 | 395 | elseif state.reached and not state.expires and (state.cover < 2 or state.cover > 4) then 396 | state.expires = time_plus_rand(config.moveDelay) 397 | end 398 | 399 | if not state.vid then 400 | local findFn = state.keepType == "near" and POS.assessSpace(state.actorPos) ~= "open" 401 | and POS.bestInsideUnclaimedLVID 402 | or POS.bestOutsideUnclaimedLVID 403 | 404 | local points = VEC.pointsAlongAxis({ 405 | direction = vec_dir(enemy.pos, state.actorPos), 406 | radius = state.maxDist, 407 | position = state.actorPos, 408 | arcAngle = 225, 409 | rows = 3, 410 | spacing = 2, 411 | rowSpacing = 3, 412 | }) 413 | 414 | local best = POS.pickByBestCover(npc, points, { 415 | order = COMBAT.COVER_ORDER[state.coverOrder], 416 | enemyPos = enemy.pos, 417 | findFn = findFn, 418 | findFrom = state.actorPos, 419 | }) 420 | 421 | if best then 422 | state.vid = best.vid 423 | end 424 | end 425 | end 426 | -- 427 | 428 | 429 | -- BINDER -- 430 | function add_to_binder(npc, ini, storage, planner, temp) 431 | planner:add_evaluator(EVAL_ID, 432 | evaluator_combat_type("combat_support", storage, "support") 433 | ) 434 | 435 | local action = action_combat_support("combat_support", storage) 436 | 437 | if action then 438 | action:add_precondition(WP(EVAL_ENEMY, true)) 439 | action:add_precondition(WP(EVAL_ID, true)) 440 | action:add_precondition(WP(EVAL_FACER, false)) 441 | action:add_precondition(WP(EVAL_STEAL_UP, false)) 442 | 443 | action:add_effect(WP(EVAL_LOGIC, false)) 444 | action:add_effect(WP(EVAL_ENEMY, false)) 445 | action:add_effect(WP(EVAL_ID, false)) 446 | 447 | planner:add_action(ACTION_ID, action) 448 | end 449 | end 450 | -- 451 | -------------------------------------------------------------------------------- /gamedata/scripts/illish/lib/pos.lua: -------------------------------------------------------------------------------- 1 | local UTIL = require "illish.lib.util" 2 | local TABLE = require "illish.lib.table" 3 | local VEC = require "illish.lib.vector" 4 | local RAY = require "illish.lib.ray" 5 | 6 | 7 | local POS = {} 8 | 9 | 10 | -- CONSTANTS -- 11 | POS.INVALID_LVID = 4294967295 12 | 13 | POS.COVER = {0.3, 0.7, 0.9, 1.3, 1.5, 1.8} 14 | POS.COVER.SHOOT_LOW = 0 15 | POS.COVER.PEEK_LOW = POS.COVER[1] 16 | POS.COVER.SHOOT_MID = POS.COVER[2] 17 | POS.COVER.PEEK_MID = POS.COVER[3] 18 | POS.COVER.SHOOT_HIGH = POS.COVER[4] 19 | POS.COVER.PEEK_HIGH = POS.COVER[5] 20 | POS.COVER.FULL = POS.COVER[6] 21 | 22 | POS.AIM = {0.22, 0.94, 1.50} 23 | POS.AIM.LOW = POS.AIM[1] 24 | POS.AIM.MID = POS.AIM[2] 25 | POS.AIM.HIGH = POS.AIM[3] 26 | -- 27 | 28 | 29 | -- CONVERT -- 30 | function POS.lvid(pos) 31 | return level.vertex_id(pos) or 4294967295 32 | end 33 | 34 | 35 | function POS.position(vid) 36 | return VEC.set(level.vertex_position(vid)) 37 | end 38 | 39 | 40 | function POS.snap(pos) 41 | local vid = POS.lvid(pos) 42 | local pos = POS.position(vid) 43 | return pos, vid 44 | end 45 | -- 46 | 47 | 48 | -- CLAIM LVID -- 49 | function POS.claimLVID(npc, vid) 50 | POS.unclaimLVID(npc) 51 | db.used_level_vertex_ids[vid] = npc:id() 52 | end 53 | 54 | 55 | function POS.unclaimLVID(npc, vid) 56 | local used = db.used_level_vertex_ids 57 | 58 | if vid and used[vid] == npc:id() then 59 | used[vid] = nil 60 | return 61 | end 62 | 63 | for v, id in pairs(used) do 64 | if id == npc:id() then 65 | used[v] = nil 66 | end 67 | end 68 | end 69 | 70 | 71 | function POS.setLVID(npc, vid) 72 | POS.clearLVID(npc) 73 | POS.claimLVID(npc, vid) 74 | npc:set_dest_level_vertex_id(vid) 75 | end 76 | 77 | 78 | function POS.clearLVID(npc) 79 | POS.unclaimLVID(npc) 80 | npc:set_desired_position() 81 | npc:set_desired_direction() 82 | npc:set_path_type(game_object.level_path) 83 | npc:set_detail_path_type(move.line) 84 | end 85 | -- 86 | 87 | 88 | -- VALIDATE LVID -- 89 | function POS.isValidLVID(npc, vid) 90 | return npc and vid 91 | and vid ~= POS.INVALID_LVID 92 | and npc:accessible(vid) 93 | end 94 | 95 | 96 | function POS.isUnclaimedLVID(npc, vid, space) 97 | space = space or 0.4 98 | 99 | if not POS.isValidLVID(npc, vid) then 100 | return false 101 | end 102 | 103 | if space > 0 then 104 | for v, id in pairs(db.used_level_vertex_ids) do 105 | if id ~= npc:id() then 106 | if v == vid or VEC.distance(POS.position(v), POS.position(vid)) < space then 107 | return false 108 | end 109 | end 110 | end 111 | end 112 | 113 | return true 114 | end 115 | 116 | 117 | function POS.isUnoccupiedLVID(npc, vid, space) 118 | local space = space or 0.8 119 | 120 | if not POS.isUnclaimedLVID(npc, vid, space) then 121 | return false 122 | end 123 | 124 | local pos = POS.position(vid) 125 | local unoccupied = true 126 | 127 | level.iterate_nearest(pos, space + 0.1, function(obj) 128 | if not IsStalker(obj) or obj:id() == npc:id() then 129 | return 130 | end 131 | 132 | local objPos = obj:position() 133 | local used = TABLE.keyof(db.used_level_vertex_ids, obj:id()) 134 | 135 | if used and used ~= vid then 136 | objPos = POS.position(used) 137 | end 138 | 139 | if VEC.distance(pos, objPos) < space then 140 | unoccupied = false 141 | return true 142 | end 143 | end) 144 | 145 | return unoccupied 146 | end 147 | -- 148 | 149 | 150 | -- GET LVID -- 151 | function POS.bestOutsideLVID(npc, fromPos, pos, validator, spacing) 152 | spacing = spacing or 0.8 153 | 154 | local dirs = 8 155 | local maxDist = 8 156 | local baseAngle = 360 / dirs 157 | local baseDir = VEC.direction(fromPos, pos) 158 | 159 | for dist = 0, maxDist, spacing do 160 | for i = 0, dirs - 1 do 161 | for f = -1, 1, 2 do 162 | local dir = VEC.rotate(baseDir, baseAngle * i * f) 163 | local vid = POS.lvid(VEC.offset(pos, dir, dist)) 164 | 165 | if validator(npc, vid, spacing) then 166 | return vid 167 | end 168 | end 169 | end 170 | end 171 | 172 | return POS.INVALID_LVID 173 | end 174 | 175 | 176 | function POS.bestOutsideValidLVID(npc, fromPos, pos, spacing) 177 | return POS.bestOutsideLVID(npc, fromPos, pos, POS.isValidLVID, spacing) 178 | end 179 | 180 | 181 | function POS.bestOutsideUnclaimedLVID(npc, fromPos, pos, spacing) 182 | return POS.bestOutsideLVID(npc, fromPos, pos, POS.isUnclaimedLVID, spacing) 183 | end 184 | 185 | 186 | function POS.bestOutsideUnoccupiedLVID(npc, fromPos, pos, spacing) 187 | return POS.bestOutsideLVID(npc, fromPos, pos, POS.isUnoccupiedLVID, spacing) 188 | end 189 | 190 | 191 | function POS.bestInsideLVID(npc, fromPos, pos, validator, spacing) 192 | spacing = spacing or 1 193 | 194 | local castY = 1.9 195 | local basePos = VEC.set(fromPos):add(0, castY, 0) 196 | local baseDist = VEC.distance(basePos, pos) 197 | local baseDir = VEC.direction(basePos, pos) 198 | baseDir.y = 0 199 | 200 | local castDist = RAY.distance(basePos, baseDir, baseDist) 201 | local dist = castDist 202 | 203 | while dist > 0 do 204 | local vid = POS.lvid(VEC.offset(basePos, baseDir, dist)) 205 | 206 | if validator(npc, vid, spacing) then 207 | return vid 208 | end 209 | 210 | dist = dist - spacing 211 | end 212 | 213 | return POS.INVALID_LVID 214 | end 215 | 216 | 217 | function POS.bestInsideValidLVID(npc, fromPos, pos, spacing) 218 | return POS.bestInsideLVID(npc, fromPos, pos, POS.isValidLVID, spacing) 219 | end 220 | 221 | 222 | function POS.bestInsideUnclaimedLVID(npc, fromPos, pos, spacing) 223 | return POS.bestInsideLVID(npc, fromPos, pos, POS.isUnclaimedLVID, spacing) 224 | end 225 | 226 | 227 | function POS.bestInsideUnoccupiedLVID(npc, fromPos, pos, spacing) 228 | return POS.bestInsideLVID(npc, fromPos, pos, POS.isUnoccupiedLVID, spacing) 229 | end 230 | 231 | 232 | function POS.legacyLVID(npc, fromPos, pos, validator, spacing) 233 | spacing = spacing or 1 234 | 235 | local dist = VEC.distance(fromPos, pos) 236 | local dir = VEC.direction(fromPos, pos) 237 | 238 | while dist > 0 do 239 | local vid = level.vertex_in_direction(POS.lvid(fromPos), dir, dist) 240 | 241 | if validator(npc, vid, spacing) then 242 | return vid 243 | end 244 | 245 | dist = dist - spacing 246 | end 247 | 248 | return POS.INVALID_LVID 249 | end 250 | 251 | 252 | function POS.legacyValidLVID(npc, fromPos, pos, spacing) 253 | return POS.legacyLVID(npc, fromPos, pos, POS.isValidLVID, spacing) 254 | end 255 | 256 | 257 | function POS.legacyUnclaimedLVID(npc, fromPos, pos, spacing) 258 | return POS.legacyLVID(npc, fromPos, pos, POS.isUnclaimedLVID, spacing) 259 | end 260 | 261 | 262 | function POS.legacyUnoccupiedLVID(npc, fromPos, pos, spacing) 263 | return POS.legacyLVID(npc, fromPos, pos, POS.isUnoccupiedLVID, spacing) 264 | end 265 | -- 266 | 267 | 268 | -- POIs -- 269 | function POS.nearbyCampfires(pos, radius) 270 | radius = radius or 32 271 | local fires = {} 272 | 273 | for id, binders in pairs(bind_campfire.campfires_all) do 274 | local dist = pos:distance_to(binders.object:position()) 275 | 276 | if dist <= radius then 277 | fires[#fires + 1] = { 278 | id = id, 279 | distance = dist, 280 | object = binders.object, 281 | campfire = binders.campfire, 282 | } 283 | end 284 | end 285 | 286 | table.sort(fires, function(a, b) 287 | return a.distance < b.distance 288 | end) 289 | 290 | return fires 291 | end 292 | 293 | 294 | function POS.nearbyGrenades(pos, radius) 295 | radius = radius or 12 296 | local positions = {} 297 | local grenades = {} 298 | 299 | level.iterate_nearest(pos, radius, function(thing) 300 | if IsGrenade(thing) and thing:name() == thing:section() then 301 | table.insert(positions, thing:position()) 302 | table.insert(grenades, thing:id()) 303 | end 304 | end) 305 | 306 | if #grenades > 0 then 307 | local avgPos = VEC.average(positions) 308 | return { 309 | avgPos = avgPos, 310 | avgDir = VEC.direction(pos, avgPos), 311 | avgDist = VEC.distance(pos, avgPos), 312 | grenades = grenades, 313 | } 314 | end 315 | end 316 | 317 | 318 | function POS.assessSpace(pos, options) 319 | options = TABLE.merge({ 320 | height = 1.9, 321 | flags = 15, 322 | distance = 16, 323 | count = 16, 324 | openDist = 9.8, 325 | closeDist = 3.2, 326 | }, options) 327 | 328 | local castPos = VEC.set(pos):add(0, options.height, 0) 329 | local angle = 360 / options.count 330 | local dir = VEC.set(1, 0, 0) 331 | 332 | local distances = {} 333 | 334 | for i = 1, options.count do 335 | local castDir = VEC.rotate(VEC.set(dir), -angle * (i - 1)) 336 | local castDist = RAY.distance(castPos, castDir, options.distance, options.flags) 337 | distances[i] = UTIL.round(castDist, 1) 338 | end 339 | 340 | table.sort(distances) 341 | local cullCount = UTIL.round(options.count / 10) 342 | 343 | for i = 1, cullCount do 344 | table.remove(distances, 1) 345 | table.remove(distances, #distances) 346 | end 347 | 348 | local avg = TABLE.average(distances) 349 | 350 | local type = avg >= options.openDist and "open" 351 | or avg >= options.closeDist and "enclosed" 352 | or "cramped" 353 | 354 | return type, avg 355 | end 356 | 357 | 358 | function POS.assessCover(coverPos, enemyPos, flags) 359 | local pos = VEC.set(enemyPos):add(0, POS.AIM.HIGH, 0) 360 | local score = 0 361 | 362 | local __debug = {} 363 | 364 | for index, height in ipairs(POS.COVER) do 365 | local epos = VEC.set(coverPos):add(0, height, 0) 366 | local dist = VEC.distance(pos, epos) 367 | local dir = VEC.direction(pos, epos) 368 | local cast = RAY.distance(pos, dir, dist, flags) 369 | 370 | table.insert(__debug, {pos = pos, dir = dir, cast = cast}) 371 | 372 | if math.floor(dist - cast) <= 0 then 373 | break 374 | end 375 | 376 | score = index 377 | end 378 | 379 | return score, __debug 380 | end 381 | 382 | 383 | function POS.sortByBestCover(vids, options) 384 | options = TABLE.merge({ 385 | enemyPos = db.actor:position(), 386 | order = nil, 387 | }, options) 388 | 389 | local scores = {} 390 | 391 | for i, vid in ipairs(vids) do 392 | local score = POS.assessCover(POS.position(vid), options.enemyPos) 393 | table.insert(scores, {score = score, vid = vid}) 394 | end 395 | 396 | table.sort(scores, function(a, b) 397 | if options.order then 398 | local ia = TABLE.keyof(options.order, a.score) 399 | local ib = TABLE.keyof(options.order, b.score) 400 | 401 | if ia or ib then 402 | return (ib or #POS.COVER + 1) > (ia or #POS.COVER + 1) 403 | end 404 | end 405 | 406 | return b.score < a.score 407 | end) 408 | 409 | return scores 410 | end 411 | 412 | 413 | function POS.pickByBestCover(npc, points, options) 414 | options = TABLE.merge({ 415 | findFn = POS.bestOutsideUnclaimedLVID, 416 | enemyPos = db.actor:position(), 417 | findFrom = npc:position(), 418 | pickMethod = "random", 419 | minDist = 0, 420 | }, options) 421 | 422 | local vids = {} 423 | 424 | for i, point in ipairs(points) do 425 | local vid = options.findFn(npc, options.findFrom, point) 426 | 427 | if POS.isValidLVID(npc, vid) and VEC.distance(POS.position(vid), options.enemyPos) >= options.minDist then 428 | table.insert(vids, vid) 429 | end 430 | end 431 | 432 | local all = POS.sortByBestCover(vids, { 433 | enemyPos = options.enemyPos, 434 | order = options.order, 435 | }) 436 | 437 | local best = TABLE.ipairscb(all, function(value) 438 | if value.score == all[1].score then 439 | return value 440 | end 441 | end) 442 | 443 | if not best then 444 | return 445 | end 446 | 447 | if options.pickMethod == "random" then 448 | best = TABLE.shuffle(best) 449 | end 450 | 451 | if options.pickMethod == "farthest" then 452 | best = TABLE.reverse(best) 453 | end 454 | 455 | return best[1] 456 | end 457 | 458 | 459 | function POS.getStrafePos(npc, options) 460 | options = TABLE.merge({ 461 | findFn = POS.legacyUnclaimedLVID, 462 | enemyPos = db.actor:position(), 463 | findFrom = npc:position(), 464 | range = 10, 465 | distance = 8, 466 | spacing = 1, 467 | }, options) 468 | 469 | local enemyDir = VEC.direction(options.findFrom, options.enemyPos) 470 | 471 | local dir1 = vec_rot(enemyDir, -90 + UTIL.randomRange(10)) 472 | local dir2 = vec_rot(enemyDir, 90 + UTIL.randomRange(10)) 473 | local pos1 = vec_offset(options.findFrom, dir1, options.distance) 474 | local pos2 = vec_offset(options.findFrom, dir2, options.distance) 475 | local vid1 = options.findFn(npc, options.findFrom, pos1, options.spacing) 476 | local vid2 = options.findFn(npc, options.findFrom, pos2, options.spacing) 477 | local valid1 = POS.isValidLVID(npc, vid1) 478 | local valid2 = POS.isValidLVID(npc, vid2) 479 | 480 | if not (valid1 or valid2) then 481 | return POS.INVALID_LVID 482 | end 483 | 484 | if not (valid1 and valid2) then 485 | return valid1 and vid1 or vid2 486 | end 487 | 488 | return vec_dist(options.findFrom, lvpos(vid2)) > vec_dist(options.findFrom, lvpos(vid1)) 489 | and vid2 490 | or vid1 491 | end 492 | 493 | 494 | function POS.legacySafeCover(npc, options) 495 | options = TABLE.merge({ 496 | findFn = POS.bestOutsideUnoccupiedLVID, 497 | findFrom = npc:position(), 498 | position = npc:position(), 499 | radius = 32, 500 | distance = 1, 501 | spacing = 1, 502 | }, options) 503 | 504 | local cover = npc:safe_cover(options.position, options.radius, options.distance) 505 | 506 | if not cover then 507 | return POS.INVALID_LVID 508 | end 509 | 510 | return options.findFn(npc, options.findFrom, cover:position(), options.spacing) 511 | end 512 | 513 | 514 | function POS.legacyBestCover(npc, options) 515 | options = TABLE.merge({ 516 | findFn = POS.bestOutsideUnoccupiedLVID, 517 | enemyPos = db.actor:position(), 518 | findFrom = npc:position(), 519 | spacing = 1, 520 | }, options) 521 | 522 | local cover = npc:find_best_cover(options.enemyPos) 523 | 524 | if not cover then 525 | return POS.INVALID_LVID 526 | end 527 | 528 | return options.findFn(npc, options.findFrom, cover:position(), options.spacing) 529 | end 530 | 531 | 532 | function POS.legacyCover(npc, options) 533 | options = TABLE.merge({ 534 | findFn = POS.bestOutsideUnoccupiedLVID, 535 | enemyPos = db.actor:position(), 536 | findFrom = npc:position(), 537 | position = npc:position(), 538 | radius = 8, 539 | maxRadius = 32, 540 | spacing = 1, 541 | minEnemyDist = 1, 542 | maxEnemyDist = 128, 543 | }, options) 544 | 545 | local radius = options.radius 546 | 547 | while true do 548 | local cover = npc:best_cover(options.position, options.enemyPos, radius, options.minEnemyDist, options.maxEnemyDist) 549 | 550 | if cover then 551 | return options.findFn(npc, options.findFrom, cover:position(), options.spacing) 552 | end 553 | if radius >= options.maxRadius then 554 | break 555 | end 556 | 557 | radius = math.min(radius + math.min(options.radius, 4), options.maxRadius) 558 | end 559 | 560 | return POS.INVALID_LVID 561 | end 562 | -- 563 | 564 | 565 | return POS 566 | --------------------------------------------------------------------------------