Download Lua/Tactical/UnitAwareness.lua from sirnii/myspace: direct link, hf CLI and curl.
- Browser
- Download file 53.7 kB
-
https://huggingface.co/spaces/sirnii/myspace/resolve/main/Lua/Tactical/UnitAwareness.lua
- Command line
-
hf download hf://spaces/sirnii/myspace/Lua/Tactical/UnitAwareness.lua
-
curl -L -o UnitAwareness.lua https://huggingface.co/spaces/sirnii/myspace/resolve/main/Lua/Tactical/UnitAwareness.lua
53.7 kB
| MapVar("g_UnitAlertThread", false) | |
| MapVar("g_RepositionMarkersClaimed", {}) | |
| MapVar("g_NoiseSources", {}) | |
| MapVar("g_SuspicionThreads", {}) | |
| MapVar("g_UnitAwarenessPending", false) | |
| GameVar("g_AwarenessLog", {}) | |
| --[[ | |
| Awareness: | |
| Events that can raise unit Awareness or Suspicion use PushUnitAlert which determines the units who should | |
| change their state and marks them. Different types of events/triggers can behave differently, but whenever | |
| a unit becomes Aware they will propagate this awareness to nearby allies. Unit awareness state does NOT change. | |
| Actual state change is triggered by calling AlertPendingUnits. The function is safe to be called at any time and is | |
| called automatically on a number of occasons. It does not, however, guarantee the change would happen immediately. | |
| The change itself is handled differently, depending if the unit becomes Aware or Suspicious - Suspicious is applied | |
| directly as it does not involve any actions, whereas Aware involves Reposition phase (and possibly a StartCombat setpiece) | |
| and is handled by a dedicated AI execution phase ran in an own thread. | |
| TriggerUnitAlert is a convenience function which will call PushUnitAlert and AlertPendingUnits immediately when | |
| the caller only needs a single alert raised. | |
| --]] | |
| DefineClass.AwareReasons = { | |
| __parents = { "ListPreset", }, | |
| properties = { | |
| { id = "display_name", name = "Display Name", | |
| editor = "text", default = false, translate = true, }, | |
| }, | |
| } | |
| DefineClass.NoiseTypes = { | |
| __parents = { "Preset", }, | |
| properties = { | |
| { id = "display_name", name = "Display Name", | |
| editor = "text", default = false, translate = true, }, | |
| }, | |
| } | |
| if Platform.developer or Platform.debug then | |
| local AwarenessLogMaxLines = 100 | |
| function dbg_awareness_log(...) | |
| local msg = string.format("[%d] ", GameTime()) | |
| for i = 1, select("#", ...) do | |
| local obj = select(i, ...) | |
| local item_str | |
| if IsValid(obj) then | |
| if IsKindOf(obj, "Unit") then | |
| item_str = string.format("%s (%d)", obj.unitdatadef_id, obj.handle) | |
| else | |
| item_str = obj.class | |
| end | |
| else | |
| item_str = tostring(obj) | |
| end | |
| msg = msg .. item_str | |
| end | |
| g_AwarenessLog = g_AwarenessLog or {} | |
| while #g_AwarenessLog >= AwarenessLogMaxLines do | |
| table.remove(g_AwarenessLog, 1) | |
| end | |
| table.insert(g_AwarenessLog, msg) | |
| end | |
| else | |
| dbg_awareness_log = empty_func | |
| end | |
| function PushUnitAlert(trigger_type, ...) | |
| if trigger_type == "discovered" and CheatEnabled("DisableDiscoveryAlert") then | |
| return | |
| end | |
| local param1, param2 = ... | |
| NetUpdateHash("PushUnitAlert", trigger_type, param1 and param1.class or "", param2 or 0) | |
| local pov_team = GetPoVTeam() | |
| local enemies = pov_team and pov_team.units and GetAllEnemyUnits(pov_team.units[1] or false) | |
| local enemies_alive | |
| for _, unit in ipairs(enemies) do | |
| if IsValidTarget(unit) then | |
| enemies_alive = true | |
| break | |
| end | |
| end | |
| if not enemies_alive then | |
| return 0, 0 | |
| end | |
| local alerted | |
| local suspicious = 0 | |
| local surprised = 0 | |
| if trigger_type == "attack" then -- make target and damaged units aware | |
| local attacker, alerted_obj, from_stealth, hit_objs = ... | |
| local aware_state = (from_stealth or HasPerk(attacker, "FoxPerk")) and "surprised" or "aware" | |
| dbg_awareness_log(attacker, " alerts: attack") | |
| local units = IsValid(alerted_obj) and {alerted_obj} or alerted_obj | |
| for _, unit in ipairs(units) do | |
| local state = (g_Combat and unit:HasStatusEffect("Surprised")) and "aware" or aware_state | |
| local is_aware = unit:IsAware() or unit.pending_aware_state == "aware" | |
| local reason_id | |
| if state == "surprised" and not is_aware or (hit_objs and not table.find(hit_objs, unit)) then | |
| reason_id = "arSurprised" | |
| else | |
| reason_id = "arAttack" | |
| end | |
| local reason = T{Presets.AwareReasons.Default[reason_id].display_name, enemy = unit.Name, merc = attacker.Nick or attacker.Name} | |
| if unit:SetPendingAwareState(state, reason, attacker) then | |
| if state ~= "aware" then | |
| surprised = surprised + 1 | |
| else | |
| if not alerted then alerted = {} end | |
| alerted[#alerted + 1] = unit | |
| end | |
| dbg_awareness_log(" ", unit, " alerted") | |
| end | |
| if unit.pending_aware_state == "aware" then | |
| local action = g_CurrentAttackActions[1] | |
| if action and action.attack_args and action.attack_args.target == unit then | |
| unit.pending_awareness_role = state == "surprised" and "surprised" or "attacked" | |
| end | |
| end | |
| end | |
| elseif trigger_type == "death" then | |
| local actor = ... | |
| dbg_awareness_log(actor, " alerts: dead") | |
| -- check units based on their sight/los toward actor | |
| local units | |
| for _, team in ipairs(g_Teams) do | |
| if not team.neutral then | |
| for _, u in ipairs(team.units) do | |
| if not u.dummy and not u:IsIncapacitated() and not u:IsAware() then | |
| local sight = u:GetSightRadius(actor) | |
| if IsCloser(u, actor, sight + 1) then | |
| if not units then units = {} end | |
| units[#units + 1] = u | |
| end | |
| end | |
| end | |
| end | |
| end | |
| if units then | |
| local los_any, los_targets = CheckLOS(units, actor) | |
| if los_any then | |
| for i, los_value in ipairs(los_targets) do | |
| if los_value then | |
| local unit = units[i] | |
| local reason = T{Presets.AwareReasons.Default.arSawDying.display_name, enemy = unit.Name} | |
| if unit:SetPendingAwareState("surprised", reason) then | |
| dbg_awareness_log(" ", unit, " is surprised") | |
| surprised = surprised + 1 | |
| end | |
| end | |
| end | |
| end | |
| end | |
| elseif trigger_type == "dead body" then | |
| local actor, _units = ... | |
| local units | |
| for _, unit in ipairs(_units) do | |
| if not unit.seen_bodies[actor] | |
| and not (unit.dummy or unit.team.neutral or unit:IsDead() or unit:IsAware()) -- SetPendingAwareState ignores these units | |
| and IsCloser(unit, actor, unit:GetSightRadius(actor) + 1) | |
| then | |
| if not units then units = {} end | |
| units[#units + 1] = unit | |
| end | |
| end | |
| if units then | |
| local los_any, los_targets = CheckLOS(units, actor) | |
| if los_any then | |
| local aware_state = g_Combat and "surprised" or "suspicious" | |
| for i, los_value in ipairs(los_targets) do | |
| if los_value then | |
| local unit = units[i] | |
| if unit:SetPendingAwareState(aware_state) then | |
| unit.suspicious_body_seen = actor:GetHandle() | |
| unit.seen_bodies[actor] = true | |
| dbg_awareness_log(" ", unit, " is suspicious") | |
| suspicious = suspicious + 1 | |
| end | |
| end | |
| end | |
| end | |
| end | |
| elseif trigger_type == "noise" then -- aware/suspicious based on range, current awareness | |
| local actor, radius, soundName, attacker = ... -- actor can be unit or another object (grenade, mine, etc.) | |
| -- log noise sources (will reset on new turn) | |
| if GameState.RainLight or GameState.RainHeavy then | |
| radius = MulDivRound(radius, Max(0, 100 + const.EnvEffects.RainNoiseMod), 100) | |
| end | |
| dbg_awareness_log(actor, " alerts: noise ", radius) | |
| g_NoiseSources[#g_NoiseSources + 1] = { | |
| actor = actor, | |
| pos = actor and actor:GetPos(), | |
| noise = radius, | |
| } | |
| radius = radius * const.SlabSizeX | |
| local alerter = IsKindOf(actor, "Unit") and actor or nil | |
| if IsKindOf(attacker, "Unit") then | |
| alerter = attacker | |
| end | |
| local state = alerter and HasPerk(alerter, "FoxPerk") and "surprised" or "aware" | |
| for _, team in ipairs(g_Teams) do | |
| local side = team.side | |
| if side ~= "neutral" and (g_Combat or side == "enemy1" or side == "enemy2") then | |
| for _, unit in ipairs(team.units) do | |
| if unit ~= actor | |
| and IsCloser(unit, actor, radius + 1) | |
| and (not unit:HasStatusEffect("Distracted") or IsCloser(unit, actor, MulDivRound(radius, 66, 100) + 1)) | |
| then | |
| local reason = T{Presets.AwareReasons.Default.arNoise.display_name, enemy = unit.Name, noise = soundName} | |
| if unit:SetPendingAwareState(state, reason, alerter) then | |
| if actor then | |
| unit.last_known_enemy_pos = actor:GetPos() | |
| end | |
| if state == "aware" then | |
| if not alerted then alerted = {} end | |
| alerted[#alerted + 1] = unit | |
| else | |
| surprised = surprised + 1 | |
| end | |
| dbg_awareness_log(" ", unit, " alerted") | |
| end | |
| end | |
| end | |
| end | |
| end | |
| elseif trigger_type == "projector" then | |
| local actor, units, projector = ... | |
| for i, unit in ipairs(units) do | |
| if IsCloser(unit, projector, ProjectorSuspiciousApplyRange) then | |
| local reason = T{Presets.AwareReasons.Default.arProjector.display_name, enemy = unit.Name} | |
| if unit:SetPendingAwareState("aware", reason, actor) then | |
| surprised = surprised + 1 | |
| end | |
| end | |
| end | |
| elseif trigger_type == "sight" then -- sight: make unaware units suspicious | |
| local actor, seen = ... -- actor is unaware unit who saw an enemy. seen is enemy unit seen by actor | |
| local aware = actor:IsAware() or actor.pending_aware_state == "aware" | |
| local surprised = actor:HasStatusEffect("Surprised") or actor.pending_aware_state == "surprised" | |
| if actor:IsOnEnemySide(seen) and not aware and not surprised and actor:SetPendingAwareState("surprised") then | |
| suspicious = suspicious + 1 | |
| dbg_awareness_log(actor, " is alerted (sight)") | |
| end | |
| elseif trigger_type == "thrown" then | |
| local obj, attacker = ... -- obj is thrown object | |
| local units | |
| for _, team in ipairs(g_Teams) do | |
| if not team.neutral and (not attacker or attacker.team and team:IsEnemySide(attacker.team)) then | |
| for _, unit in ipairs(team.units) do | |
| if not unit:IsDead() and not unit:IsAware("pending") then | |
| local sight = unit:GetSightRadius(obj) | |
| if IsCloser(unit, obj, sight + 1) then | |
| if not units then units = {} end | |
| units[#units + 1] = unit | |
| end | |
| end | |
| end | |
| end | |
| end | |
| if units then | |
| local los_any, los_targets = CheckLOS(units, obj) | |
| if los_any then | |
| for i, los_value in ipairs(los_targets) do | |
| if los_value then | |
| local unit = units[i] | |
| local reason = T{Presets.AwareReasons.Default.arThrownObject.display_name, enemy = unit.Name} | |
| if unit:SetPendingAwareState("surprised", reason) then | |
| dbg_awareness_log(" ", unit, " is surprised") | |
| surprised = surprised + 1 | |
| end | |
| end | |
| end | |
| end | |
| end | |
| elseif trigger_type == "script" then --should this be included in the aware_reason | |
| local _units, state = ... -- units to become suspicious/aware | |
| local units = table.ifilter(_units, function(idx, unit) | |
| return unit.team and not unit.team.neutral | |
| end) | |
| for _, unit in ipairs(units) do | |
| unit.pending_aware_state = state | |
| dbg_awareness_log(unit, " is alerted (script): ", state) | |
| end | |
| if state == "aware" then | |
| alerted = units | |
| end | |
| elseif trigger_type == "surprise" then | |
| local unit, from_suspicious = ... | |
| local reason | |
| if from_suspicious then | |
| reason = T{Presets.AwareReasons.Default.arDeadBody.display_name, enemy = unit.Name} | |
| end | |
| if unit:SetPendingAwareState("aware", reason) then | |
| dbg_awareness_log(unit, " is alerted (surprise)") | |
| if not alerted then alerted = {} end | |
| alerted[#alerted + 1] = unit | |
| end | |
| elseif trigger_type == "discovered" then -- Alert all enemies who have sight of unit. | |
| local unit = ... | |
| local enemyUnits = GetAllEnemyUnits(unit) | |
| local alertedPeople = 0 | |
| for i, enemyUnit in ipairs(enemyUnits) do | |
| if not enemyUnit:IsAware() and HasVisibilityTo(enemyUnit, unit) then | |
| alertedPeople = alertedPeople + 1 | |
| CombatStarDetectedtVR(unit) | |
| if enemyUnit.pending_aware_state ~= "aware" then | |
| if not enemyUnit:HasStatusEffect("Surprised") then -- unit has already spotted someone and is now surprised, dont try to make it aware again | |
| local reason = T{Presets.AwareReasons.Default.arNotice.display_name, enemy = enemyUnit.Name, merc = unit.Nick or unit.Name} | |
| if enemyUnit:SetPendingAwareState("aware", reason, unit) then | |
| if not alerted then alerted = {} end | |
| alerted[#alerted + 1] = enemyUnit | |
| dbg_awareness_log(enemyUnit, " is alerted (combat-walk)") | |
| end | |
| end | |
| end | |
| end | |
| end | |
| if alertedPeople > 0 then | |
| unit:RemoveStatusEffect("Hidden") | |
| end | |
| else | |
| assert(false, string.format("unknown alert trigger '%s' used", tostring(trigger_type))) | |
| end | |
| if alerted then | |
| alerted = table.ifilter(alerted, function(idx, unit) | |
| return not unit.dummy and unit.pending_aware_state == "aware" | |
| end) | |
| end | |
| local alerted_count = alerted and #alerted or 0 | |
| if alerted_count > 0 then | |
| local roles = {} | |
| PropagateAwareness(alerted, roles) | |
| for _, unit in ipairs(alerted) do | |
| if unit.pending_aware_state ~= "aware" and unit:SetPendingAwareState("aware") or roles[unit] == "alerter" then | |
| unit.pending_awareness_role = roles[unit] or "alerted" | |
| end | |
| end | |
| end | |
| if alerted_count + surprised > 0 then | |
| local pendingType = alerted_count > 0 and "alert" or "sus" | |
| if not g_UnitAwarenessPending or pendingType == "alert" then | |
| g_UnitAwarenessPending = pendingType | |
| end | |
| end | |
| return alerted_count + surprised, suspicious | |
| end | |
| function TriggerUnitAlert(trigger_type, ...) | |
| local alerted, suspicious = PushUnitAlert(trigger_type, ...) | |
| AlertPendingUnits() | |
| return alerted, suspicious | |
| end | |
| function PropagateAwareness(alerted_units, roles, killed_units) | |
| local i = 1 | |
| while i <= #alerted_units do | |
| local unit = alerted_units[i] | |
| local killed = killed_units and table.find(killed_units, unit) | |
| if not unit:IsDead() or killed then | |
| local upos = GetPackedPosAndStance(unit, killed and unit.killed_stance) | |
| local allies = GetAllAlliedUnits(unit) | |
| for _, ally in ipairs(allies) do | |
| if IsValidTarget(ally) and (ally.team.side == "neutral" or (not ally:IsAware() and ally.pending_aware_state ~= "aware")) then | |
| local apos = GetPackedPosAndStance(ally) | |
| local sight = ally:GetSightRadius(unit) | |
| if apos and upos and (stance_pos_dist(upos, apos) <= sight) and stance_pos_visibility(upos, apos) then | |
| table.insert_unique(alerted_units, ally) | |
| if roles then | |
| if not roles[unit] then | |
| roles[unit] = "alerter" | |
| end | |
| roles[ally] = "alerted" | |
| end | |
| end | |
| end | |
| end | |
| end | |
| i = i + 1 | |
| end | |
| end | |
| local function ExecUnitAlert(reposition_units, alerted_by_enemy, first_unit) | |
| local sector = gv_Sectors[gv_CurrentSectorId] | |
| local unitList | |
| if next(alerted_by_enemy) then | |
| unitList = alerted_by_enemy | |
| alerted_by_enemy = table.ifilter(alerted_by_enemy, function(idx, unit) return IsValidTarget(unit) and IsValid(unit.alerted_by_enemy) and not unit:HasStatusEffect("Unconscious") end) | |
| else | |
| unitList = reposition_units | |
| end | |
| for _, unit in ipairs(unitList) do | |
| if sector.awareness_sequence == "Standard" then | |
| unit.pending_awareness_role = unit.pending_awareness_role or "alerted" | |
| else | |
| unit.pending_awareness_role = nil | |
| end | |
| end | |
| PlayBestCombatNotification(unitList) | |
| if not g_Combat then | |
| if not g_StartingCombat and g_Units and next(g_Units) then | |
| NetSyncEvent("ExplorationStartCombat", nil, first_unit and first_unit.session_id) | |
| WaitMsg("CombatStart") | |
| end | |
| elseif g_StartingCombat then | |
| WaitMsg("CombatStart") | |
| end | |
| -- Remove action camera if on. | |
| if ActionCameraPlaying then | |
| RemoveActionCamera(true) | |
| WaitMsg("ActionCameraRemoved", 5000) | |
| end | |
| -- start of combat setpiece (if needed) | |
| local cam_actor, restore_cam_obj | |
| if g_Combat and not g_Combat.unit_reposition_shown and alerted_by_enemy and #alerted_by_enemy > 0 and sector.awareness_sequence == "Standard" then | |
| local unit | |
| for _, u in ipairs(alerted_by_enemy) do | |
| if u.pending_awareness_role == "attacked" then | |
| unit = u | |
| break | |
| end | |
| end | |
| unit = unit or table.interaction_rand(alerted_by_enemy, "StartCombat") | |
| g_Combat.unit_reposition_shown = true | |
| cam_actor, restore_cam_obj = unit, unit.alerted_by_enemy | |
| if unit and not IsCompetitiveGame() then | |
| cameraTac.SetZoom(0,50) | |
| LockCamera(unit) | |
| Sleep(50) | |
| -- add all units to AlertedUnits group so the setpiece can access them | |
| for _, unit in ipairs(reposition_units) do | |
| if IsValid(unit) and not unit:IsDead() then | |
| unit:AddToGroup("AlertedUnits") | |
| end | |
| end | |
| -- go over setpieces and try to find the ones which can use their action camera | |
| local valid, all = {}, {} | |
| ForEachPresetInGroup("SetpiecePrg", "Combat", function(prg) -- todo: move to separate StartCombat group maybe? | |
| for _, cmd in ipairs(prg) do | |
| if IsKindOfClasses(cmd, "SetpieceActionCameraSingle", "SetpieceActionCamera") then | |
| local target | |
| if IsKindOf(cmd, "SetpieceActionCameraSingle") then | |
| target = SetpieceActionCameraSingle.CalcTarget({unit}, cmd.TargetOffset, cmd.TargetHeight, cmd.TargetAngleOffset) | |
| else | |
| target = unit.alerted_by_enemy | |
| end | |
| local pos, lookat, preset = SetpieceActionCamera.CalcCamera(unit, target, cmd.Preset, cmd.Position) | |
| if cmd.Preset == "Any" or preset == cmd.Preset then | |
| valid[#valid + 1] = prg.id | |
| end | |
| all[#all + 1] = prg.id | |
| elseif IsKindOfClasses(cmd, "SetStartCombatAnim") then | |
| valid[#valid + 1] = prg.id | |
| end | |
| end | |
| end) | |
| -- fall back to picking randomly if none of the available setpieces matched the current situation and rely on action cam fallback mechanism | |
| valid = (#valid > 0) and valid or all | |
| assert(#valid > 0) | |
| local setpiece = table.interaction_rand(valid, "StartCombat") | |
| -- pass the single unit in triggerUnits as it defines the camera for the setpiece | |
| local dlg = OpenDialog("XSetpieceDlg", false, {setpiece = setpiece, triggerUnits = {unit} }) | |
| if dlg then | |
| while true do | |
| local ok, sp = WaitMsg("SetpieceEnded", 20 * 1000) | |
| if not ok or sp.id == dlg.setpiece then | |
| break | |
| end | |
| end | |
| end | |
| -- remove units from AlertedUnits group | |
| for _, unit in ipairs(reposition_units) do | |
| unit:RemoveFromGroup("AlertedUnits") | |
| unit.pending_awareness_role = nil | |
| end | |
| UnlockCamera(unit) | |
| end | |
| end | |
| CancelWaitingActions(-1) | |
| assert(#reposition_units > 0) | |
| assert(g_AIExecutionController and g_AIExecutionController.label == "AlertUnits") | |
| if not g_StartingCombat then | |
| g_AIExecutionController.restore_camera_obj = restore_cam_obj or SelectedObj | |
| end | |
| g_AIExecutionController:Execute(reposition_units) | |
| -- special-case: if there are allied units who did not yet reposition in this combat, let them do it now - they're all aware anyway | |
| local team = table.find(g_Teams, "side", "ally") | |
| team = g_Teams[team or false] | |
| if g_Combat and team then | |
| local units = table.ifilter(team.units, function(idx, u) return not g_Combat:IsRepositioned(u) end) | |
| if #units > 0 then | |
| for _, unit in ipairs(units) do | |
| unit.pending_aware_state = "reposition" | |
| end | |
| g_AIExecutionController:Execute(units) | |
| end | |
| end | |
| g_UnitAlertThread = false | |
| g_UnitAwarenessPending = false | |
| DoneObject(g_AIExecutionController) | |
| end | |
| function AlertPendingUnits(sync_code) | |
| -- check buffering conditions | |
| --NetUpdateHash("AlertPendingUnits") | |
| if g_Combat then | |
| if g_Combat.start_of_turn or next(CombatActions_RunningState) ~= nil then | |
| return | |
| end | |
| end | |
| --[[ | |
| NetUpdateHash("AlertPendingUnits_EarlyOuts", "GameState.entering_sector", GameState.entering_sector, | |
| "IsValidThread(g_UnitAlertThread)", IsValidThread(g_UnitAlertThread), | |
| "IsSetpiecePlaying()", IsSetpiecePlaying(), | |
| "GameState.setpiece_playing", GameState.setpiece_playing, | |
| "IsRadioBanterPlaying()", IsRadioBanterPlaying(), | |
| "g_AIExecutionController", not not g_AIExecutionController, | |
| "GameState.sync_loading", GameState.sync_loading, | |
| "not gv_Sectors", not gv_Sectors, | |
| "GameState.entering_sector", GameState.entering_sector) | |
| ]] | |
| if IsSetpiecePlaying() or GameState.sync_loading or g_AIExecutionController or IsValidThread(g_UnitAlertThread) or gv_SatelliteView or not gv_Sectors then | |
| return | |
| end | |
| if GameState.entering_sector then | |
| return | |
| end | |
| --NetUpdateHash("AlertPendingUnits_doing_work") | |
| -- check for unit reactions to damage: | |
| -- pain: the alert will happen after it ends (triggered by the pain thread) | |
| -- dying: the alert will happen after it ends (triggered by UnitDied msg) | |
| -- thrown off by explosion: the alert will happen after it ends (triggered by Idle or UnitDied msg) | |
| for _, unit in ipairs(g_Units) do | |
| --if IsValidThread(unit.pain_thread) or (unit:IsDead() and not unit:IsIdleCommand()) or unit.command == "ExplosionFly" then | |
| if IsValidThread(unit.pain_thread) or unit.command == "ExplosionFly" then | |
| return | |
| end | |
| if HasCombatActionInProgress(unit) then | |
| if g_Combat then | |
| return | |
| else -- In exploration block the alert only if the current action is an attack | |
| if not unit:IsInterruptable() then return end | |
| local action_id = CombatActions_UnitAction[unit] | |
| local action = action_id and CombatActions[action_id] | |
| if action and (action.ActionType == "Ranged Attack" or action.ActionType == "Melee Attack") then | |
| return | |
| end | |
| end | |
| end | |
| end | |
| local sector = gv_Sectors[gv_CurrentSectorId] | |
| -- only enum the units with pending awareness, the execution controller will process them | |
| local reposition_units, alerted_by_enemy, surprised_units | |
| local start_combat, first_unit | |
| local current_team = g_Combat and g_Teams[g_CurrentTeam] | |
| local end_combat = g_Combat and g_Combat:ShouldEndCombat() | |
| local skip_all = sector and sector.awareness_sequence == "Skip All" | |
| for _, team in ipairs(g_Teams) do | |
| if end_combat or team.side == "neutral" then | |
| for _, unit in ipairs(team.units) do | |
| unit.pending_aware_state = nil | |
| end | |
| else | |
| for _, unit in ipairs(team.units) do | |
| local state = unit.pending_aware_state | |
| if state then | |
| if not IsValidTarget(unit) then | |
| unit.pending_aware_state = nil | |
| elseif unit:IsAware() then | |
| unit.pending_aware_state = nil | |
| elseif state == "aware" then | |
| start_combat = true | |
| if skip_all or team == current_team then | |
| unit:RemoveStatusEffect("Unaware") | |
| unit:RemoveStatusEffect("Surprised") | |
| unit:RemoveStatusEffect("Suspicious") | |
| unit.pending_aware_state = nil | |
| else | |
| if not reposition_units then reposition_units = {} end | |
| reposition_units[#reposition_units + 1] = unit | |
| first_unit = first_unit or unit.alerted_by_enemy | |
| if IsValidTarget(unit.alerted_by_enemy) then | |
| if not alerted_by_enemy then alerted_by_enemy = {} end | |
| alerted_by_enemy[#alerted_by_enemy + 1] = unit | |
| end | |
| end | |
| elseif state == "surprised" then | |
| start_combat = true | |
| if g_Combat then | |
| unit:AddStatusEffect("Surprised") | |
| unit:RemoveStatusEffect("Suspicious") | |
| unit.pending_aware_state = nil | |
| else | |
| if not surprised_units then surprised_units = {} end | |
| surprised_units[#surprised_units + 1] = unit | |
| end | |
| elseif state == "suspicious" then -- becoming suspicious is instant (no action involved), so do it directly | |
| unit:AddStatusEffect("Suspicious") | |
| unit:RemoveStatusEffect("Unaware") | |
| unit.pending_aware_state = nil | |
| else | |
| unit.pending_aware_state = nil | |
| end | |
| end | |
| end | |
| end | |
| end | |
| if start_combat and not g_Combat or reposition_units then | |
| if reposition_units then | |
| CreateAIExecutionController{label = "AlertUnits", reposition = true} -- create controller first so nobody else does before the thread starts | |
| end | |
| g_UnitAlertThread = CreateGameTimeThread(function(sync_code, first_unit, reposition_units, alerted_by_enemy) | |
| if not g_Combat then | |
| if not g_StartingCombat and g_Units and next(g_Units) then | |
| if sync_code then | |
| CreateGameTimeThread(NetSyncEvents.ExplorationStartCombat, nil, first_unit and first_unit.session_id) | |
| else | |
| NetSyncEvent("ExplorationStartCombat", nil, first_unit and first_unit.session_id) | |
| end | |
| end | |
| while not g_Combat do | |
| WaitMsg("CombatStarting", 10) | |
| end | |
| -- now that we're in combat recheck the units for pending Surprised status | |
| for _, team in ipairs(g_Teams) do | |
| if team.side ~= "neutral" then | |
| for _, unit in ipairs(team.units) do | |
| if unit.pending_aware_state == "surprised" and IsValidTarget(unit) and not unit:IsAware() then | |
| unit:AddStatusEffect("Surprised") | |
| unit.pending_aware_state = nil | |
| end | |
| end | |
| end | |
| end | |
| end | |
| if reposition_units then | |
| ExecUnitAlert(reposition_units, alerted_by_enemy, first_unit) | |
| end | |
| end, sync_code, first_unit, reposition_units, alerted_by_enemy) | |
| end | |
| if not reposition_units then | |
| if surprised_units then | |
| PlayBestCombatNotification(surprised_units) | |
| end | |
| g_UnitAwarenessPending = false | |
| end | |
| end | |
| MapGameTimeRepeat("AlertPendingUnits", 100, AlertPendingUnits) | |
| function OnMsg.VisibilityUpdate() | |
| if not g_Combat then return end -- out of combat this is handled by UpdateSuspicion | |
| local aware = {} | |
| for _, unit in ipairs(g_Units) do | |
| if not unit:IsDead() then | |
| local is_aware = unit:IsAware() | |
| if is_aware and not unit.team.player_team and unit.team.side ~= "neutral" then | |
| aware[#aware + 1] = unit | |
| end | |
| for _, seen in ipairs(g_Visibility[unit]) do | |
| if IsValid(seen) and unit:IsOnEnemySide(seen) then | |
| unit.last_known_enemy_pos = seen:GetPos() | |
| if not is_aware then | |
| PushUnitAlert("sight", unit, seen) | |
| end | |
| end | |
| end | |
| end | |
| end | |
| -- PropagateAwareness from the already aware units to reflect sight/visuals changes | |
| PropagateAwareness(aware) | |
| for _, unit in ipairs(aware) do | |
| if unit:SetPendingAwareState("aware") then | |
| unit.pending_awareness_role = "alerted" | |
| end | |
| end | |
| AlertPendingUnits() | |
| end | |
| function DeadUnitsPulse() | |
| if IsSetpiecePlaying() then return end | |
| local alert_team_units | |
| for _, team in ipairs(g_Teams) do | |
| local neutral = team.neutral | |
| local alert_side = neutral and "enemy1" or team.side | |
| local alert_units = alert_team_units and alert_team_units[alert_side] | |
| for _, unit in ipairs(team.units) do | |
| if (not neutral or unit.neutral_retaliate) and unit:IsDead() then | |
| if not alert_units then | |
| for _, team in ipairs(g_Teams) do | |
| if team.side == alert_side then | |
| for _, u in ipairs(team.units) do | |
| if not u:IsDead() | |
| and not u:IsAware("pending") | |
| and not u:HasStatusEffect("HighAlert") | |
| and not u:HasStatusEffect("IgnoreBodies") | |
| then | |
| if not alert_units then alert_units = {} end | |
| alert_units[#alert_units + 1] = u | |
| end | |
| end | |
| end | |
| end | |
| if not alert_team_units then alert_team_units = {} end | |
| alert_team_units[alert_side] = alert_units or empty_table | |
| end | |
| if not alert_units or #alert_units == 0 then | |
| break | |
| end | |
| PushUnitAlert("dead body", unit, alert_units) | |
| end | |
| end | |
| end | |
| AlertPendingUnits() | |
| end | |
| OnMsg.TurnStart = DeadUnitsPulse | |
| OnMsg.UnitDied = DeadUnitsPulse | |
| OnMsg.Idle = AlertPendingUnits | |
| function OnMsg.CombatActionEnd() | |
| CreateGameTimeThread(AlertPendingUnits) | |
| end | |
| function OnMsg.CombatEnd() | |
| MapForEach("map", "Unit", function(unit) | |
| unit.pending_aware_state = nil | |
| unit.alerted_by_enemy = nil | |
| unit.aware_reason = nil | |
| end) | |
| dbg_awareness_log("Combat ended") | |
| end | |
| function OnMsg.CombatStart() | |
| dbg_awareness_log("Combat started") | |
| end | |
| function OnMsg.EnterSector() | |
| g_AwarenessLog = {} | |
| end | |
| MapGameTimeRepeat("DeadAwarenessPulseTick", 500, function() | |
| if not g_Combat then | |
| DeadUnitsPulse() | |
| end | |
| end) | |
| function Unit:SetPendingAwareState(state, reason, alerter) | |
| NetUpdateHash("SetPendingAwareState", state, alerter, self.pending_aware_state, self:IsAware()) | |
| if self.dummy or self.team.side == "neutral" or self:IsDead() or self:IsAware() then return end | |
| if reason and (not self.aware_reason or table.find(Presets.AwareReasons.Default, "display_name", reason[1]) > table.find(Presets.AwareReasons.Default, "display_name", self.aware_reason[1])) then | |
| self.aware_reason = reason or self.aware_reason | |
| end | |
| if not self.pending_aware_state or (state == "aware" or state == "surprised") and self.pending_aware_state ~= "aware" then | |
| self.pending_aware_state = state | |
| self.alerted_by_enemy = alerter or self.alerted_by_enemy | |
| return true | |
| elseif self.pending_aware_state == state then | |
| self.alerted_by_enemy = self.alerted_by_enemy or alerter | |
| return true | |
| end | |
| end | |
| -- Suspicious | |
| function Unit:SuspiciousRoutine() | |
| local def = CharacterEffectDefs.Suspicious | |
| -- wait to finish the previouse job | |
| Sleep(self:TimeToAngleInterpolationEnd()) | |
| local body = self.suspicious_body_seen and HandleToObject[self.suspicious_body_seen] | |
| if IsValid(body) then | |
| self:Face(body, not GameTimeAdvanced and 0 or 500) | |
| end | |
| local anim = self:TryGetActionAnim("Suspicious", "Standing") | |
| if anim and self:GetStateText() ~= anim then | |
| self:SetState(anim) | |
| end | |
| local last_update = GameTime() | |
| local effect = self:GetStatusEffect("Suspicious") | |
| local time = effect:ResolveValue("suspicious_time") or 0 | |
| effect:SetParameter("suspicious_time", time) | |
| local grow_time = effect:ResolveValue("sight_grow_time") | |
| local sight_mod_max = effect:ResolveValue("sight_modifier_max") | |
| local delay_time = effect:ResolveValue("max_sight_delay") | |
| local shrink_time = effect:ResolveValue("sight_shrink_time") | |
| repeat | |
| WaitMsg("CombatStarting", 100) -- so it breaks when on combat start | |
| effect = self:GetStatusEffect("Suspicious") -- recheck in case it was lost meanwhile | |
| if g_Combat or not effect then break end | |
| -- accumulate time spent looking around | |
| local time_now = GameTime() | |
| time = time + time_now - last_update | |
| last_update = time_now | |
| effect:ResolveValue("suspicious_time", time) | |
| -- update sight radius mod in effect value | |
| local mod | |
| if time < grow_time then | |
| mod = MulDivRound(sight_mod_max, time, grow_time) | |
| elseif time < grow_time + delay_time then | |
| mod = sight_mod_max | |
| elseif time < grow_time + delay_time + shrink_time then | |
| local t = time - grow_time - delay_time | |
| mod = MulDivRound(sight_mod_max, shrink_time - t, shrink_time) | |
| else | |
| mod = nil | |
| end | |
| if effect:ResolveValue("suspicious_sight_mod") ~= mod then | |
| effect:SetParameter("suspicious_sight_mod", mod) | |
| InvalidateUnitLOS(self) | |
| end | |
| until not mod | |
| self.suspicious_body_seen = nil | |
| if not g_Combat and not g_StartingCombat and self:HasStatusEffect("Suspicious") then | |
| local enemies = GetAllEnemyUnits(self) | |
| local mindist | |
| for _, enemy in ipairs(enemies) do | |
| if enemy.team and enemy.team.player_team then | |
| local dist = self:GetDist(enemy) | |
| mindist = Min(mindist or dist, dist) | |
| end | |
| end | |
| -- become aware or revert to unaware based on min distance to enemy | |
| if mindist and mindist < Suspicious:ResolveValue("remain_unaware_min_dist")*guim then | |
| TriggerUnitAlert("surprise", self, "suspicious") | |
| else | |
| self:AddStatusEffect("Unaware") | |
| end | |
| end | |
| end | |
| -- Reposition | |
| local function PathFromContextDest(unit, context, dest) | |
| for _, stance in ipairs(StancesList) do | |
| local cpath = context.combat_paths[stance] | |
| local path = cpath and cpath:GetCombatPathFromPos(dest) | |
| if path then | |
| return path | |
| end | |
| end | |
| end | |
| function Unit:ClaimRepositionMarker() | |
| local rep_markers = MapGetMarkers("Reposition", nil, function(marker, unit) | |
| if g_RepositionMarkersClaimed[marker] then | |
| return false | |
| end | |
| if (marker.TargetUnits or "") ~= "" and marker.TargetUnits ~= unit.unitdatadef_id then | |
| return false | |
| end | |
| local x, y, z = GetPassSlabXYZ(marker) | |
| if not x then | |
| return false | |
| end | |
| local has_path | |
| local dest = point_pack(x, y, z) | |
| for _, stance in ipairs(StancesList) do | |
| local cpath = unit.ai_context.combat_paths[stance] | |
| if cpath and cpath:GetAP(dest) then | |
| has_path = true | |
| break | |
| end | |
| end | |
| return | |
| has_path and | |
| CanOccupy(unit, x, y, z) and | |
| marker:IsMarkerEnabled() | |
| end, self) | |
| if not rep_markers or #rep_markers == 0 then | |
| return | |
| end | |
| local idx = 1 + self:Random(#rep_markers) | |
| return rep_markers[idx] | |
| end | |
| function Unit:PickRepositionDest() | |
| local context = self.ai_context | |
| local behavior = context and context.behavior | |
| context.reposition = true | |
| context.forced_run = true | |
| context.ai_destination = false | |
| self.reposition_dest = false | |
| self.reposition_marker = false | |
| if not behavior then return end | |
| local marker = self:ClaimRepositionMarker() | |
| if marker then | |
| g_RepositionMarkersClaimed[marker] = true | |
| local x, y, z = GetPassSlabXYZ(marker) | |
| self.reposition_path = PathFromContextDest(self, context, point_pack(x, y, z)) | |
| -- there could be no path when the unit is on the destination | |
| if self.reposition_path then | |
| self.reposition_dest = stance_pos_pack(x, y, z, self.stance) | |
| self.reposition_marker = marker | |
| end | |
| else | |
| behavior:Think(self) | |
| if context.ai_destination then | |
| local x, y, z = stance_pos_unpack(context.ai_destination) | |
| assert(CanOccupy(self, x, y, z)) | |
| self.reposition_path = PathFromContextDest(self, context, point_pack(x, y, z)) | |
| -- there could be no path when the unit is on the destination | |
| if self.reposition_path then | |
| self.reposition_dest = context.ai_destination | |
| end | |
| end | |
| end | |
| end | |
| function Unit:GetProvokePos(path, visible_only) | |
| local goto_dummies = self:GenerateTargetDummiesFromPath(path) | |
| local interrupts, provoke_idx = self:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", goto_dummies, visible_only) | |
| local provoke_pos = provoke_idx and goto_dummies[provoke_idx].pos | |
| return provoke_pos | |
| end | |
| function Unit:Reposition() | |
| assert(g_Combat and self:IsAware()) | |
| local always_ready = g_AIExecutionController and g_AIExecutionController.label == "AlwaysReady" and g_AIExecutionController.activator == self | |
| if not g_Combat or (not always_ready and g_Combat:IsRepositioned(self)) or self:HasStatusEffect("Unconscious") then | |
| return | |
| end | |
| -- initialization/startup | |
| g_Combat:SetRepositioned(self, true) | |
| self:PushDestructor(function() | |
| self.reposition_dest = nil | |
| self.reposition_path = nil | |
| self.reposition_marker = nil | |
| end) | |
| if not g_Combat or (self.team == g_Teams[g_CurrentTeam] and not always_ready) or (self.team.side == "neutral") or self.dummy then | |
| self:PopAndCallDestructor() | |
| return | |
| end | |
| local context = self.ai_context | |
| local path = self.reposition_path | |
| if path then | |
| -- set target dummy to free the current position (random waits could chnage the move order) | |
| local destination = point(point_unpack(path[1])) | |
| self:SetTargetDummyFromPos(destination, nil, true) | |
| self:ClearEnumFlags(const.efResting) | |
| Sleep(0) | |
| -- debug code | |
| local x, y, z = point_unpack(path[1]) | |
| local o = GetOccupiedBy(x, y, z, self) | |
| if o and o ~= self then | |
| printf("Unit %d reposition to %s is occupied by another unit %d (%s)", self.handle, tostring(point(point_unpack(path[1]))), o.handle, o.command) | |
| printf("Unit current pos %s, efResting = %d", tostring(self:GetPos()), self:GetEnumFlags(const.efResting)) | |
| if self.target_dummy then | |
| printf("Unit target dummy pos %s, efResting = %d, locked = %s", tostring(self.target_dummy:GetPos()), self.target_dummy:GetEnumFlags(const.efResting), tostring(self.target_dummy.locked)) | |
| end | |
| printf("Unit reposition_dest %s", self.reposition_dest and tostring(point(stance_pos_unpack(self.reposition_dest))) or "") | |
| if self.ai_context and self.ai_context.ai_destination then | |
| printf("Unit ai_destination %s", tostring(point(stance_pos_unpack(self.ai_context.ai_destination)))) | |
| end | |
| printf("Other command: %s", o.command) | |
| printf("Other current pos %s, efResting = %d", tostring(o:GetPos()), o:GetEnumFlags(const.efResting)) | |
| if o.target_dummy then | |
| printf("Other target dummy pos %s, efResting = %d, locked = %s", tostring(o.target_dummy:GetPos()), o.target_dummy:GetEnumFlags(const.efResting), tostring(o.target_dummy.locked)) | |
| end | |
| if o.reposition_dest then | |
| printf("Other reposition_dest %s", tostring(point(stance_pos_unpack(o.reposition_dest)))) | |
| end | |
| if o.ai_context and o.ai_context.ai_destination then | |
| printf("Other ai_destination %s", tostring(point(stance_pos_unpack(o.ai_context.ai_destination)))) | |
| end | |
| assert(false, "Unit reposition") | |
| end | |
| -- check for interrupts first and move to PostAction early if there are none to enable all units to play their Awareness anims simultaneously | |
| if not self:GetProvokePos(path) then | |
| SetCombatActionState(self, "PostAction") | |
| end | |
| if self.pending_awareness_role then | |
| self:PlayAwarenessAnim() -- PlayAwarenessAnim has built-in randomized starting time when applicable | |
| else | |
| -- randomize starting time to avoid total sync when multiple units reposition | |
| Sleep(self:Random(500)) | |
| end | |
| self:CombatGoto("Move", self.ActionPoints, nil, path, true) | |
| else | |
| -- no movement, just move to PostAction | |
| SetCombatActionState(self, "PostAction") | |
| if self.pending_awareness_role then | |
| self:PlayAwarenessAnim() -- PlayAwarenessAnim has built-in randomized starting time when applicable | |
| end | |
| end | |
| self:SetTargetDummyFromPos(nil, nil, true) | |
| if string.match(self:GetStateText(), ".*_Combat%a+Stop.*") then | |
| Sleep(self:TimeToAnimEnd()) | |
| end | |
| local base_idle = self:GetIdleBaseAnim(self.target_dummy.stance) | |
| if not IsAnimVariant(self:GetStateText(), base_idle) then | |
| self:PlayTransitionAnims(base_idle, self.target_dummy:GetAngle()) | |
| end | |
| self:AnimatedRotation(self.target_dummy:GetAngle(), base_idle) | |
| self:SetRandomAnim(base_idle) | |
| -- end/cleanup | |
| self:PopAndCallDestructor() | |
| end | |
| function Unit:RepositionOpeningAttack() | |
| local context = self.ai_context | |
| if not context then return end | |
| local dest = GetPackedPosAndStance(self) | |
| local target | |
| if dest then | |
| -- recalc target to make sure we're firing at a valid target, giving enough AP for one basic attack | |
| self.ActionPoints = context.default_attack_cost | |
| context.dest_ap[dest] = self.ActionPoints | |
| context.reposition = false -- re-enable damage score | |
| AIPrecalcDamageScore(context, {dest}) | |
| target = (context.dest_target or empty_table)[dest] | |
| end | |
| if IsKindOf(target, "Unit") and IsValidTarget(target) then | |
| local chance | |
| if self.AlwaysUseOpeningAttack then | |
| chance = 100 | |
| else | |
| chance = Max(0, 30 + self.Dexterity - target.Dexterity) | |
| chance = self:CallReactions_Modify("OnCalcOpeningAttackChance", chance, self, target) | |
| chance = target:CallReactions_Modify("OnCalcOpeningAttackChance", chance, self, target) | |
| chance = Clamp(chance, 0, 100) | |
| end | |
| if self:Random(100) < chance then | |
| local weapon = context.default_attack:GetAttackWeapons(self) | |
| if IsKindOf(weapon, "Firearm") and not IsKindOfClasses(weapon, "HeavyWeapon", "FlareGun") then | |
| local attacked | |
| local opening_attack = self.OpeningAttackType | |
| if opening_attack ~= "Default" then | |
| if CombatActions[opening_attack]:GetUIState({self}) == "hidden" then | |
| opening_attack = false -- revert to basic attack | |
| end | |
| end | |
| if opening_attack == "Overwatch" then | |
| local args, has_ap = AIGetAttackArgs(context, CombatActions.Overwatch, nil, "None") | |
| if has_ap then | |
| local zones = AIPrecalcConeTargetZones(context, "Overwatch") | |
| local zone = AIEvalZones(context, zones, 1, 1) | |
| if zone then | |
| -- We're already running a Combat Action so we can't use AIPlayCombatAction. | |
| -- We also shouldn't call OverwatchAction() directly as it will break the execution of the current command in the end. | |
| -- Instead, we exploit OverwatchAction being a combat behavior and set it directly as one, relying on the code to be | |
| -- executed once the current command concludes and the we go into Idle. | |
| local args = { target = zone.target_pos } | |
| self:SetCombatBehavior("OverwatchAction", {"Overwatch", 0, args}) | |
| attacked = true | |
| end | |
| end | |
| elseif opening_attack == "PinDown" then | |
| local args, has_ap, target = AIGetAttackArgs(context, CombatActions.PinDown, nil, "None") | |
| if has_ap and IsValidTarget(target) then | |
| if self:HasPindownLine(target, "Torso") then | |
| -- Same as above, set the behavior directly. | |
| local arg = { target = target, target_spot_group = "Torso" }, | |
| self:SetCombatBehavior("PinDown", {"PinDown", 0, args}) | |
| attacked = true | |
| end | |
| end | |
| end | |
| if not attacked then | |
| self:FirearmAttack(context.default_attack.id, 0, {target = target, opportunity_attack = true, opening_attack = true}) | |
| end | |
| elseif IsKindOf(weapon, "MeleeWeapon") then | |
| self:MeleeAttack(context.default_attack.id, 0, { target = target, opportunity_attack = true}) | |
| end | |
| end | |
| end | |
| -- take cover chance | |
| local archetype = self:GetCurrentArchetype() | |
| local chance = 0 | |
| for _, behavior in ipairs(archetype.Behaviors) do | |
| if behavior:MatchUnit(self) then | |
| chance = Max(chance, behavior.TakeCoverChance) | |
| end | |
| end | |
| if self:Random(100) < chance then | |
| self:TakeCover() | |
| end | |
| self:RemoveStatusEffect("OpeningAttackBonus") | |
| end | |
| function IsRepositionPhase() | |
| return g_AIExecutionController and g_AIExecutionController.reposition | |
| end | |
| -- noise sources debug | |
| if Platform.developer or Platform.debug then | |
| function ToggleNoiseSources() | |
| if not g_NoiseSources then return end | |
| if g_NoiseSources.shown then | |
| for _, obj in ipairs(g_NoiseSources.shown) do | |
| DoneObject(obj) | |
| end | |
| g_NoiseSources.shown = nil | |
| printf("displayed noise sources removed") | |
| else | |
| local list, centers, ranges = {}, {}, {} | |
| local avg_pos = point30 | |
| for i, descr in ipairs(g_NoiseSources) do | |
| local obj = PlaceObject("Object") | |
| obj:ChangeEntity("MarkerMusic") | |
| obj:SetScale(20) | |
| obj:SetPos(descr.pos) | |
| local text = Text:new() | |
| text:SetText(string.format("%s (%d)", descr.actor.unitdatadef_id, descr.noise)) | |
| obj:Attach(text) | |
| --[[ local mesh = Mesh:new() | |
| local mesh_str = pstr("", 1024) | |
| AppendVerticesUnionCircles({descr.pos}, {descr.noise * const.SlabSizeX}, mesh_str) | |
| mesh:SetMeshFlags(const.mfWorldSpace) | |
| mesh:SetMesh(mesh_str) | |
| mesh:SetShader(ProceduralMeshShaders.enemy_aware_range) | |
| obj:Attach(mesh)--]] | |
| centers[i] = descr.pos | |
| ranges[i] = descr.noise * const.SlabSizeX | |
| list[i] = obj | |
| end | |
| if #list > 0 then | |
| ---[[ | |
| local mesh = Mesh:new() | |
| local mesh_str = pstr("", 1024) | |
| AppendVerticesUnionCircles(centers, ranges, mesh_str) | |
| mesh:SetMeshFlags(const.mfWorldSpace) | |
| mesh:SetMesh(mesh_str) | |
| mesh:SetShader(ProceduralMeshShaders.enemy_aware_range) | |
| mesh:SetColorFromTextStyle("EnemyAwareRange") | |
| mesh:SetPos(avg_pos:SetInvalidZ() / #list) | |
| list[#list + 1] = mesh--]] | |
| g_NoiseSources.shown = list | |
| end | |
| printf("%d logged noise sources displayed", #g_NoiseSources) | |
| end | |
| end | |
| function ResetNoiseSources() | |
| if not g_NoiseSources then return end | |
| if g_NoiseSources.shown then | |
| ToggleNoiseSources() | |
| end | |
| g_NoiseSources = {} | |
| end | |
| --OnMsg.NewCombatTurn = ResetNoiseSources | |
| OnMsg.CombatStart = ResetNoiseSources | |
| end | |
| -- Suspicion | |
| SuspicionThreshold = 160 -- Above this much the unit will become alerted | |
| local lSuspicionTickRate = 100 -- How often to add the tick amount | |
| local lSuspicionTickAmount = 10 -- The amount to add when hidden | |
| local lSuspicionTickAmountProjector = 6 -- The amount to add when hidden | |
| ProjectorSuspiciousApplyRange = 10 * const.SlabSizeX -- Enemies within this distance of the projector will be alerted | |
| local lSuspicionTickAmountProne = 5 -- The amount to add when hidden and in prone | |
| local lSuspicionTickAmountNotHidden = 16 -- The amount to add when not hidden | |
| local lSuspicionTickDownAmount = 2 -- The amount to remove when no unit is in range | |
| local lSuspicionTickMinDist = const.SlabSizeX * 2 -- If this close to an enemy then frontness doesn't matter (unless hidden or in the dark) | |
| local lSuspicionTickDistanceModOuter = const.SlabSizeX * 4 -- Past this distance in the sight radius the distance modifier is 100% | |
| local lCubicInIndex = GetEasingIndex("Cubic in") | |
| MapVar("lastSusUpdate", 0) | |
| function OnMsg.CombatEnd() | |
| for i, u in ipairs(g_Units) do | |
| u.suspicion = 0 | |
| end | |
| Msg("CombatEndAfterAwarenessReset") | |
| end | |
| function UpdateSuspicion(alliedUnits, enemyUnits, intermediate_update) | |
| if GameTime() - lastSusUpdate < lSuspicionTickRate then return end | |
| --NetUpdateHash("UpdateSuspicion", alliedUnits, enemyUnits, intermediate_update) | |
| local sneakLights | |
| if intermediate_update then | |
| sneakLights = GetSneakProjectorLights() | |
| end | |
| local sector = gv_Sectors[gv_CurrentSectorId] | |
| local anySusUpdated = false | |
| local susIncreasedBy = {} | |
| for i, ally in ipairs(alliedUnits) do | |
| ally.suspicion = ally.suspicion or 0 | |
| -- Performing an attack or something | |
| if not ally:IsIdleCommand() and not ally:IsInterruptable() then | |
| goto continue | |
| end | |
| if not IsValid(ally) or ally:IsDead() then goto continue end | |
| local allyDetectionModifier = 100 | |
| if HasPerk(ally, "Untraceable") then | |
| allyDetectionModifier = allyDetectionModifier - Untraceable:ResolveValue("enemy_detection_reduction") | |
| end | |
| if ally:HasStatusEffect("Darkness") then | |
| allyDetectionModifier = allyDetectionModifier + const.EnvEffects.DarknessDetectionRate | |
| end | |
| allyDetectionModifier = Max(0, allyDetectionModifier) | |
| local raiseSusLargest = 0 | |
| local raiseSusEnemy = false | |
| local max_sight_radius = MulDivRound(GetMaxSightRadius(), 1200, 1000) | |
| for i, enemy in ipairs(enemyUnits) do | |
| if enemy.retreating then goto continue end | |
| if enemy.command == "ExitCombat" then goto continue end | |
| if enemy:IsDead() then goto continue end | |
| if not IsCloser(enemy, ally, max_sight_radius) then | |
| goto continue | |
| end | |
| local seesAlly = HasVisibilityTo(enemy, ally) | |
| -- try to skip GetSightRadius calculations | |
| if not seesAlly then | |
| if raiseSusEnemy or not HasVisibilityTo(ally, enemy) then | |
| goto continue | |
| end | |
| end | |
| local sightRad, hidden, darkness = enemy:GetSightRadius(ally) | |
| local dist = enemy:GetDist(ally) | |
| local inRad = dist <= sightRad | |
| if inRad then | |
| if seesAlly then | |
| -- If in front of any enemy, add a bonus detection % | |
| -- If in the behind plane then have a smaller cut off. | |
| local angle_to_object = AngleDiff(CalcOrientation(enemy, ally), enemy:GetOrientationAngle()) | |
| if abs(angle_to_object) < 90*60 then | |
| local radiusLess = MulDivRound(sightRad, 80, 100) | |
| if dist > radiusLess then | |
| goto continue | |
| end | |
| end | |
| -- The larger this is, the closer the ally is to the enemy | |
| local distFromSightRad = sightRad - dist | |
| -- Decrease sus the further away you are | |
| local distanceModifier = false | |
| if distFromSightRad < lSuspicionTickDistanceModOuter then | |
| distanceModifier = Lerp(10, 100, distFromSightRad, sightRad) | |
| else | |
| distanceModifier = 100 | |
| end | |
| -- Modify the value based on how in front you are | |
| local frontnessModifier = false | |
| local maxDot = (4096 * 4096) * 2 | |
| local dot = cos(angle_to_object) * 4096 | |
| dot = EaseCoeff(lCubicInIndex, dot + 4096 * 4096, maxDot) | |
| frontnessModifier = Lerp(hidden and 30 or 40, 100, dot, maxDot) | |
| local closeInTheLight = false | |
| if hidden and not darkness and dist < lSuspicionTickMinDist and frontnessModifier > 60 then | |
| closeInTheLight = true | |
| end | |
| -- Get the base value based on a variety of factors | |
| local value = 0 | |
| if hidden and not closeInTheLight then | |
| if ally.stance == "Prone" then | |
| value = lSuspicionTickAmountProne | |
| else | |
| value = lSuspicionTickAmount | |
| end | |
| else | |
| value = lSuspicionTickAmountNotHidden | |
| end | |
| value = MulDivRound(value, distanceModifier, 100) | |
| value = MulDivRound(value, frontnessModifier, 100) | |
| if value > raiseSusLargest then | |
| raiseSusEnemy = enemy | |
| raiseSusLargest = value | |
| end | |
| end | |
| elseif not raiseSusEnemy and HasVisibilityTo(ally, enemy) then | |
| local extraRad = MulDivRound(sightRad, 1200, 1000) | |
| if dist <= extraRad then | |
| raiseSusEnemy = enemy | |
| end | |
| end | |
| ::continue:: | |
| end | |
| if sneakLights and IsMerc(ally) then | |
| local lightIndex = IsVoxelIlluminatedByObjects(ally:GetPos(), sneakLights) | |
| local val = lightIndex ~= 0 and lSuspicionTickAmountProjector or 0 | |
| if val > raiseSusLargest then | |
| raiseSusLargest = val | |
| local light = sneakLights[lightIndex] | |
| local originalLight = light and light.original_light | |
| local projector = originalLight and originalLight:GetParent() | |
| if projector then | |
| raiseSusEnemy = projector | |
| end | |
| if ally.suspicion + raiseSusLargest >= SuspicionThreshold and ally:HasStatusEffect("Hidden") then | |
| ally:RemoveStatusEffect("Hidden") | |
| -- In this specific case we want this status effect to have a removal message. | |
| -- Copied from AddStatusEffect's floating text. | |
| CreateMapRealTimeThread(function() | |
| WaitPlayerControl() | |
| CreateFloatingText(ally, T{488962074575, "- <DisplayName>", Hidden}, nil, nil, true) | |
| end) | |
| PushUnitAlert("projector", ally, enemyUnits, projector) | |
| end | |
| end | |
| end | |
| local oldSus = ally.suspicion | |
| if raiseSusLargest > 0 then | |
| ally.suspicion = ally.suspicion + raiseSusLargest | |
| susIncreasedBy[#susIncreasedBy + 1] = { unit = raiseSusEnemy, amount = raiseSusLargest, sees = ally } | |
| else | |
| ally.suspicion = ally.suspicion - lSuspicionTickDownAmount | |
| if raiseSusEnemy then | |
| susIncreasedBy[#susIncreasedBy + 1] = { unit = raiseSusEnemy, amount = -1, sees = ally } | |
| end | |
| end | |
| ally.suspicion = Clamp(ally.suspicion, 0, SuspicionThreshold) | |
| if ally.suspicion ~= oldSus and ally.ui_badge then | |
| local wasZeroNowIsnt = oldSus == 0 and ally.suspicion > 0 | |
| local wasntZeroNowIs = oldSus > 0 and ally.suspicion == 0 | |
| if wasZeroNowIsnt or wasntZeroNowIs then | |
| ally.ui_badge:UpdateActive() | |
| anySusUpdated = true | |
| end | |
| end | |
| if ally.suspicion >= SuspicionThreshold then | |
| if sector.warningStateEnabled and not sector.warningReceived then | |
| EnterWarningState(enemyUnits, alliedUnits, ally) | |
| anySusUpdated = true | |
| break | |
| else | |
| TriggerUnitAlert("discovered", ally) | |
| return | |
| end | |
| end | |
| ::continue:: | |
| end | |
| if anySusUpdated then | |
| local igi = GetInGameInterfaceModeDlg() | |
| if igi.crosshair then | |
| igi.crosshair:UpdateBadgeHiding() | |
| end | |
| end | |
| if not intermediate_update then | |
| lastSusUpdate = GameTime() | |
| end | |
| return susIncreasedBy | |
| end | |
| MapVar("g_CombatStartDetectedVR", false) | |
| function CombatStarDetectedtVR(unit) | |
| if (not g_Combat or g_Combat:ShouldEndCombat()) and unit:IsMerc() then | |
| if unit:HasStatusEffect("Hidden") then | |
| g_CombatStartDetectedVR = true | |
| PlayVoiceResponse(unit, "CombatStartDetected") | |
| end | |
| end | |
| end | |
| function OnMsg.CombatStart() | |
| if not g_CombatStartDetectedVR and g_Combat.starting_unit then | |
| if g_LastAttackStealth then | |
| PlayVoiceResponse(g_Combat.starting_unit, "CombatStartDetected") | |
| else | |
| PlayVoiceResponse(g_Combat.starting_unit, "CombatStartPlayer") | |
| end | |
| end | |
| end | |
| function OnMsg.CombatEnd() | |
| g_CombatStartDetectedVR = false | |
| end | |
| function OnMsg.UnitMovementDone(self, action_id) | |
| if not g_Combat or not self.team.player_team or self.command == "EnterCombat" then return end | |
| if action_id ~= "Move" then -- Attacks which include movement will push the "attack" alert. | |
| return | |
| end | |
| PushUnitAlert("discovered", self) | |
| end | |
| -- Warning State | |
| MapVar("WarningStateEnemies", {}) | |
| function EnterWarningState(enemyUnits, alliedUnits, triggeringUnit) | |
| local sector = gv_Sectors[gv_CurrentSectorId] | |
| if sector.inWarningState then return end -- already in Warning State | |
| WarningStateEnemies = enemyUnits | |
| -- Try to play a Banter from the nearest enemy to the unit that triggered the Warning | |
| local warningBanters = sector.warningBanters or {} | |
| if #warningBanters > 0 then | |
| local nearestEnemy = GetNearestEnemy(triggeringUnit) | |
| local banters, actors = FilterAvailableBanters(warningBanters, nil, {nearestEnemy}) | |
| if banters then | |
| local idx = InteractionRand(#banters, "PlayBanterEffect") + 1 | |
| -- Stop and look at triggering unit | |
| local lookAt = CalcOrientation(nearestEnemy, triggeringUnit) or nearestEnemy:GetAngle() | |
| nearestEnemy:SetBehavior() | |
| nearestEnemy:SetCommand("Idle") | |
| nearestEnemy:SetAngle(lookAt) | |
| PlayBanter(banters[idx], actors[idx]) | |
| end | |
| end | |
| for _, enemy in ipairs(WarningStateEnemies) do | |
| enemy:SetSide("neutral") | |
| end | |
| local timerId = "warningTimer_" .. sector.Id | |
| local timerText = sector.warningTimerText or T(243197217972, "Exit the Area") | |
| TimerCreate(timerId, timerText, sector.warningStateTimer) | |
| -- Reset Suspicion | |
| for _, unit in ipairs(alliedUnits) do | |
| unit.suspicion = 0 | |
| end | |
| sector.warningReceived = true | |
| sector.inWarningState = true | |
| Msg("OnEnterWarningState") | |
| ExecuteSectorEvents("SE_OnEnterWarningState", sector.Id) | |
| end | |
| function EndWarningState() | |
| local sector = gv_Sectors[gv_CurrentSectorId] | |
| if not sector or not sector.inWarningState then return end -- not in Warning State | |
| for _, enemy in ipairs(WarningStateEnemies) do | |
| enemy:SetSide("enemy1") | |
| end | |
| local timerId = "warningTimer_" .. sector.Id | |
| TimerDelete(timerId) -- might be better to move this after | |
| sector.inWarningState = false | |
| end | |
| function OnMsg.OnAttack(attacker) | |
| if IsMerc(attacker) then | |
| local sector = gv_Sectors[gv_CurrentSectorId] | |
| if sector.inWarningState then | |
| EndWarningState() | |
| attacker.suspicion = SuspicionThreshold | |
| end | |
| end | |
| end | |
| function OnMsg.TimerFinished(timerId) | |
| if string.starts_with(timerId, "warningTimer_") then | |
| EndWarningState() | |
| end | |
| end | |
| function OnMsg.AllSquadsLeftSector() -- this might not be the ideal place to do the clean up | |
| EndWarningState() | |
| end | |
| function OnMsg.CampaignTimeAdvanced() | |
| EndWarningState() | |
| end | |
| function PlayBestCombatNotification(units) | |
| local bestReason = false | |
| local bestUnitIdx = 0 | |
| for idx, unit in ipairs(units or empty_table) do | |
| if not bestReason then | |
| bestReason = unit.aware_reason | |
| bestUnitIdx = idx | |
| elseif unit.aware_reason and table.find(Presets.AwareReasons.Default, "display_name", unit.aware_reason[1]) > table.find(Presets.AwareReasons.Default, "display_name", bestReason[1]) then | |
| bestReason = unit.aware_reason | |
| bestUnitIdx = idx | |
| end | |
| end | |
| if bestReason then | |
| local unit = units[bestUnitIdx] | |
| local isAlly = unit and unit:IsPlayerAlly() | |
| ShowTacticalNotification(isAlly and "allyAwareReason" or "awareReason", false, bestReason) | |
| end | |
| end |