Download Lua/Tactical/UnitOverwatch.lua from sirnii/myspace: direct link, hf CLI and curl.
- Browser
- Download file 58.3 kB
-
https://huggingface.co/spaces/sirnii/myspace/resolve/main/Lua/Tactical/UnitOverwatch.lua
- Command line
-
hf download hf://spaces/sirnii/myspace/Lua/Tactical/UnitOverwatch.lua
-
curl -L -o UnitOverwatch.lua https://huggingface.co/spaces/sirnii/myspace/resolve/main/Lua/Tactical/UnitOverwatch.lua
58.3 kB
| 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, "<LogName> 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, "<LogName> 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, "(<attacker>) Overwatch failed: no line of sight", attacker = attacker:GetLogName()}) | |
| elseif band(condition, owInSight) == 0 then | |
| CombatLog("important",T{787029916541, "(<attacker>) Overwatch failed: out of sight range", attacker = attacker:GetLogName()}) | |
| elseif band(condition, owNoAllyHit) == 0 then | |
| CombatLog("important",T{121792198447, "(<attacker>) Overwatch failed: ally in the way", attacker = attacker:GetLogName()}) | |
| else | |
| CombatLog("important",T{957255271523, "(<attacker>) Overwatch failed", attacker = attacker:GetLogName()}) | |
| CombatLog("debug", T{Untranslated("overwatch fail condition: <value>"), 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 |