if FirstLoad then owInArea = 1 owHasLine = 2 owNoAllyHit = 4 owInSight = 8 end MapVar("g_Overwatch", setmetatable({}, weak_keys_meta)) MapVar("g_Pindown", setmetatable({}, weak_keys_meta)) function OnMsg.UnitRelationsUpdated() local attackers = SortUnitsMap(g_Overwatch) for _, data in ipairs(attackers) do local attacker = data.unit if IsValid(attacker.prepared_attack_obj) then local mode = attacker.prepared_attack_obj.mode if (mode == "Ally") ~= attacker.team.player_ally then DoneObject(attacker.prepared_attack_obj) attacker.prepared_attack_obj = nil end end attacker:UpdateOverwatchVisual() end end function Unit:UpdateOverwatchVisual(overwatch) overwatch = overwatch or g_Overwatch[self] if not overwatch then return end if (overwatch.permanent and overwatch.num_attacks <= 0) or not self.visible then if IsValid(self.prepared_attack_obj) then DoneObject(self.prepared_attack_obj) end self.prepared_attack_obj = nil return end local lightStepHideOW = HasPerk(SelectedObj, "LightStep") and IsValidTarget(SelectedObj) and self:IsOnEnemySide(SelectedObj) and GetInGameInterfaceMode() == "IModeCombatMovement" if lightStepHideOW then if IsValid(self.prepared_attack_obj) then DoneObject(self.prepared_attack_obj) end self.prepared_attack_obj = nil else if overwatch.origin_action_id == "EyesOnTheBack" then local step_positions, step_objs = GetStepPositionsInArea(overwatch.pos, overwatch.dist) local filtered_positions = table.ifilter(step_positions, function(idx, pt) return not IsOnFadedSlab(pt) end) overwatch.pos = SnapToPassSlab(overwatch.pos) or overwatch.pos:SetTerrainZ() local visual_pos = overwatch.pos:IsValidZ() and overwatch.pos or overwatch.pos:SetTerrainZ() local maxvalue, los_values = CheckLOS(step_positions, overwatch.pos, -1, overwatch.stance, -1, false, false) if not self.prepared_attack_obj then local data = { explosion_pos = visual_pos, stance = overwatch.stance, range = overwatch.dist, step_positions = step_positions, step_objs = step_objs, los_values = los_values or empty_table, } self.prepared_attack_obj = MortarAOEVisuals:new({ material_prefix = "EyesOnTheBack", mode = (self.team.control == "UI" or self.team.player_ally) and "Ally" or "Enemy"}, nil, data) end else if IsValid(self.prepared_attack_obj) and not IsKindOf(self.prepared_attack_obj, "OverwatchVisuals") then DoneObject(self.prepared_attack_obj) self.prepared_attack_obj = false end if not self.prepared_attack_obj then self.prepared_attack_obj = OverwatchVisuals:new({mode = (self.team.control == "UI" or self.team.player_ally) and "Ally" or "Enemy"}) end self.prepared_attack_obj:UpdateFromOverwatch(overwatch) end end end local function CalcEarlyOverwatchEntry(unit, action_id, weapon, args, attack_data, target_pos) local step_pos = attack_data.step_pos local stance = attack_data.stance local attacker_pos3D = attack_data.step_pos if not attacker_pos3D:IsValidZ() then attacker_pos3D = attacker_pos3D:SetTerrainZ() end local action = CombatActions[action_id] if not weapon then weapon = action:GetAttackWeapons(unit) end local aoe_params = action and action:GetAimParams(unit, weapon) or weapon:GetAreaAttackParams(action_id, unit) local distance = Clamp(attacker_pos3D:Dist(target_pos), aoe_params.min_range * const.SlabSizeX, aoe_params.max_range * const.SlabSizeX) local cone_angle = aoe_params.cone_angle local target_angle = CalcOrientation(step_pos, target_pos) return { pos = step_pos, stance = stance, angle = target_angle, cone_angle = cone_angle, target_pos = target_pos, dist = distance, min_distance_2d = aoe_params.min_distance_2d, dir = SetLen(target_pos - step_pos, guim), aim = args.aim or 0, action_id = weapon:GetBaseAttack(unit), origin_action_id = action_id, triggered_by = args.triggered_by, permanent = args.permanent, num_attacks = args.num_attacks or 1, orient = CalcOrientation(step_pos, target_pos), } end function Unit:OverwatchAction(action_id, cost_ap, args) self:EndInterruptableMovement() args = table.copy(args) args.OverwatchAction = true args.num_attacks = (g_Overwatch[self] and g_Overwatch[self].num_attacks) or args.num_attacks if self.opportunity_attack then local action = CombatActions[action_id] local results, attack_args = action:GetActionResults(self, args) self:AimTarget(attack_args, results, true) self:SetCommand("PreparedAttackIdle") return end local target = args.target if not IsPoint(target) and not IsValid(target) then if g_Combat then self:GainAP(cost_ap) CombatActionInterruped(self) end return end local action = CombatActions[action_id] local weapon = action:GetAttackWeapons(self) if not weapon then self:InterruptPreparedAttack() return end local dlg = GetInGameInterfaceModeDlg() if dlg and dlg:HasMember("dont_return_camera_on_close") and not g_AIExecutionController then dlg.dont_return_camera_on_close = true end local wasInterruptable if not g_Combat then wasInterruptable = self.interruptable if wasInterruptable then self:EndInterruptableMovement() end if not self:HasStatusEffect("ManningEmplacement") then self:UninterruptableGoto(self:GetVisualPos()) -- stop on the nearest free slab else local handle = self:GetEffectValue("hmg_emplacement") local obj = handle and HandleToObject[handle] if obj then -- make sure the unit is correctly positioned when loading a save local visual = obj.weapon and obj.weapon:GetVisualObj() local fire_spot = visual:GetSpotBeginIndex("Unit") local fire_pos = visual:GetSpotPos(fire_spot) self:SetPos(fire_pos) end end self:SetTargetDummyFromPos() self:UpdateAttachedWeapons() self:ExplorationStartCombatAction(action_id, self:GetMaxActionPoints(), args) end local target_pos = IsValid(target) and target:GetPos() or target local results, attack_args = action:GetActionResults(self, args) args.step_pos = attack_args.step_pos args.stance = attack_args.stance args.can_use_covers = false self:SetCombatBehavior("OverwatchAction", {action_id, cost_ap, args}) if not g_Combat or args.permanent then self:SetBehavior("OverwatchAction", {action_id, cost_ap, args}) end local overwatch = CalcEarlyOverwatchEntry(self, action_id, weapon, args, attack_args, target_pos) self:UpdateOverwatchVisual(overwatch) if not args.activated then self:ProvokeOpportunityAttacks(action, "attack interrupt") end if attack_args.stance and self.stance ~= attack_args.stance then self:DoChangeStance(attack_args.stance) end self:PrepareToAttack(attack_args, results) if not args.activated then self:ProvokeOpportunityAttacks(action, "attack reaction") PlayFX("OverwatchActivate", "start", self) end local step_pos = attack_args.step_pos local stance = attack_args.stance local attacker_pos3D = attack_args.step_pos if not attacker_pos3D:IsValidZ() then attacker_pos3D = attacker_pos3D:SetTerrainZ() end local aoe_params = action and action:GetAimParams(self, weapon) or weapon:GetAreaAttackParams(action_id, self) local distance = Clamp(attacker_pos3D:Dist(target_pos), aoe_params.min_range * const.SlabSizeX, aoe_params.max_range * const.SlabSizeX) local cone_angle = aoe_params.cone_angle local target_angle = CalcOrientation(step_pos, target_pos) local expiration_turn = args.expiration_turn if not expiration_turn then if args.permanent then expiration_turn = -1 else expiration_turn = (g_Combat and g_Combat.current_turn or 1) + 1 end end if cone_angle >= 180*60 then -- special case for Omryn's ability self.return_pos = false end args.triggered_by = args.triggered_by or {} -- create this in args so it gets automatically saved/restored args.expiration_turn = expiration_turn g_Overwatch[self] = { pos = step_pos, stance = stance, angle = target_angle, cone_angle = cone_angle, target_pos = target_pos, dist = distance, min_distance_2d = aoe_params.min_distance_2d, dir = SetLen(target_pos - step_pos, guim), aim = args.aim or 0, action_id = self:GetDefaultAttackAction("ranged", "ungrouped", nil, true, "ignore").id, origin_action_id = action_id, triggered_by = args.triggered_by, permanent = args.permanent, expiration_turn = expiration_turn, num_attacks = args.num_attacks or 1, orient = CalcOrientation(step_pos, target_pos), weapon_id = weapon.id, } self:UpdateOverwatchVisual() self:OnOverwatchPlaced() if action_id ~= "MGSetup" and action_id ~= "MGRotate" then self.ActionPoints = 0 Msg("UnitAPChanged", self, action_id) end Msg("OverwatchChanged",self) args.activated = true if wasInterruptable then self:BeginInterruptableMovement() end self:SetCommand("PreparedAttackIdle") end function Unit:OnOverwatchPlaced() local overwatch = g_Overwatch[self] if not overwatch then return end local step_pos = overwatch.pos local distance = overwatch.dist local stance = overwatch.stance local cone_angle = overwatch.cone_angle local target_angle = overwatch.angle local enemies = table.ifilter(GetAllEnemyUnits(self), function(_, enemy) return enemy:GetDist(step_pos) <= distance end) if #enemies > 0 then local maxvalue, los_values = CheckLOS(enemies, step_pos, distance, stance, cone_angle, target_angle, false) -- reveal & mark enemies in the area for i, los in ipairs(los_values) do if los then if enemies[i]:HasStatusEffect("Hidden") then CombatLog("short", T{353305209140, " was revealed by enemy overwatch", enemies[i]}) enemies[i]:RemoveStatusEffect("Hidden") end if g_Combat and HasPerk(self, "Spotter") then enemies[i]:AddStatusEffect("Marked") end end end end end function OnMsg.CombatStart(dynamic_data) if not dynamic_data then for _, unit in ipairs(g_Units) do unit:OnOverwatchPlaced() end end end function Unit:GetOverwatchAttacksAndAim(action, args, unit_ap) action = action or CombatActions.Overwatch local weapon = action:GetAttackWeapons(self) local attack = self:GetDefaultAttackAction() unit_ap = unit_ap or (g_Combat and self:GetUIActionPoints() or self:GetMaxActionPoints()) args = table.copy(args) args.action_cost_only = true local cost = action:GetAPCost(self, args) if cost < 0 then return 1 end local ap = unit_ap - cost local atk_cost = attack:GetAPCost(self, args) local attacks = 1 + ap / atk_cost attacks = self:CallReactions_Modify("OnCalcOverwatchAttacks", attacks, action, args) local minAim, maxAim = self:GetBaseAimLevelRange(action) return attacks, minAim or 0 end function Unit:GetOverwatchTarget() return g_Overwatch[self] and g_Overwatch[self].target_pos end function Unit:InvalidatePindownLinesCache() if self.combat_cache then self.combat_cache.pindown_line_data = nil end end local function InvalidatePindownLinesCaches() for _, unit in ipairs(g_Units) do unit:InvalidatePindownLinesCache() end end OnMsg.UnitMovementDone = InvalidatePindownLinesCaches OnMsg.DestructionPassDone = InvalidatePindownLinesCaches OnMsg.CoversChanged = InvalidatePindownLinesCaches function Unit:HasPindownLineCached(target, target_spot_group, step_pos) if not self.combat_cache or not IsValid(target) then return self:HasPindownLine(target, target_spot_group, step_pos) end self.combat_cache.pindown_line_data = self.combat_cache.pindown_line_data or {} local hash = xxhash(target.handle, target_spot_group, step_pos) local value = self.combat_cache.pindown_line_data[hash] if value ~= nil then return value end value = self:HasPindownLine(target, target_spot_group, step_pos) or false self.combat_cache.pindown_line_data[hash] = value return value end function Unit:HasPindownLine(target, target_spot_group, step_pos) if type(target) == "number" then target = HandleToObject[target] end if not IsKindOf(target, "Unit") or not HasVisibilityTo(self, target) then return false end local weapon = self:GetActiveWeapons("Firearm") local lof_params = { weapon = weapon, step_pos = step_pos, target_spot_group = target_spot_group, can_use_covers = false, } local lof = GetLoFData(self, target, lof_params) if not lof or lof.los == 0 then -- check line of sight to target return false end -- check line of fire to the specified body part local idx = table.find(lof.lof, "target_spot_group", target_spot_group) return idx and not lof.lof[idx].stuck end function Unit:PinDown(action_id, cost_ap, args) if self.opportunity_attack then self:SetCommand("PreparedAttackIdle") return end local target = args.target if not IsPoint(target) and not IsValid(target) then self:GainAP(cost_ap) CombatActionInterruped(self) return end args = table.copy(args) self:SetCombatBehavior("PinDown", {action_id, cost_ap, args}) local action = CombatActions[action_id] local results, attack_args = action:GetActionResults(self, args) local lof_idx = table.find(attack_args.lof, "target_spot_group", attack_args.target_spot_group or "Torso") local lof_data = attack_args.lof and attack_args.lof[lof_idx or 1] local attack_pos = lof_data.attack_pos local target_pos = lof_data.target_pos if not self:HasPindownLine(target, attack_args.target_spot_group) then self:InterruptPreparedAttack() self:GainAP(cost_ap) CombatActionInterruped(self) return end g_Pindown[self] = { action_id = action_id, target = target, target_pos = target:GetPos(), target_stance = target.stance, target_spot_group = attack_args.target_spot_group, aim = args.aim, weapon_id = results.weapon.id, } if not args.activated then self:ProvokeOpportunityAttacks(action, "attack interrupt") end self:PrepareToAttack(attack_args, results) if not args.activated then assert(IsValidTarget(target)) target:AddStatusEffect("Marked") if HasPerk(self, "HawksEye") then target:AddStatusEffect("Exposed") end self:ProvokeOpportunityAttacks(action, "attack reaction") PlayFX("PindownActivate", "start", self) end if IsValid(self.prepared_attack_obj) then DoneObject(self.prepared_attack_obj) end local meshPtr = pstr("") local dir = target_pos - attack_pos local length = dir:Len() CRTrail_AppendLineSegment(meshPtr, attack_pos, target_pos, false, false, false) self.prepared_attack_obj = PlaceObject("Mesh") self.prepared_attack_obj:SetMeshFlags(const.mfWorldSpace) local mat = CRM_VisionLinePreset:GetById(self.team.player_ally and "PreparedAttack" or "PreparedAttackEnemy"):Clone() mat.length = length self.prepared_attack_obj:SetCRMaterial(mat) self.prepared_attack_obj:SetColorFromTextStyle("PreparedAttack") self.prepared_attack_obj:SetMesh(meshPtr) self.prepared_attack_obj:SetPos(attack_pos) self.ActionPoints = 0 Msg("UnitAPChanged", self, action_id) Msg("OverwatchChanged",self) args.activated = true self:SetCommand("PreparedAttackIdle") end function Unit:PrepareBombard(action_id, cost_ap, args) local target = args.target if not IsPoint(target) and not IsValid(target) then self:GainAP(cost_ap) CombatActionInterruped(self) return end local action = CombatActions[action_id] args = table.copy(args) if cost_ap <= 0 then cost_ap = action:GetAPCost(self) end args.spent_ap = cost_ap args.prediction = false if not g_Combat then self:ExplorationStartCombatAction(action_id, cost_ap, args) self:SetBehavior("PrepareBombard", {action_id, cost_ap, args}) end self:SetCombatBehavior("PrepareBombard", {action_id, cost_ap, args}) self:PushDestructor(function(self) self:SetBehavior() self:SetCombatBehavior() end) local isPlayerTurn = self:IsMerc() and not g_AIExecutionController self:ProvokeOpportunityAttacks(action, "attack interrupt") local results, attack_args = action:GetActionResults(self, args) self:PrepareToAttack(attack_args, results) -- weapon load local voxel_pos = GetPassSlab(self) or SnapToVoxel(self):SetZ(self:GetVisualPos():z()) self:SetPos(voxel_pos) self:SetAxisAngle(axis_z, self:GetVisualOrientationAngle(), 0) PlayTransitionAnims(self, "nw_Standing_Idle") self:SetState("nw_Standing_MortarLoad", const.eKeepComponentTargets) local weapon_pos, weapon_angle = self:GetSpotLoc(self:GetSpotBeginIndex("Mortar")) local duration = self:TimeToAnimEnd() local move_pos = voxel_pos + self:GetStepVector() if move_pos:IsValidZ() then local stepz = GetVoxelStepZ(move_pos) if stepz then move_pos = move_pos:SetZ(stepz) end end self:SetPos(move_pos, duration) self:SetAngle(self:GetAngle() + self:GetStepAngle(), duration) local t = self:TimeToMoment(1, "hit") if t then Sleep(t) end local weapon = self:GetActiveWeapons() self.bombard_weapon = weapon:GetVisualObj() if self.bombard_weapon then self.bombard_weapon:Detach() self.bombard_weapon:SetState("idle") self.bombard_weapon:SetPos(weapon_pos) self.bombard_weapon:SetAxisAngle(axis_z, weapon_angle, 0) self.bombard_weapon:SetHierarchyEnumFlags(const.efVisible) self.bombard_weapon:SetContourOuterOccludeRecursive(true) end Sleep(self:TimeToAnimEnd()) self:SetState("nw_Standing_MortarIdle", const.eKeepComponentTargets) if IsValid(self.prepared_bombard_zone) then DoneObject(self.prepared_bombard_zone) end local ordnance = results.ordnance local bombard_radius = weapon.BombardRadius local bombard_shots = results.fired PlayFX("BombardZoneSetup", "start", self) local zone = PlaceObject("BombardZone") local target_pos = IsValid(target) and target:GetPos() or target -- mishap check & effect if IsKindOf(weapon, "MishapProperties") then local chance = weapon:GetMishapChance(self, target_pos) if not CheatEnabled("AlwaysHit") and (CheatEnabled("AlwaysMiss") or self:Random(100) < chance) then target_pos = target_pos self:ShowMishapNotification(action) end end local time if not g_Combat then time = 5000 end zone.attacker = self zone:Setup(target_pos, bombard_radius, self.team.side, ordnance, bombard_shots, time) zone.action_id = action_id zone.weapon_id = weapon.id zone.weapon_condition = results.condition self.prepared_bombard_zone = zone if isPlayerTurn then SnapCameraToObj(zone,nil,nil,200) Sleep(const.Combat.BombardSetupHoldTime) end self:ProvokeOpportunityAttacks(action, "attack reaction") self:PopDestructor() if g_Combat then self.ActionPoints = 0 Msg("UnitAPChanged", self, action_id) else self:SetBehavior("PreparedBombardIdle") end self:SetCombatBehavior("PreparedBombardIdle") self:SetCommand("PreparedBombardIdle") end function Unit:PreparedAttackIdle() if SelectedObj == self then local overwatch = g_Overwatch[self] self:FlushCombatCache() self:RecalcUIActions(true) ObjModified(self) end self:SetTargetDummy(self:GetPos(), self:GetOrientationAngle(), self:GetStateText(), 0) Msg("Idle", self) Halt() end function Unit:PreparedBombardIdle() self:PushDestructor(function(self) self:SetBehavior() self:SetCombatBehavior() if IsValid(self.prepared_bombard_zone) then DoneObject(self.prepared_bombard_zone) end self.prepared_bombard_zone = nil end) if not IsValid(self.prepared_bombard_zone) then self:PopAndCallDestructor() return end if not self.bombard_weapon then local weapon = self:GetActiveWeapons() self.bombard_weapon = weapon and weapon:GetVisualObj() end if self.bombard_weapon then local start_pos = GetPassSlab(self) or SnapToVoxel(self):SetZ(self:GetVisualPos():z()) local start_angle = self:GetAngle() - self:GetStepAngle("nw_Standing_MortarLoad") local mortar_spot = self:GetSpotBeginIndex("Mortar") local weapon_pos = start_pos + Rotate(GetEntitySpotPos(self:GetEntity(), mortar_spot), start_angle) local weapon_angle = start_angle + GetEntitySpotAngle(self:GetEntity(), mortar_spot) self.bombard_weapon:Detach() self.bombard_weapon:SetState("idle") self.bombard_weapon:SetPos(weapon_pos) self.bombard_weapon:SetAxisAngle(axis_z, weapon_angle, 0) self.bombard_weapon:SetEnumFlags(const.efVisible) end self:SetState("nw_Standing_MortarIdle", const.eKeepComponentTargets) self:SetTargetDummy(self:GetPos(), self:GetOrientationAngle(), self:GetStateText(), 0) Msg("Idle", self) if SelectedObj == self then SetInGameInterfaceMode(g_Combat and "IModeCombatMovement" or "IModeExploration") Sleep(1) if g_Combat then GetInGameInterfaceModeDlg():NextUnit() end end Halt() end function Unit:HolsterBombardWeapon() if IsValid(self.bombard_weapon) then local item = self.bombard_weapon.weapon if not item or self:GetEquippedWeaponSlot(item) then AttachVisualItems(self, { self.bombard_weapon }, 0, true) else DoneObject(self.bombard_weapon) end end self.bombard_weapon = nil end function Unit:PreparedBombardEnd() self:SetState("nw_Standing_MortarEnd", const.eKeepComponentTargets) local duration = self:TimeToAnimEnd() self:SetAngle(self:GetAngle() + self:GetStepAngle(), duration) self:SetPos(GetPassSlab(self) or SnapToVoxel(self):SetZ(self:GetVisualPos():z()), duration) Sleep(self:TimeToMoment(1, "hit") or duration) self:HolsterBombardWeapon() Sleep(self:TimeToAnimEnd()) end local PreparedAttackCommands = { PreparedAttackIdle = true, PreparedBombardIdle = true, } local PreparedAttackBehaviors = { PinDown = true, OverwatchAction = true, PrepareBombard = true, PrepareBombardIdle = true, OverwatchAction = true, } function Unit:HasPreparedAttack() return PreparedAttackCommands[self.command] or PreparedAttackBehaviors[self.behavior] or PreparedAttackBehaviors[self.combat_behavior] end function Unit:GetPinDownTarget() return g_Pindown[self] and g_Pindown[self].target end -- MeleeTraining area function Unit:UpdateMeleeTrainingVisual() local contour_visible if g_Combat and #GetEnemies(self) > 0 and not HasCombatActionInProgress(self) then contour_visible = self:CanUseMeleeTraining() and (self:IsNPC() or IsCompetitiveGame() and NetPlayerSide() ~= self.team.side) end if contour_visible then local pos = self:GetPos() if not IsValid(self.melee_threat_contour) or self.melee_threat_contour:GetDist(pos) > 0 then local voxels = GetMeleeRangePositions(self) voxels = voxels or { } table.insert(voxels, point_pack(self:GetPos())) local is_ally = self.team.side == "player1" or self.team.side == "player2" if not IsValid(self.melee_threat_contour) then self.melee_threat_contour = MeleeAOEVisuals:new({vstate = "Deployed"}, nil, {voxels = voxels, pos = pos, mode = is_ally and "Ally" or "Enemy"}) else self.melee_threat_contour:Init({voxels = voxels, pos = pos, mode = is_ally and "Ally" or "Enemy"}) end end elseif not contour_visible and IsValid(self.melee_threat_contour) then DoneObject(self.melee_threat_contour) self.melee_threat_contour = nil end end function Unit:CanUseMeleeTraining() return not self:IsIncapacitated() and self:IsAware() and not self:IsDefeatedVillain() and self.stance ~= "Prone" and not self:HasStatusEffect("Protected") and self:CanActivatePerk("MeleeTraining") and not not (self:GetActiveWeapons("MeleeWeapon") or not self:GetActiveWeapons()) and not self:HasStatusEffect("BandageInCombat") and not self:HasStatusEffect("Unconscious") end function OnMsg.UnitMovementStart(unit) -- check if the unit is pindown target for _, attacker in ipairs(g_Units) do local descr = g_Pindown[attacker] or empty_table if descr.target == unit then if IsValid(attacker.prepared_attack_obj) then DoneObject(attacker.prepared_attack_obj) attacker.prepared_attack_obj = nil end end end end local function UpdatePindowns() if not g_Combat then if next(g_Pindown) ~= nil then for _, unit in ipairs(g_Units) do if g_Pindown[unit] then unit:InterruptPreparedAttack() end end end return end for attacker, descr in sorted_handled_obj_key_pairs(g_Pindown) do local target = descr.target if type(target) == "number" then target = HandleToObject[target] end if not IsValidTarget(target) then attacker:InterruptPreparedAttack() else local changed = not IsValid(target) or target:GetDist(descr.target_pos) > 0 or target.stance ~= descr.target_stance if changed then if not attacker:HasPindownLine(target, descr.target_spot_group) then attacker:InterruptPreparedAttack() else -- reset the command to reenter the pindown (face new location, recreate visual, etc) if attacker.command == "PreparedAttackIdle" then CreateGameTimeThread(attacker.SetCommand, attacker, "Idle") end end end end end end function OnMsg.UnitMovementDone(unit) unit:UpdateMeleeTrainingVisual() end function OnMsg.UnitAnyMovementStart(unit) unit:UpdateMeleeTrainingVisual() end function OnMsg.Idle(unit) unit:UpdateMeleeTrainingVisual() if not HasAnyCombatActionInProgress("check all") then -- can be true if move_and_attack is in progress UpdatePindowns() end end OnMsg.UnitDieStart = UpdatePindowns function Unit:RemovePreparedAttackVisuals() if IsValid(self.melee_threat_contour) then DoneObject(self.melee_threat_contour) self.melee_threat_contour = nil end if IsValid(self.prepared_attack_obj) then DoneObject(self.prepared_attack_obj) end self.prepared_attack_obj = nil end function Unit:InterruptPreparedAttack(begin_turn) if not self.team or self.team.side == "neutral" then return end if not self:IsDead() and not self:IsDefeatedVillain() and not self:IsDowned() and not self:HasStatusEffect("BandageInCombat") then self:SetCombatBehavior() if self.behavior == "OverwatchAction" or self.behavior == "PrepareBombard" then self:SetBehavior() end end if IsValid(self.prepared_attack_obj) then DoneObject(self.prepared_attack_obj) end self.prepared_attack_obj = nil if g_Overwatch[self] then g_Overwatch[self] = nil self:RemoveStatusEffect("StationedMachineGun") Msg("OverwatchChanged") PlayFX("OverwatchActivate", "end", self) elseif g_Pindown[self] then g_Pindown[self] = nil Msg("OverwatchChanged") PlayFX("PindownActivate", "end", self) end if IsValid(self.prepared_bombard_zone) then DoneObject(self.prepared_bombard_zone) end self.prepared_bombard_zone = nil self:RecalcUIActions(true) ObjModified(self) if self:HasPreparedAttack() then CreateGameTimeThread(function() if IsValid(self) and not self:IsIncapacitated() then self:SetCommand(self.bombard_weapon and "PreparedBombardEnd" or "Idle") end end) else self:HolsterBombardWeapon() end if not g_Combat and not g_StartingCombat then if self:GetStatusEffect("EyesOnTheBack") and self.signature_recharge then self:RechargeSignature("EyesOnTheBack") end self:RemoveStatusEffect("SpentAP", "all") end end function Unit:InterruptEnd() if self.interrupted then self.interrupted = false self:SetAnimSpeedModifier(1000) self:UpdateMoveSpeed() if IsValidThread(self.pain_thread) then Wakeup(self.pain_thread) end if CurrentThread() ~= self.command_thread then Wakeup(self.command_thread) end end end function Unit:InterruptBegin() if IsValid(self) and self.interruptable and not self.interrupted and not self:IsDead() then self.interrupted = true self:UpdateMoveSpeed() if IsValidThread(self.pain_thread) then Wakeup(self.pain_thread) end if CurrentThread() == self.command_thread then if self:GetStepLength() > 0 then self:SetState(self:GetIdleBaseAnim()) end self:SetAnimSpeedModifier(30) else Wakeup(self.command_thread) end end end local OverwatchSpotTargetPreferOrder = { Torso = 1, Groin = 2, Head = 4, Arms = 5, Legs = 6 } function Unit:OverwatchCheck(target, target_dummies, conditions, sync) if #target_dummies == 0 or target:HasStatusEffect("Hidden") then return end local overwatch = g_Overwatch[self] if not overwatch or overwatch.num_attacks <= 0 then --or overwatch.triggered_by[target.handle] then return end --local action = CombatActions[overwatch.action_id] local action = self:GetDefaultAttackAction("ranged", "ungrouped") local aim_type = action.AimType local is_aoe = aim_type == "cone" or aim_type == "aoe" or aim_type == "parabola aoe" or aim_type == "line aoe" local weapon1, weapon2 = action:GetAttackWeapons(self) local base_sight = self:HasStatusEffect("ManningEmplacement") and const.Combat.AwareSightRange or false local sight = self:GetSightRadius(target, base_sight) local attack_args = { obj = self, --action_id = overwatch.action_id, action_id = action.id, step_pos = overwatch.pos, occupied_pos = self:GetOccupiedPos(), stance = overwatch.stance, range = overwatch.dist, angle = overwatch.angle, cone_angle = overwatch.cone_angle, can_use_covers = false, group_spots = false, -- dual shot should find out which spot should be target require_los = true, prediction = true, } local wdata1, wdata2 if weapon1 then attack_args.weapon = weapon1 wdata1 = GetLoFData(self, target_dummies, attack_args) or false end if weapon2 then attack_args.weapon = weapon2 wdata2 = GetLoFData(self, target_dummies, attack_args) or false end local trigger_idx, trigger_best_target_idx for i, target_dummy in ipairs(target_dummies) do local lof1 = wdata1 and wdata1[i] local lof2 = wdata2 and wdata2[i] conditions[i] = conditions[i] or 0 local in_area = lof1 and lof1.lof or lof2 and lof2.lof if in_area then conditions[i] = bor(conditions[i], owInArea) end -- do sight check manually here instead of in LOF check to discern between "in larger cone, out of sight" and "out of cone" cases local dist = self:GetDist(IsValid(target_dummy) and target_dummy or target_dummy.pos or target_dummy.obj) if in_area then conditions[i] = bor(conditions[i], owHasLine) end if in_area and (dist <= sight) then conditions[i] = bor(conditions[i], owInSight) local best_idx, best_value if not is_aoe then local spot_lof1 = lof1 and lof1.lof or empty_table local spot_lof2 = lof2 and lof2.lof or empty_table for k = 1, Max(#spot_lof1, #spot_lof2) do local hit_data1 = spot_lof1[k] or empty_table local hit_data2 = spot_lof2[k] or empty_table if (hit_data1.ally_hits_count or 0) == 0 and (hit_data2.ally_hits_count or 0) == 0 then local value = OverwatchSpotTargetPreferOrder[hit_data1.target_spot_group] or 100 if not best_idx or value < best_value or value == best_value and (not sync or self:Random(100) < 50) then best_idx, best_value = k, value end else best_idx, best_value = nil, nil break end end end if is_aoe or best_idx then conditions[i] = bor(conditions[i], owNoAllyHit) if not trigger_idx then trigger_idx = i trigger_best_target_idx = best_idx end end end end if trigger_idx then local lof = wdata1 and wdata1[trigger_idx] or wdata2 and wdata2[trigger_idx] local target_lof = trigger_best_target_idx and lof.lof[trigger_best_target_idx] for k, v in pairs(lof) do attack_args[k] = v end if target_lof then attack_args.best_ally_hits_count = 0 --attack_args.lof_pos1 = target_lof.lof_pos1 --attack_args.lof_pos2 = target_lof.lof_pos2 --attack_args.target_pos = target_lof.target_pos attack_args.target_spot = target_lof.target_spot attack_args.target_spot_group = target_lof.target_spot_group end return trigger_idx, attack_args end end local function GetNearbyPathfindingPath(unit) local path = {} local d = const.SlabSizeX local x, y, z = unit:GetVisualPosXYZ() if not unit:IsValidZ() then z = nil end local p0 = point(x, y, z) path[1] = p0 local vx, vy, vz = SnapToVoxel(x, y, z) local path_idx = unit:GetPathPointCount() while path_idx > 0 do local p1 = pf.GetPathPoint(unit, path_idx) if p1:IsValid() then local dx = p1:x() - vx local dy = p1:y() - vy if dx < -d or dx >= d or dy < -d or dy >= d then p1 = point(p0:x() + Clamp(dx, -d, d), p0:y() + Clamp(dy, -d, d), p1:z()) if p1 ~= path[#path] then path[#path + 1] = p1 end break end if p1 ~= path[#path] then path[#path + 1] = p1 end p0 = p1 else path_idx = path_idx - 1 p0 = pf.GetPathPoint(unit, path_idx) end path_idx = path_idx - 1 end return path end local function AddInterrupt(target_dummies, interrupts, return_result, idx, provoke_attack, obj, attack_args, keep_dummy, interrupt_pos) --if visible_only and not HasVisibilityTo(self.team, obj) then return end assert(return_result ~= "any") if return_result ~= "all" then if idx < #target_dummies and not keep_dummy then for k = 1, #target_dummies - idx do table.remove(target_dummies) end table.iclear(interrupts) end end table.insert(interrupts, {provoke_attack, obj, attack_args, target_dummies[idx], interrupt_pos}) end local _interrupts_traps local function CheckProvokeOpportunityAttacks_Trap(self, target_dummies, interrupts, return_result, visible_only, known_traps) local idx = 1 local posx, posy, posz _interrupts_traps = nil while idx <= #target_dummies do local dummy = target_dummies[idx] local prev_x, prev_y = posx, posy if IsValid(dummy) then if dummy:IsValidPos() then posx, posy, posz = dummy:GetPosXYZ() end else if dummy.pos then posx, posy, posz = dummy.pos:xyz() else local dummy_obj = dummy.obj or self if dummy_obj and dummy_obj:IsValidPos() then posx, posy, posz = dummy_obj:GetPosXYZ() end end end if posx then MapForEach(posx, posy, MaxTrapTriggerRadius * const.SlabSizeX, "Landmine", function(obj, idx, posx, posy, posz, prev_x, prev_y, self, target_dummies, interrupts, return_result, visible_only, known_traps) if obj:IsDead() then return end -- This isn't necessary for MaxTrapTriggerRadius above since we don't know where pos is relative to -- the center of a voxel, and in the worst case we'll just cut it off here. local radius = obj:GetTriggerDistance() if radius <= 0 then return end local dist = obj:GetDist2D(posx, posy) if dist >= radius then if prev_x and (prev_x ~= posx or prev_y ~= posy) and IsCloser2D(posx, posy, prev_x, prev_y, radius) then dist = DistPtToSegment2D(point(prev_x, prev_y), point(posx, posy), obj:GetPos()) end if dist >= radius + const.SlabSizeX * 3 / 4 then return end end local z = select(3, obj:GetPosXYZ()) or terrain.GetHeight(obj) if abs(z - (posz or terrain.GetHeight(posx, posy))) > const.SlabSizeZ then return end local passed_type local passed_idx = table.find(self.passed_interrupts, 2, obj) if passed_idx then passed_type = self.passed_interrupts[passed_idx][1] if passed_type == "trap" then return end end local side = self.team and self.team.side local seen = obj:SeenByTeam(side) if visible_only and not seen then return end if seen and not self.combat_path and not (side == "player1" or side == "player2" or side == "ally") then return end if dist < radius then if return_result == "any" then _interrupts_traps = true return "break" end AddInterrupt(target_dummies, interrupts, return_result, idx, "trap", obj) else -- exploration_interrupt if return_result ~= "any" and not self.combat_path and not self.goto_interrupted then local vx, vy, vz = SnapToVoxel(posx, posy, posz) local ox, oy = obj:GetPosXYZ() local d = radius + const.SlabSizeX if ox >= vx - d and oy >= vy - d and ox < vx + d and oy < vy + d then local nearbyPath = GetNearbyPathfindingPath(self) local pt_interrupt for k = 2, #nearbyPath do local dist, x, y, z = DistSegmentToPt2D(nearbyPath[k-1], nearbyPath[k]:x(), nearbyPath[k]:y(), 0, obj) if dist < radius then local interrupt_pt = GetPassSlab(x, y, z) if not interrupt_pt or IsCloser2D(obj, interrupt_pt, radius) then interrupt_pt = GetPassSlab(nearbyPath[k-1]) end _interrupts_traps = true AddInterrupt(target_dummies, interrupts, return_result, idx, "trap_approach", obj, nil, nil, interrupt_pt) break end end end end end if return_result ~= "any" and self.combat_path and not seen and not passed_type and idx > 1 and (not known_traps or not known_traps[obj]) then for i = idx, 1, -1 do local dummy = target_dummies[i] local interrupt_x, interrupt_y, interrupt_z = SnapToPassSlabXYZ(dummy.pos or dummy.obj) if interrupt_x and not IsCloser(obj, interrupt_x, interrupt_y, interrupt_z or const.InvalidZ, radius) then _interrupts_traps = true AddInterrupt(target_dummies, interrupts, return_result, i, "trap_interrupt", obj) break end end end end, idx, posx, posy, posz, prev_x, prev_y, self, target_dummies, interrupts, return_result, visible_only, known_traps) if return_result == "any" and _interrupts_traps then return true end end idx = idx + 1 end if _interrupts_traps then return true end end local function CheckProvokeOpportunityAttacks_MeleeInterruptsMovement(self, target_dummies, interrupts, return_result) local has_interrupt for i, team in ipairs(g_Teams) do if team:IsEnemySide(self.team) then for _, attacker in ipairs(team.units) do if attacker:CanUseMeleeTraining() and not table.find(self.passed_interrupts, 2, attacker) then local idx_melee_atk for idx, dummy in ipairs(target_dummies) do local can_melee_attack if IsValid(dummy) then local target = IsKindOf(dummy, "TargetDummy") and dummy.obj or dummy can_melee_attack = IsMeleeRangeTarget(attacker, nil, attacker.stance, target, nil, dummy.stance) else can_melee_attack = IsMeleeRangeTarget(attacker, nil, attacker.stance, dummy.obj, dummy.pos, dummy.stance) end if can_melee_attack then idx_melee_atk = idx elseif idx_melee_atk == idx - 1 then if return_result == "any" then return true end AddInterrupt(target_dummies, interrupts, return_result, idx_melee_atk, "melee", attacker) has_interrupt = true break end end end end end end return has_interrupt end local function CheckProvokeOpportunityAttacks_MeleeInterruptsRangedAttacks(self, target_dummies, interrupts, return_result) local has_interrupt for i, team in ipairs(g_Teams) do if team:IsEnemySide(self.team) then for _, attacker in ipairs(team.units) do if attacker:CanUseMeleeTraining() and not table.find(self.passed_interrupts, 2, attacker) then for idx, dummy in ipairs(target_dummies) do local can_melee_attack if IsValid(dummy) then local target = IsKindOf(dummy, "TargetDummy") and dummy.obj or dummy can_melee_attack = IsMeleeRangeTarget(attacker, nil, attacker.stance, target, nil, dummy.stance) else can_melee_attack = IsMeleeRangeTarget(attacker, nil, attacker.stance, dummy.obj, dummy.pos, dummy.stance) end if can_melee_attack then if return_result == "any" then return true end AddInterrupt(target_dummies, interrupts, return_result, idx, "melee", attacker) has_interrupt = true break end end end end end end return has_interrupt end local function CheckProvokeOpportunityAttacks_Overwatch(self, target_dummies, interrupts, visible_only, return_result) local overwatch_interrupt, fail_interrupt local attackers = SortUnitsMap(g_Overwatch) for _, data in ipairs(attackers) do local attacker = data.unit if attacker:IsIdleCommand() and attacker:IsOnEnemySide(self) and (g_Combat or (attacker.team.player_enemy and attacker:HasStatusEffect("ManningEmplacement"))) and (not visible_only or HasVisibilityTo(self.team, attacker)) and not table.find(self.passed_interrupts, 2, attacker) then local conditions = {} local idx, attack_args = attacker:OverwatchCheck(self, target_dummies, conditions, not visible_only) if idx then if return_result == "any" then return true end if fail_interrupt then table.remove(interrupts, fail_interrupt) fail_interrupt = nil end AddInterrupt(target_dummies, interrupts, return_result, idx, "overwatch", attacker, attack_args) overwatch_interrupt = #interrupts elseif not overwatch_interrupt and not fail_interrupt then -- check if there's a non-zero condition (in area) and add a provisional "interrupt" to indicate a failed trigger if no real trigger happens local trigger_condition for i, cond in ipairs(conditions) do if cond ~= 0 then trigger_condition = cond idx = i break end end if trigger_condition then if return_result == "any" then return true end AddInterrupt(target_dummies, interrupts, return_result, idx, "failoverwatch", attacker, trigger_condition, "keep") fail_interrupt = #interrupts end end end end if overwatch_interrupt or fail_interrupt then return true end end MapVar("__provoke_opportunity_attacks_target_dummies", {}) MapVar("__provoke_opportunity_attacks_interrupts", {}) function Unit:CheckProvokeOpportunityAttacks(action, trigger_type, target_dummies_all, visible_only, return_result, trigger_attack_type, known_traps) -- return by default the interrupts of the very first dummy if not self.team or self.team.side == "neutral" then return elseif IsSetpiecePlaying() then return elseif g_StartingCombat then -- units moving to their combat positions should not provoke opportunity attacks, only their combat movement -- todo: check interactions with traps/mines return elseif self.opportunity_attack then return -- already in opportunity attack (melee interrupt, pindown, overwatch, retaliate) elseif #target_dummies_all == 0 then return end local target if IsKindOf(target_dummies_all[1], "Unit") then target = target_dummies_all[1] else target = target_dummies_all[1].obj end if trigger_type == "move" and self.move_attack_action_id then action = CombatActions[self.move_attack_action_id] end local interruptAttackAvailable = self:CallReactions_And("OnCheckInterruptAttackAvailable", target, action) table.iclear(__provoke_opportunity_attacks_interrupts) table.iclear(__provoke_opportunity_attacks_target_dummies) for i, dummy in ipairs(target_dummies_all) do __provoke_opportunity_attacks_target_dummies[i] = dummy end local target_dummies = __provoke_opportunity_attacks_target_dummies local interrupts = __provoke_opportunity_attacks_interrupts -- traps if trigger_type == "move" then if CheckProvokeOpportunityAttacks_Trap(self, target_dummies, interrupts, return_result, visible_only, known_traps) then if return_result == "any" then return true end end end -- melee interrupts (movement) if trigger_type == "move" and g_Combat and interruptAttackAvailable then if CheckProvokeOpportunityAttacks_MeleeInterruptsMovement(self, target_dummies, interrupts, return_result) then if return_result == "any" then return true end end end -- melee interrupts (ranged attacks;) if g_Combat and interruptAttackAvailable then local melee_trigger = (trigger_type == "attack interrupt" and trigger_attack_type ~= "melee") or (trigger_type == "attack reaction" and trigger_attack_type == "melee") if melee_trigger then if CheckProvokeOpportunityAttacks_MeleeInterruptsRangedAttacks(self, target_dummies, interrupts, return_result) then if return_result == "any" then return true end end end end -- overwatch if next(g_Overwatch) and interruptAttackAvailable then if trigger_type == "attack interrupt" or trigger_type == "move" and not HasPerk(self, "LightStep") then if CheckProvokeOpportunityAttacks_Overwatch(self, target_dummies, interrupts, visible_only, return_result) then if return_result == "any" then return true end end end end if #interrupts == 0 then return end -- move warnings last for i = #interrupts - 1, 1, -1 do local data = interrupts[i] local provoke_attack = data[1] if provoke_attack == "trap_interrupt" or provoke_attack == "trap_approach" then table.remove(interrupts, i) table.insert(interrupts, data) end end local provoke_idx = #target_dummies __provoke_opportunity_attacks_interrupts = {} return interrupts, provoke_idx end function Unit:FinishOpportunityAttack_Pindown() self:InterruptPreparedAttack() self:SetAttackReason() end function Unit:ProvokeOpportunityAttack_Pindown(obj, descr) --No need to reset them as it is assumed AIExecutionController:Done to run later on. self:InterruptBegin() LockCameraMovement("pindown") AdjustCombatCamera("set") descr = descr or g_Pindown[obj] obj:SetAttackReason(T(579571403585, "Pin Down"), true) local cmd_thread = CurrentThread() == self.command_thread if cmd_thread then self:PushDestructor(function(self) obj:FinishOpportunityAttack_Pindown() end) end local num_attacks = HasPerk(obj, "Killzone") and 2 or 1 for i = 1, num_attacks do local weapon = obj:GetActiveWeapons("Firearm") local default_action = obj:GetDefaultAttackAction() if not weapon or not default_action or not obj:CanAttack(self, weapon, default_action, 0, nil, "skip_ap_check") then break end if IsValidTarget(self) then obj:SetCommand("PinDownAttack", self, descr.action_id, descr.target_spot_group, descr.aim) while not obj:IsIdleCommand() do WaitMsg("Idle", 100) end end end -- hit makes target go Pain/Die and breaks the execution of the cleanup code below while not obj:IsIdleCommand() do WaitMsg("Idle") end if cmd_thread then self:PopDestructor() end obj:FinishOpportunityAttack_Pindown() end function Unit:FinishOpportunityAttack_Overwatch() local overwatch = g_Overwatch[self] self:SetAttackReason() if not overwatch then return end local action = CombatActions[overwatch.action_id] local weapon1, weapon2 = action:GetAttackWeapons(self) local jammed = not weapon1 or weapon1.jammed local out_of_ammo = not weapon1 or self:OutOfAmmo(weapon1) if weapon2 then jammed = jammed and weapon2.jammed out_of_ammo = out_of_ammo and self:OutOfAmmo(weapon2) end local remaining = overwatch.num_attacks - 1 overwatch.num_attacks = remaining if self.combat_behavior == "OverwatchAction" then self.combat_behavior_params[3].num_attacks = remaining end if jammed or out_of_ammo then self:InterruptPreparedAttack() elseif remaining <= 0 then if overwatch.permanent then CreateFloatingText(self, T(559296489948, "No remaining attacks"), "FloatingTextMiss") self:UpdateOverwatchVisual(overwatch) else self:InterruptPreparedAttack() end end end function Unit:ProvokeOpportunityAttack_Overwatch(obj, attack_args, target_dummy) local overwatch = g_Overwatch[obj] if not overwatch then return end --local action = CombatActions[overwatch.action_id] overwatch.triggered_by[self.handle] = true CombatLog("short", T{353305209140, " was revealed by enemy overwatch", self}) self:RemoveStatusEffect("Hidden") self:InterruptBegin() local reason = T(484641340197, "Overwatch") obj:SetAttackReason(reason, true) local cmd_thread = CurrentThread() == self.command_thread if cmd_thread then self:PushDestructor(function(self) obj:FinishOpportunityAttack_Overwatch() end) end attack_args.aim = overwatch.aim attack_args.origin_action_id = overwatch.origin_action_id assert(attack_args.target == self) attack_args.target_dummy = target_dummy attack_args.opportunity_attack = true attack_args.opportunity_attack_type = "Overwatch" if overwatch.cone_angle > 180*60 then attack_args.circular_overwatch = true end local lof_data for _, data in ipairs(attack_args.lof) do if data.ally_hits_count == 0 and data.target_spot_group == attack_args.target_spot_group then lof_data = data break end end lof_data = lof_data or attack_args.lof[1] table.clear(attack_args.lof) lof_data.target_spot_group = "Torso" -- treat resulting hits as if they hit the target in the torso attack_args.lof[1] = lof_data attack_args.target_spot_group = "Torso" local status local num_attacks = HasPerk(obj, "Killzone") and 2 or 1 for i = 1, num_attacks do local weapon = obj:GetActiveWeapons("Firearm") local default_action = obj:GetDefaultAttackAction("ranged", "ungrouped", nil, true, "ignore", {skip_ap_check = true}) if not weapon or not default_action or not obj:CanAttack(self, weapon, default_action, 0, nil, "skip_ap_check") then break end overwatch.action_id = default_action.id if IsValidTarget(self) then if IsKindOf(obj.prepared_attack_obj, "AOEActionVisuals") then obj.prepared_attack_obj:SetState("activate", self:GetPos()) end obj:SetCommand("OpportunityAttack", default_action.id, attack_args, status) if attack_args.circular_overwatch and obj.combat_behavior == "OverwatchAction" then local shot_vector = self:GetPos() - obj:GetPos() local target_pos = (obj:GetPos() + SetLen(shot_vector, overwatch.dist)):SetZ(overwatch.target_pos:z()) local args = obj.combat_behavior_params[3] if args then args.target = target_pos end end while not obj:IsIdleCommand() do WaitMsg("Idle", 100) end end end if cmd_thread then self:PopDestructor() end obj:FinishOpportunityAttack_Overwatch() end function Unit:FinishOpportunityAttack_Melee() self:SetAttackReason() self:UpdateMeleeTrainingVisual() end function Unit:ProvokeOpportunityAttack_Melee(unit) unit:SetAttackReason(CharacterEffectDefs.MeleeTraining.DisplayName, true) local cmd_thread = CurrentThread() == self.command_thread if cmd_thread then self:PushDestructor(function(self) unit:FinishOpportunityAttack_Melee() end) end local action = unit:GetDefaultAttackAction() if not action or action.ActionType ~= "Melee Attack" then action = CombatActions.MeleeAttack end unit:SetCommand("OpportunityMeleeAttack", self, action) self:InterruptBegin() while not unit:IsIdleCommand() do WaitMsg("Idle") end if cmd_thread then self:PopDestructor() end unit:FinishOpportunityAttack_Melee() end function Unit:ProvokeOpportunityAttack_Trap(obj) -- Trap explosions shouldn't be interruptable due to stuff like -- explosion fly as that can cause the attack to be cut short. local wasInterruptable = self.interruptable if wasInterruptable then self:EndInterruptableMovement() end self:InterruptBegin() obj:TriggerTrap(self, obj.attacker) if wasInterruptable then self:BeginInterruptableMovement() end end function Unit:ProvokeOpportunityAttack_TrapInterrupt(obj) if obj:IsDead() then return end local seen = obj:SeenByTeam(self.team.side) if not seen then obj:CheckDiscovered(self) seen = obj:SeenByTeam(self.team.side) end if seen then self:Interrupt() end end function Unit:ProvokeOpportunityAttacksFromList(list) for i, data in ipairs(list) do local provoke_attack, attacker, attack_args, target_dummy = table.unpack(data) if IsValid(attacker) then if provoke_attack == "trap" then self:ProvokeOpportunityAttack_Trap(attacker, attack_args, target_dummy) elseif provoke_attack == "trap_interrupt" then self:ProvokeOpportunityAttack_TrapInterrupt(attacker, attack_args, target_dummy) elseif attacker:IsKindOf("Unit") and not attacker:IsDead() and not attacker:IsDowned() then if provoke_attack == "pindown" then self:ProvokeOpportunityAttack_Pindown(attacker) elseif provoke_attack == "overwatch" then self:ProvokeOpportunityAttack_Overwatch(attacker, attack_args, target_dummy) elseif provoke_attack == "failoverwatch" then self:InterruptBegin() CreateFloatingText(self, T(231879654091, "Overwatch Failed"), "FloatingTextMiss") local condition = attack_args if band(condition, owHasLine) == 0 then CombatLog("important",T{174159992548, "() Overwatch failed: no line of sight", attacker = attacker:GetLogName()}) elseif band(condition, owInSight) == 0 then CombatLog("important",T{787029916541, "() Overwatch failed: out of sight range", attacker = attacker:GetLogName()}) elseif band(condition, owNoAllyHit) == 0 then CombatLog("important",T{121792198447, "() Overwatch failed: ally in the way", attacker = attacker:GetLogName()}) else CombatLog("important",T{957255271523, "() Overwatch failed", attacker = attacker:GetLogName()}) CombatLog("debug", T{Untranslated("overwatch fail condition: "), value = condition}) end elseif provoke_attack == "melee" then self:ProvokeOpportunityAttack_Melee(attacker, attack_args, target_dummy) end end if not self.passed_interrupts then self.passed_interrupts = {} end table.insert(self.passed_interrupts, data) end local provocationAttacksLeft = 0 for c = i, #list do local attackType = list[c][1] if attackType == "melee" or attackType == "overwatch" or attackType == "pindown" then provocationAttacksLeft = provocationAttacksLeft + 1 end end if provocationAttacksLeft == 1 then Msg("ActionCameraWaitSignalEnd") end end if self.interrupted then Sleep(const.Combat.ShootDelayAfterInterrupt) self:InterruptEnd() self:WaitPain() end end function Unit:ProvokeOpportunityAttacksWarning(trigger_type, interrupts) local warned_traps_pos = self.warned_traps_pos if warned_traps_pos and not self.goto_interrupted then self.warned_traps_pos = false end if not interrupts or not (self.team and self.team:IsPlayerControlled()) then return end if not self:IsInterruptable() then return end if trigger_type == "move" and self.team then for i, data in ipairs(interrupts) do local provoke_attack = data[1] if provoke_attack == "trap" or provoke_attack == "trap_approach" then local obj = data[2] if not obj:IsDead() and obj:SeenByTeam(self.team.side) then local interrupt_pos = data[5] if self.combat_path then self:Interrupt(interrupt_pos) else if warned_traps_pos then if IsCloser2D(interrupt_pos or self, warned_traps_pos, const.SlabSizeX) then self.warned_traps_pos = warned_traps_pos return end end self.warned_traps_pos = interrupt_pos or self:GetVisualPos() self:InterruptCommand("GotoSlab", interrupt_pos, nil, nil, nil, nil, nil, "interrupted") end return end end end end end function Unit:ProvokeOpportunityAttacks(action, trigger_type, target_dummy, trigger_attack_type) local target_dummies = { target_dummy or self.target_dummy or self } local interrupts = self:CheckProvokeOpportunityAttacks(action, trigger_type, target_dummies, nil, nil, trigger_attack_type) if not interrupts then return end self:ProvokeOpportunityAttacksFromList(interrupts, target_dummy) end function Unit:IsThreatened(enemies, mode) if not IsValid(self) then return end enemies = enemies or GetEnemies(self) local pos = self:GetVisualPos() for _, enemy in ipairs(enemies) do if (not mode or mode == "pindown") and g_Pindown[enemy] and g_Pindown[enemy].target == self then return true end local enemy_ow = g_Overwatch[enemy] if (not mode or mode == "overwatch") and enemy_ow then local angle = enemy_ow.orient or CalcOrientation(enemy:GetPos(), enemy_ow.target_pos) if CheckLOS(self, enemy, enemy_ow.dist, enemy.stance, enemy_ow.cone_angle, angle) and enemy:CanSee(self) then return true end end -- check melee threats if not mode or mode == "melee" then if enemy.stance ~= "Prone" and enemy:CanActivatePerk("MeleeTraining") and enemy:GetActiveWeapons("MeleeWeapon") then if IsMeleeRangeTarget(enemy, nil, enemy.stance, self, nil, self.stance) then self.is_melee_aim_last_turn = true return true end end end end end function Unit:IsUnderBombard() for _, bombard in ipairs(g_Bombard) do if IsCloser(self, bombard, (bombard.radius + g_Classes[bombard.ordnance].AreaOfEffect) * const.SlabSizeX) then return true end end end function Unit:IsUnderTimedTrap() for _, trap in pairs(g_Traps) do if trap.TriggerType == "Timed" and trap.visible and not trap.done and not trap:IsDead() then local trapCenter = trap:GetPos() local trapRadius = trap.AreaOfEffect * const.SlabSizeX if self:GetDist(trapCenter) <= trapRadius then return true end end end end function Unit:GetNumMGInterruptAttacks(skip_check) if not skip_check and not self:HasStatusEffect("StationedMachineGun") and not self:HasStatusEffect("ManningEmplacement") then return 0 end local action = self:GetDefaultAttackAction() local ap_cost = action:GetAPCost(self) if ap_cost <= 0 then return 0 end local ap = g_Combat and self:GetUIActionPoints() or self:GetMaxActionPoints() return const.Combat.MGFreeInterruptAttacks + ap / ap_cost end function Unit:UpdateNumOverwatchAttacks() local overwatch = g_Overwatch[self] if overwatch and overwatch.permanent then local num = self:GetNumMGInterruptAttacks() if num ~= overwatch.num_attacks then overwatch.num_attacks = num self:UpdateOverwatchVisual(overwatch) end end end function OnMsg.UnitAPChanged(unit) if g_Combat and g_Teams[g_Combat.team_playing] == unit.team then unit:UpdateNumOverwatchAttacks() end end local function TimeToNextMoment(obj, moment) local t = obj:TimeToMoment(1, moment) if not t then return false end local index = 1 while t == 0 do index = index + 1 t = obj:TimeToMoment(1, moment, index) end return t end function OnMsg.GatherFXMoments(list) table.insert(list, "InterruptableStart") table.insert(list, "InterruptableEnd") end MapRealTimeRepeat("OverwatchAreaUpdate", 200, function() if not g_Combat then return end for unit, overwatch in pairs(g_Overwatch) do unit:UpdateOverwatchVisual(overwatch) end end) function OnMsg.InterruptAttackStart(unit, target, action) if not HasVisibilityTo(target.team, unit) and (unit.team.side == "enemy1" or unit.team.side == "enemy2") then ShowTacticalNotification("interruptStealth", true, false, action) else ShowTacticalNotification("interrupt", true, false, action) end end function OnMsg.InterruptAttackEnd() HideTacticalNotification("interrupt") HideTacticalNotification("interruptStealth") end