--List of grenades that will result in different VR played when thrown SpecialGrenades = { [1] = "ConcussiveGrenade", [2] = "SmokeGrenade", [3] = "TearGasGrenade", [4] = "ToxicGasGrenade", [5] = "Molotov", [6] = "FlareStick" } function Unit:OnAttack(action, target, results, attack_args, holdXpLog) if type(action) == "string" then action = CombatActions[action] end if IsKindOf(results.weapon, "FirearmBase") then results.weapon.num_safe_attacks = Max(0, results.weapon.num_safe_attacks - 1) end if IsKindOf(results.weapon, "TransmutedItemProperties") and results.weapon.RevertCondition=="attacks" then results.weapon.RevertConditionCounter = results.weapon.RevertConditionCounter-1 if results.weapon.RevertConditionCounter== 0 then local slot_name = self:GetItemSlot(results.weapon) local new, prev = results.weapon:MakeTransmutation("revert") self:RemoveItem(slot_name, results.weapon) self:AddItem(slot_name, new) DoneObject(prev) self:UpdateOutfit() end end self.last_attack_pos = self:GetPos() -- Add Exposed on any Melee Attack hit if action.ActionType == "Melee Attack" and IsKindOf(target, "Unit") and not results.miss then target:AddStatusEffect("Exposed") end if IsValidTarget(target) then target.attacked_this_turn = target.attacked_this_turn or {} table.insert(target.attacked_this_turn, self) end local kill = #(results.killed_units or empty_table) > 0 if kill then Msg("OnKill", self, results.killed_units) self:CallReactions("OnUnitKill", results.killed_units) end -- singatures can't recharge themselves local originAction = CombatActions[attack_args.origin_action_id] if kill and (action.group ~= "SignatureAbilities" and (not originAction or originAction.group ~= "SignatureAbilities")) then self:UpdateSignatureRecharges("kill") end -- signature actions never recharge themselves, this has to be after UpdateSignatureRecharges if action.group == "SignatureAbilities" then local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 local rechargeTime = action:ResolveValue("rechargeTime") or const.Combat.SignatureAbilityRechargeTime if rechargeTime > 0 then self:AddSignatureRechargeTime(action.id, rechargeTime, recharge_on_kill > 0) end end Msg("OnAttack", self, action, target, results, attack_args) self:CallReactions("OnUnitAttack", self, action, target, results, attack_args) if IsKindOf(target, "Unit") then target:CallReactions("OnUnitAttack", self, action, target, results, attack_args) end local hitUnitFromAttack = false for _, hit in ipairs(results.hit_objs) do if IsKindOf(hit.obj, "Unit") then if hit.damage and hit.damage > 0 then hitUnitFromAttack = true break end end end if results.miss then Msg("AttackMiss", self, target) if IsKindOf(target, "Unit") and not IsMerc(target) then target:AddStatusEffect("AITauntCounter") if not hitUnitFromAttack then local effect = target:GetStatusEffect("AITauntCounter") if effect and effect.stacks >= 3 then PlayVoiceResponse(target, "AITaunt") end end end end -- Reward xp to Mercs on kill if kill and IsMerc(self) then if not g_AccumulatedTeamXP then g_AccumulatedTeamXP = {} end for i, unit in ipairs(results.killed_units) do if self:IsOnEnemySide(unit) then RewardTeamExperience(unit, GetCampaignPlayerTeam()) end end if not holdXpLog then LogAccumulatedTeamXP("debug") end end if next(results.killed_units) then local killCam = not not ActionCameraPlaying local waitTime = killCam and const.Combat.UnitDeathKillcamWait or const.Combat.UnitDeathWait Sleep(waitTime) if killCam then Msg("ActionCameraWaitSignalEnd") end end end function Unit:GetLastAttack() return self.last_attack_session_id and g_Units[self.last_attack_session_id] end local function ResetLastAttack(unit) if unit:IsAmbientUnit() then return end unit.last_attack_session_id = false local session_id = unit.session_id if session_id then for _, u in ipairs(g_Units) do if u.last_attack_session_id == session_id then u.last_attack_session_id = false end end end end function IsBasicAttack(action, attack_args) local basicAttack if attack_args.origin_action_id then basicAttack = CombatActions[attack_args.origin_action_id].basicAttack else basicAttack = action.basicAttack end return basicAttack end OnMsg.UnitMovementDone = ResetLastAttack OnMsg.UnitDied = ResetLastAttack MapVar("g_CurrentAttackActions", {}) -- stack of all started attack actions MapVar("g_Interrupt", false) function Unit:WaitAttack() self:UninterruptableGoto(self:GetVisualPos()) self.waiting_attack = true end function Unit:FirearmAttack(action_id, cost_ap, args, applied_status) -- SingleShot/DualShot if true then -- net debug code local effects = {} for i, effect in ipairs(self.StatusEffects) do effects[i] = effect.class end effects = table.concat(effects, ",") local target_effects = "-" if IsKindOf(args.target, "Unit") then target_effects = {} for i, effect in ipairs(args.target.StatusEffects) do target_effects[i] = effect.class end target_effects = table.concat(target_effects, ",") end NetUpdateHash("Unit:FirearmAttack", action_id, cost_ap, self, effects, args.target, target_effects) end -- end net debug code local target = args.target self:CallReactions("OnFirearmAttackStart", self, target, CombatActions[action_id], args) if IsKindOf(target, "Unit") then target:CallReactions("OnFirearmAttackStart", self, target, CombatActions[action_id], args) end while not args.opportunity_attack and IsKindOf(target, "Unit") and not target:IsIdleOrRunningBehavior() do WaitMsg("Idle", 50) end if args.replace_action then action_id = args.replace_action end if IsPoint(target) or IsValidTarget(target) then local action = CombatActions[action_id] if action.StealthAttack then args.stealth_kill_roll = 1 + self:Random(100) end args.prediction = false local units_waiting = {} self:PushDestructor(function() for _, unit in ipairs(units_waiting) do unit.waiting_attack = false end end) if not g_Combat and IsKindOf(target, "Unit") then units_waiting[1] = target PropagateAwareness(units_waiting) for _, unit in ipairs(units_waiting) do if unit:IsInterruptable() then unit.waiting_attack = true unit:InterruptCommand("WaitAttack") end end repeat local waiting = false for _, unit in ipairs(units_waiting) do waiting = waiting or (unit.command == "WaitAttack" and not unit.waiting_attack) end if waiting then Sleep(10) end until not waiting end local results, attack_args = action:GetActionResults(self, args) self:ExecFirearmAttacks(action, cost_ap, attack_args, results) self:PopAndCallDestructor() else self:GainAP(cost_ap) CombatActionInterruped(self) end end function Unit:ExecFirearmAttacks(action, cost_ap, attack_args, results) NetUpdateHash("ExecFirearmAttacks", action, cost_ap, not not g_Combat) local lof_idx = table.find(attack_args.lof, "target_spot_group", attack_args.target_spot_group or "Torso") local lof_data = attack_args.lof[lof_idx or 1] local target = attack_args.target local target_unit = IsKindOf(target, "Unit") and IsValidTarget(target) and target local interrupt = attack_args.interrupt if interrupt then if ActionCameraPlaying then RemoveActionCamera(true) WaitMsg("ActionCameraRemoved", 5000) end self:PushDestructor(function() Msg("InterruptAttackEnd") end) Msg("InterruptAttackStart", self, target_unit, action) end NetUpdateHash("ExecFirearmAttacks_After_Interrupt_Cam_Wait") results.attack_from_stealth = not not self:HasStatusEffect("Hidden") for _, attack in ipairs(results.attacks or {results}) do if attack.fired then self:AttackReveal(action, attack_args, results) break end end local can_provoke_opportunity_attacks = not action or (action.id ~= "CancelShot" and action.id ~= "CancelShotCone") if can_provoke_opportunity_attacks then self:ProvokeOpportunityAttacks(action, "attack interrupt") end self:PrepareToAttack(attack_args, results) if can_provoke_opportunity_attacks then self:ProvokeOpportunityAttacks(action, "attack interrupt") end local was_interruptable = self.interruptable if not was_interruptable then self:EndInterruptableMovement() end NetUpdateHash("ExecFirearmAttacks_Start_Action_Cam") -- camera effects if attack_args.opportunity_attack_type ~= "Retaliation" then local cinematicKill = false local dontPlayForLocalPlayer = false if g_Combat and IsEnemyKill(self, results) then g_Combat:CheckPendingEnd(results.killed_units) local isKillCinematic isKillCinematic, dontPlayForLocalPlayer = IsEnemyKillCinematic(self, results, attack_args) if isKillCinematic then cameraTac.SetForceMaxZoom(false) SetAutoRemoveActionCamera(self, results.killed_units[1], nil, nil, nil, nil, nil, dontPlayForLocalPlayer) cinematicKill = true end elseif interrupt then -- the attack is from enemy pindown or overwatch --[[if self.team.side == "enemy" then SetAutoRemoveActionCamera(target_unit, self, 1000, true) -- todo: should this use the anim duration? else SetAutoRemoveActionCamera(self, target_unit, 1000, true) end--]] end if not cinematicKill and IsKindOf(target, "Unit") then local cinematicAttack, interpolation = IsCinematicAttack(self, results, attack_args, action) if cinematicAttack then local playerUnit = (IsKindOf(target, "Unit") and target:IsLocalPlayerTeam() and target) or (self:IsLocalPlayerTeam() and self) local enemyUnit = playerUnit and (playerUnit == target and self or target) if playerUnit and enemyUnit then SetAutoRemoveActionCamera(playerUnit, enemyUnit, false, false, false, interpolation and default_interpolation_time, nil, dontPlayForLocalPlayer) end end end end NetUpdateHash("ExecFirearmAttacks_After_Action_Cam") -- animspeed modifier & cmd destructor local asm = self:GetAnimSpeedModifier() local anim_speed_mod = attack_args.anim_speed_mod or 1000 self:SetAnimSpeedModifier(anim_speed_mod) self:PushDestructor(function(self) self:SetAnimSpeedModifier(asm) if IsValid(target) and target:HasMember("session_id") then self.last_attack_session_id = target.session_id else self.last_attack_session_id = false end local cooldown = action:ResolveValue("cooldown") if cooldown then self:SetEffectExpirationTurn(action.id, "cooldown", g_Combat.current_turn + cooldown) end if IsValid(target) then ObjModified(target) end table.remove(g_CurrentAttackActions) -- pop the pushed attack action end) local ap = (cost_ap and cost_ap > 0) and cost_ap or action:GetAPCost(self, attack_args) table.insert(g_CurrentAttackActions, { action = action, cost_ap = ap, attack_args = attack_args, results = results }) -- start anim, wait hit moment, apply ammo/condition results local chance_to_hit = results.chance_to_hit local missed = results.miss local critical = results.crit local chance_crit = results.crit_chance local aim_state = self:GetStateText() local fired = false if results.attacks then --- multi-weapon attacks (DualShot) local shots = results.attacks[1] and results.attacks[1].shots self:StartFireAnim(shots and shots[1], attack_args) for _, attack in ipairs(results.attacks) do attack.weapon:ApplyAmmoUse(self, attack.fired, attack.jammed, attack.condition) fired = fired or attack.fired end else self:StartFireAnim(results.shots and results.shots[1], attack_args) results.weapon:ApplyAmmoUse(self, results.fired, results.jammed, results.condition) fired = results.fired end if not fired then -- none of the weapons fired, abort if not was_interruptable then self:BeginInterruptableMovement() end Sleep(self:TimeToAnimEnd()) self:PopAndCallDestructor() if interrupt then self:PopAndCallDestructor() end NetUpdateHash("ExecFirearmAttacks_early_out") return end PushUnitAlert("noise", self, results.weapon.Noise, Presets.NoiseTypes.Default.Gunshot.display_name) local shot_threads = {} local attacks = results.attacks or {results} local attackArgs = results.attacks_args or {attack_args} if results.shots and #results.shots > 8 and g_Combat and not g_Combat:ShouldEndCombat(results.killed_units) then if (not results.killed_units or #results.killed_units == 1) then local vr = IsMerc(self) and "Autofire" or "AIAutofire" PlayVoiceResponse(self, vr) end end local lowChanceShot local base_weapon_damage = 0 for attackIdx, attack in ipairs(attacks) do local attackArg = attackArgs[attackIdx] local fx_action = attackArg.fx_action if action.id == "BulletHell" then BulletHellOverwriteShots(attack) end local shots_per_animation = Min(3, #attack.shots) if action.id == "BurstFire" or action.id == "MGBurstFire" then shots_per_animation = #attack.shots end for i, shot in ipairs(attack.shots) do -- shot visuals attack.weapon:FireBullet(self, shot, shot_threads, results, attackArg) if attackArg.single_fx then fx_action = "" end if i < #attack.shots then -- more shots to fire if i % shots_per_animation == 0 then local shotAnimDelay = attackArg.attack_anim_delay or self:TimeToAnimEnd() self:StartFireAnim(attack.shots[i+1], attackArg, nil, shotAnimDelay) -- fire next shot else Sleep(self:GetAnimDuration() / shots_per_animation) end elseif attackIdx < #attacks then Sleep(MulDivRound(self:GetAnimDuration() / shots_per_animation, 30, 100)) end if IsMerc(self) and attack.target_hit then if attack.chance_to_hit <= 20 then lowChanceShot = true end end end attack.weapon:FireSpread(attack, attackArg) -- deal the area damage, if any base_weapon_damage = base_weapon_damage + attack.weapon.Damage end -- additional damage (e.g. from DualShot perk) for _, packet in ipairs(results.extra_packets) do if IsValidTarget(packet.target) then if packet.damage then packet.target:TakeDirectDamage(packet.damage, false, "short", packet.message) end if packet.effects then packet.target:ApplyDamageAndEffects(false, false, packet) end end end -- wait end moment and restore animation local time_to_fire_end = self:TimeToAnimEnd() if not attack_args.dont_restore_aim then if self:CanAimIK(results.weapon) then local restore_aim_delay = Min(300, time_to_fire_end) Sleep(restore_aim_delay) self:SetIK("AimIK", lof_data.lof_pos2, nil, nil, 0) Sleep(time_to_fire_end - restore_aim_delay) self:SetState(aim_state, const.eKeepComponentTargets) else Sleep(time_to_fire_end) self:SetState(aim_state, const.eKeepComponentTargets) end end -- special-case: interrupt neutral units with neutral_retaliate flag attacked by player units, -- so they don't look ridiculous minding their own business for several more seconds until the attack resolves if self.team.player_team and not g_Combat then if IsValid(target_unit) and target_unit.team.neutral and target_unit.neutral_retaliate and not target_unit:IsIncapacitated() then target_unit.neutral_retal_attacked = true target_unit:SetBehavior() target_unit:SetCommand("Idle") end local hits = #results > 0 and results or results.area_hits for _, hit in ipairs(hits) do local unit = IsKindOf(hit.obj, "Unit") and not hit.obj:IsIncapacitated() and hit.obj if IsValid(unit) and unit.team.neutral and unit.neutral_retaliate then unit.neutral_retal_attacked = true unit:SetBehavior() unit:SetCommand("Idle") end end end self.interruptable = false self:PushDestructor(function() self.interruptable = was_interruptable end) if attack_args.external_wait_shots then table.iappend(attack_args.external_wait_shots, shot_threads) else Firearm:WaitFiredShots(shot_threads) end -- wait target dodge anim while target_unit and target_unit.command == "Dodge" do WaitMsg("Idle") end -- play voices base_weapon_damage = MulDivRound(base_weapon_damage, 120, 100) if attacks and next(attacks)then --count shots fired per team for Voice Response self.team.tactical_situations_vr.shotsFired = self.team.tactical_situations_vr.shotsFired and self.team.tactical_situations_vr.shotsFired + 1 or 1 self.team.tactical_situations_vr.shotsFiredBy = self.team.tactical_situations_vr.shotsFiredBy or {} self.team.tactical_situations_vr.shotsFiredBy[self.session_id] = true PlayVoiceResponseTacticalSituation(table.find(g_Teams, self.team), "now") if missed then --count missed shots per team for Voice Response self.team.tactical_situations_vr.missedShots = self.team.tactical_situations_vr.missedShots and self.team.tactical_situations_vr.missedShots + 1 or 1 PlayVoiceResponseTacticalSituation(table.find(g_Teams, self.team), "now") if chance_to_hit >= 70 then if not target_unit or not target_unit:IsCivilian() then PlayVoiceResponseMissHighChance(self) end elseif target_unit and chance_to_hit>=50 and base_weapon_damage>=target_unit:GetTotalHitPoints() then if IsMerc(target_unit) then target_unit:SetEffectValue("missed_by_kill_shot", true) end end elseif not missed then if results.stealth_kill and IsMerc(self) and results.killed_units and #results.killed_units > 0 then elseif lowChanceShot and target_unit and not self:IsOnAllySide(target_unit) and not target_unit:IsCivilian() then PlayVoiceResponse(self, "LowChanceShot") end end end for i, attack in ipairs(attacks) do local holdXpLog = i ~= #attacks self:OnAttack(action, target_unit, attack, attack_args, holdXpLog) end LogAttack(action, attack_args, results) AttackReaction(action, attack_args, results, "can retaliate") if not action or (action.id ~= "CancelShot" and action.id ~= "CancelShotCone") then self:ProvokeOpportunityAttacks(action, "attack reaction") end if not was_interruptable then self:BeginInterruptableMovement() end self:PopAndCallDestructor() self:PopAndCallDestructor() if interrupt then self:PopAndCallDestructor() end end function Unit:MGSetup(action_id, cost_ap, args) self.interruptable = false if self.stance ~= "Prone" then self:DoChangeStance("Prone") end self:AddStatusEffect("StationedMachineGun") self:UpdateHidden() self:FlushCombatCache() self:RecalcUIActions(true) ObjModified(self) return self:MGTarget(action_id, cost_ap, args) end function Unit:MGTarget(action_id, cost_ap, args) args.permanent = true args.num_attacks = self:GetNumMGInterruptAttacks() self.interruptable = false return self:OverwatchAction(action_id, cost_ap, args) -- this would change the command anyway, we can't have code below it end function Unit:MGPack() self:InterruptPreparedAttack() self:RemoveStatusEffect("StationedMachineGun") self:UpdateHidden() self:FlushCombatCache() self:RecalcUIActions(true) if HasPerk(self, "KillingWind") then self:RemoveStatusEffect("FreeMove") self:AddStatusEffect("FreeMove") end ObjModified(self) end function Unit:OpportunityAttack(action_id, args, status)--target, target_spot_group, action, status) -- does basically nothing on its own but changes the name of the current command (relevant for overwatch/to-hit modifiers) g_Interrupt = true args.interrupt = true PlayFX("OpportunityAttack", "start", self) self:FirearmAttack(action_id, 0, args, status) end function Unit:PinDownAttack(target, action_id, target_spot_group, aim, status) -- does basically nothing on its own but changes the name of the current command (relevant for overwatch/to-hit modifiers) local args = { target = target, target_spot_group = target_spot_group, aim = aim, interrupt = true, opportunity_attack = true, opportunity_attack_type = "PinDown" } self:FirearmAttack(action_id, 0, args, status) end function Unit:RetaliationAttack(target, target_spot_group, action) -- does basically nothing on its own but changes the name of the current command self:CallReactions("OnUnitRetaliation", self, target, action, target_spot_group) if IsKindOf(target, "Unit") then target:CallReactions("OnUnitRetaliation", self, target, action, target_spot_group) end self:AddStatusEffect("RetaliationCounter") PlayFX("OpportunityAttack", "start", self) local args = { target = target, target_spot_group = target_spot_group, interrupt = true, opportunity_attack = true, opportunity_attack_type = "Retaliation" } if string.match(action.id, "ThrowGrenade") then -- can't Run the action as it will change the command unfortunately return self:ThrowGrenade(action.id, 0, args) end return self:FirearmAttack(action.id, 0, args) end function Unit:OpportunityMeleeAttack(target, action) -- does basically nothing on its own but changes the name of the current command if self.team and self.team.control == "AI" then PlayVoiceResponse(self, "AIMeleeOpportunist") end PlayFX("OpportunityAttack", "start", self) self:MeleeAttack(action.id, 0, { target = target, opportunity_attack = true, opportunity_attack_type = "Retaliation"}) end local tf_smooth_sleep = 100 local tf_smooth_thread = false function SetTimeFactorSmooth(tf, time) DeleteThread(tf_smooth_thread) tf_smooth_thread = CreateRealTimeThread(function() local curr_tf = GetTimeFactor() if curr_tf == tf then return end local delta = MulDivRound(tf - curr_tf, tf_smooth_sleep, time) local cmp = curr_tf < tf while cmp == (curr_tf + delta < tf) do curr_tf = curr_tf + delta SetTimeFactor(curr_tf) Sleep(tf_smooth_sleep) end SetTimeFactor(tf) end) end local smooth_tf_change_duration = 1500 function Unit:RunAndGun(action_id, cost_ap, args) local action = CombatActions[action_id] local target = args.goto_pos local weapon = action:GetAttackWeapons(self) if not weapon then self:GainAP(cost_ap) CombatActionInterruped(self) return end local aim_params = action:GetAimParams(self, weapon) local num_shots = aim_params.num_shots if self.stance ~= "Standing" then self:ChangeStance(action_id, 0, "Standing") end -- do the attack/crit rolls args.attack_rolls = {} args.crit_rolls = {} args.stealth_kill_rolls = {} for i = 1, num_shots do args.attack_rolls[i] = 1 + self:Random(100) args.crit_rolls[i] = 1 + self:Random(100) if action.StealthAttack then args.stealth_kill_rolls[i] = 1 + self:Random(100) end end args.prediction = false NetUpdateHash("RunAndGun_0", self, args) local results = action:GetActionResults(self, args) local action_camera = false --[[ disable action camera for now ]] if #(results.attacks or empty_table) == 0 then self:GainAP(cost_ap) CombatActionInterruped(self) return end local pathObj, path self:PushDestructor(function(self) if pathObj then DoneObject(pathObj) end end) pathObj = CombatPath:new() if action_camera then local tf = GetTimeFactor() self:PushDestructor(function() SetTimeFactorSmooth(tf, smooth_tf_change_duration) end) end local base_idle = self:GetIdleBaseAnim() local shot_threads for i, attack in ipairs(results.attacks) do if not self:CanUseWeapon(weapon) then -- might jam, run out of ammo, etc goto continue end NetUpdateHash("RunAndGun_1", self, attack.mobile_attack_pos, attack.mobile_attack_target) if attack.mobile_attack_pos and (not IsValidTarget(attack.mobile_attack_target) or attack.mobile_attack_target:IsIncapacitated()) then local enemies = table.ifilter(action:GetTargets({self}), function(idx, u) return IsValidTarget(u) and not u:IsIncapacitated() end) NetUpdateHash("RunAndGun_Branch_1", self, attack.mobile_attack_pos, #enemies) attack.mobile_attack_target = FindTargetFromPos(action_id, self, action, enemies, point(point_unpack(attack.mobile_attack_pos)), weapon) end if attack.mobile_attack_pos and IsValidTarget(attack.mobile_attack_target) then if action_camera and i == 1 then SetTimeFactorSmooth(tf/2, smooth_tf_change_duration) end -- We need to build the path outside of the function so that it -- doesn't refund us the ap cost difference. local targetPos = point(point_unpack(attack.mobile_attack_pos)) local occupiedPos = self:GetOccupiedPos() if self:GetDist(occupiedPos) > const.SlabSizeX / 2 and self:GetDist(targetPos) < const.SlabSizeX / 2 then -- already at target position because of expose/aim self:SetTargetDummy(nil, nil, base_idle, 0) else pathObj:RebuildPaths(self, aim_params.move_ap) path = pathObj:GetCombatPathFromPos(targetPos) self:CombatGoto(action_id, 0, nil, path, true, i == #results.attacks and args.toDoStance) end -- recheck target, as they might have died while we were moving if not IsValidTarget(attack.mobile_attack_target) or attack.mobile_attack_target:IsIncapacitated() then local enemies = table.ifilter(action:GetTargets({self}), function(idx, u) return IsValidTarget(u) and not u:IsIncapacitated() end) NetUpdateHash("RunAndGun_Branch_1_1", self, attack.mobile_attack_pos, #enemies) attack.mobile_attack_target = FindTargetFromPos(action_id, self, action, enemies, point(point_unpack(attack.mobile_attack_pos)), weapon) if not IsValidTarget(attack.mobile_attack_target) then goto continue end end if action_camera then if i == #results.attacks then SetTimeFactorSmooth(tf, smooth_tf_change_duration) end SetActionCamera(self, attack.mobile_attack_target) end self:SetRandomAnim(base_idle) local atk_action = CombatActions[attack.mobile_attack_id] or action -- rerun simulation to account for changes happened in the meantime (broken covers, etc) local atk_args = { prediction = false, target = attack.mobile_attack_target, stance = "Standing", can_use_covers = i == #results.attacks, used_action_id = action_id, -- so that cth is calculated for the master/parent action instead of the actual attack action } NetUpdateHash("RunAndGun_2", self, atk_args.target, args.goto_pos) local atk_results, attack_args = atk_action:GetActionResults(self, atk_args) attack_args.origin_action_id = action_id attack_args.keep_ui_mode = true attack_args.unit_moved = true attack_args.dont_restore_aim = true if atk_action.id == "KnifeThrow" then self:ExecKnifeThrow(atk_action, cost_ap, attack_args, atk_results) else shot_threads = shot_threads or {} attack_args.external_wait_shots = shot_threads self:ExecFirearmAttacks(atk_action, cost_ap, attack_args, atk_results) end end ::continue:: end local cooldown = action:ResolveValue("cooldown") if cooldown then self:SetEffectExpirationTurn(action.id, "cooldown", g_Combat.current_turn + cooldown) end if action_camera then RemoveActionCamera() self:PopAndCallDestructor() -- camera end -- if not at target loc, goto there (there mustn't be a target when that happens) local occupiedPos = self:GetOccupiedPos() if self.return_pos and self.return_pos:Dist(target) < const.SlabSizeX / 2 then self:ReturnToCover() elseif self:GetDist(occupiedPos) > const.SlabSizeX / 2 and self:GetDist(target) < const.SlabSizeX / 2 then self:SetTargetDummyFromPos() else pathObj:RebuildPaths(self, aim_params.move_ap) path = pathObj:GetCombatPathFromPos(target) self:CombatGoto(action_id, 0, nil, path, true) end if shot_threads then Firearm:WaitFiredShots(shot_threads) end self:PopAndCallDestructor() -- pathObj end function Unit:HundredKnives(action_id, cost_ap, args) local action = CombatActions[action_id] local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) self:RunAndGun(action_id, cost_ap, args) end function Unit:RecklessAssault(action_id, cost_ap, args) self:RunAndGun(action_id, cost_ap, args) self:SetTired(self.Tiredness + 1) end function Unit:HeavyWeaponAttack(action_id, cost_ap, args) local target = args.target if not IsPoint(target) and not IsValidTarget(target) then self:GainAP(cost_ap) CombatActionInterruped(self) return end local action = CombatActions[action_id] self:ProvokeOpportunityAttacks(action, "attack interrupt") local weapon = action:GetAttackWeapons(self) args.prediction = false local results, attack_args = action:GetActionResults(self, args) results.attack_from_stealth = not not self:HasStatusEffect("Hidden") if results.fired then self:AttackReveal(action, attack_args, results) end self:PrepareToAttack(attack_args, results) self:ProvokeOpportunityAttacks(action, "attack interrupt") self:PushDestructor(function() local ap = (cost_ap and cost_ap > 0) and cost_ap or action:GetAPCost(self, attack_args) table.insert(g_CurrentAttackActions, { action = action, cost_ap = ap, attack_args = attack_args, results = results }) local aim_pos = results and results.trajectory and results.trajectory[2] and results.trajectory[2].pos self:StartFireAnim(nil, attack_args, aim_pos) local anim_end_time = GameTime() + self:TimeToAnimEnd() local prev = weapon.Condition weapon.Condition = results.condition if prev ~= results.condition then Msg("ItemChangeCondition", weapon, prev, results.condition, self) end weapon:ApplyAmmoUse(self, results.fired, results.jammed, results.condition) if not results.jammed and results.trajectory then local ordnance = results.ordnance local trajectory = results.trajectory local action_dir = SetLen(trajectory[2].pos - trajectory[1].pos, 4096) local visual_obj = weapon:GetVisualObj(self) -- temporarily change the fx actor class of the visual obj if it doesn't match (subweapons) local actor_class = visual_obj.fx_actor_class visual_obj.fx_actor_class = weapon:GetFxClass() PlayFX("WeaponFire", "start", visual_obj, nil, trajectory[1].pos, action_dir) visual_obj.fx_actor_class = actor_class -- animate trajectory local attaches = visual_obj:GetAttaches("OrdnanceVisual") local projectile if attaches then projectile = attaches[1] projectile:Detach() else projectile = PlaceObject("OrdnanceVisual", {fx_actor_class = ordnance.class}) end if IsKindOf(weapon, "RocketLauncher") then weapon:UpdateRocket() PlayFX("RocketFire", "start", projectile) end local backfire_results = table.copy(results) for i = #backfire_results, 1, -1 do if not backfire_results[i].backfire then table.remove(backfire_results, i) end end for i = #results, 1, -1 do if results[i].backfire then table.remove(results, i) end end ApplyExplosionDamage(self, nil, backfire_results, nil, "disable burn FXes") local rpm_range = const.Combat.GrenadeMaxRPM - const.Combat.GrenadeMinRPM local rpm = const.Combat.GrenadeMinRPM + self:Random(rpm_range) local rotation_axis = RotateAxis(axis_x, axis_z, CalcOrientation(trajectory[2].pos, trajectory[1].pos)) if weapon.trajectory_type == "line" then -- disable rotation and make sure the rocket is oriented towards the target point rpm = 0 projectile:SetAxis(axis_z) projectile:SetAngle(CalcOrientation(trajectory[1].pos, trajectory[2].pos)) end local throw_thread = CreateGameTimeThread(AnimateThrowTrajectory, projectile, trajectory, rotation_axis, -rpm, "GrenadeDrop") Sleep(self:TimeToAnimEnd()) if IsValidThread(throw_thread) then local anim = self:GetAimAnim() self:SetState(anim, const.eKeepComponentTargets) while IsValidThread(throw_thread) do WaitMsg("GrenadeDoneThrow", 20) end end -- recheck results to handle possible changes in unit positions (exploration mode) args.explosion_pos = results.explosion_pos or projectile:GetPos() results, attack_args = action:GetActionResults(self, args) -- do explosion/apply results ApplyExplosionDamage(self, projectile, results) LogAttack(action, attack_args, results) PushUnitAlert("noise", projectile, ordnance.Noise, Presets.NoiseTypes.Default.Explosion.display_name) if IsValid(projectile) then DoneObject(projectile) end AttackReaction(action, attack_args, results) self:OnAttack(action_id, nil, results, attack_args) end if anim_end_time - GameTime() > 0 then Sleep(anim_end_time - GameTime()) end local dlg = GetInGameInterfaceModeDlg() if dlg and dlg:HasMember("dont_return_camera_on_close") then dlg.dont_return_camera_on_close = true end local cooldown = action:ResolveValue("cooldown") if cooldown then self:SetEffectExpirationTurn(action.id, "cooldown", g_Combat.current_turn + cooldown) end self.last_attack_session_id = false self:ProvokeOpportunityAttacks(action, "attack reaction") table.remove(g_CurrentAttackActions) end) self:PopAndCallDestructor() end function Unit:FireFlare(action_id, cost_ap, args) local target = args.target if not IsPoint(target) and not IsValidTarget(target) then self:GainAP(cost_ap) CombatActionInterruped(self) return end local action = CombatActions[action_id] self:ProvokeOpportunityAttacks(action, "attack interrupt") local weapon = action:GetAttackWeapons(self) args.prediction = false local results, attack_args = action:GetActionResults(self, args) results.attack_from_stealth = not not self:HasStatusEffect("Hidden") if results.fired then self:AttackReveal(action, attack_args, results) end self:PrepareToAttack(attack_args, results) self:ProvokeOpportunityAttacks(action, "attack interrupt") local fire_anim = self:GetAttackAnim(action_id) local aim_anim = self:GetAimAnim(action_id) self:SetState(fire_anim) local duration = self:TimeToAnimEnd() local hit_moment = self:TimeToMoment(1, "hit") or duration/2 Sleep(hit_moment) local weapon_visual = weapon:GetVisualObj(self) PlayFX("FlareHandgun_Fire", "start", weapon_visual) local thread = not results.jammed and CreateGameTimeThread(function() local visual = PlaceObject("GrenadeVisual", {fx_actor_class = "FlareBullet"}) local offset = point(0, 0, 200*guic) offset = offset + Rotate(point(30*guic, 0, 0), self:GetAngle() + 90*60)+ Rotate(point(20*guic, 0, 0), self:GetAngle()) local pos = self:GetPos() if not pos:IsValidZ() then pos = pos:SetTerrainZ() end pos = pos + offset visual:SetPos(pos) Sleep(100) visual:SetPos(pos + point(0, 0, 20*guim), 1500) Sleep(2000) local explosion_pos = results.explosion_pos +point(0, 0, 10*guic) local sky_pos = explosion_pos + point(0, 0, 20*guim) local col, pts = CollideSegmentsNearest(sky_pos, explosion_pos) if col then explosion_pos = pts[1] end visual:SetPos(sky_pos) local fall_time = MulDivRound(sky_pos:Dist(explosion_pos), 1000, const.Combat.MortarFallVelocity/5) visual:SetPos(explosion_pos, fall_time) Sleep(fall_time) local flare = PlaceObject("FlareOnGround", { visual_obj = visual, remaining_time = 4*5000, Despawn = true, campaign_time = Game.CampaignTime, }) flare:SetPos(explosion_pos) flare:UpdateVisualObj() PushUnitAlert("thrown", flare, self) Wakeup(self.command_thread) end) Sleep(duration - hit_moment) self:SetRandomAnim(self:GetIdleBaseAnim()) results.weapon:ApplyAmmoUse(self, results.fired, results.jammed, results.condition) while IsValidThread(thread) do WaitWakeup(50) end self.last_attack_session_id = false self:ProvokeOpportunityAttacks(action, "attack reaction") end function Unit:ThrowGrenade(action_id, cost_ap, args) local stealth_attack = not not self:HasStatusEffect("Hidden") local target_pos = args.target if self.stance ~= "Standing" then self:ChangeStance(nil, nil, "Standing") end local action = CombatActions[action_id] self:ProvokeOpportunityAttacks(action, "attack interrupt") local grenade = action:GetAttackWeapons(self) args.prediction = false -- mishap needs to happen now local results, attack_args = action:GetActionResults(self, args) -- early check for PrepareToAttack self:PrepareToAttack(attack_args, results) self:UpdateAttachedWeapons() self:ProvokeOpportunityAttacks(action, "attack interrupt") self:EndInterruptableMovement() -- camera effects if not attack_args.opportunity_attack_type == "Retaliation" then if g_Combat and IsEnemyKill(self, results) then g_Combat:CheckPendingEnd(results.killed_units) local isKillCinematic, dontPlayForLocalPlayer = IsEnemyKillCinematic(self, results, attack_args) if isKillCinematic then cameraTac.SetForceMaxZoom(false) SetAutoRemoveActionCamera(self, results.killed_units[1], nil, nil, nil, nil, nil, dontPlayForLocalPlayer) end end end self:RemoveStatusEffect("FirstThrow") -- multi-throw support local attacks = results.attacks or {results} local ap = (cost_ap and cost_ap > 0) and cost_ap or action:GetAPCost(self, attack_args) table.insert(g_CurrentAttackActions, { action = action, cost_ap = ap, attack_args = attack_args, results = results }) self:PushDestructor(function(self) self:ForEachAttach("GrenadeVisual", DoneObject) table.remove(g_CurrentAttackActions) self.last_attack_session_id = false local dlg = GetInGameInterfaceModeDlg() if dlg and dlg:HasMember("dont_return_camera_on_close") then dlg.dont_return_camera_on_close = true end end) Msg("ThrowGrenade", self, grenade, #attacks) -- throw anim self:SetState("gr_Standing_Attack", const.eKeepComponentTargets) -- pre-create visual objs and play activate fx local visual_objs = {} for i = 1, #attacks do local visual_obj = grenade:GetVisualObj(self, i > 1) visual_objs[i] = visual_obj PlayFX("GrenadeActivate", "start", visual_obj) end local time_to_hit = self:TimeToMoment(1, "hit") or 20 self:Face(target_pos, time_to_hit/2) Sleep(time_to_hit) if results.miss or not results.killed_units or not (#results.killed_units > 1) then local specialNadeVr = table.find(SpecialGrenades, grenade.class) and (IsMerc(self) and "SpecialThrowGrenade" or "AIThrowGrenadeSpecial") local standardNadeVr = IsMerc(self) and "ThrowGrenade" or "AIThrowGrenade" PlayVoiceResponse(self, specialNadeVr or standardNadeVr) end local thread = CreateGameTimeThread(function() -- create visuals and start anim thread for each throw local threads = {} for i, attack in ipairs(attacks) do visual_objs[i]:Detach() visual_objs[i]:SetHierarchyEnumFlags(const.efVisible) local trajectory = attack.trajectory if #trajectory > 0 then local rpm_range = const.Combat.GrenadeMaxRPM - const.Combat.GrenadeMinRPM local rpm = const.Combat.GrenadeMinRPM + self:Random(rpm_range) local rotation_axis = RotateAxis(axis_x, axis_z, CalcOrientation(trajectory[2].pos, trajectory[1].pos)) threads[i] = CreateGameTimeThread(AnimateThrowTrajectory, visual_objs[i], trajectory, rotation_axis, rpm, "GrenadeDrop") else -- try to find a fall down pos threads[i] = CreateGameTimeThread(ItemFallDown, visual_objs[i]) end end grenade:OnThrow(self, visual_objs) -- wait until all threads are done while #threads > 0 do Sleep(25) for i = #threads, 1, -1 do if not IsValidThread(threads[i]) then table.remove(threads, i) end end end -- real check when the grenade(s) landed must use the current position(s) if #attacks > 1 then args.explosion_pos = {} for i, res in ipairs(attacks) do args.explosion_pos[i] = res.explosion_pos or visual_objs[i]:GetVisualPos() end else args.explosion_pos = results.explosion_pos or visual_objs[1]:GetVisualPos() end results, attack_args = action:GetActionResults(self, args) local attacks = results.attacks or {results} results.attack_from_stealth = stealth_attack -- needs to be after GetActionResults local destroy_grenade if not self.infinite_ammo then grenade.Amount = grenade.Amount - #attacks destroy_grenade = grenade.Amount <= 0 if destroy_grenade then local slot = self:GetItemSlot(grenade) self:RemoveItem(slot, grenade) end ObjModified(self) end self:AttackReveal(action, attack_args, results) self:OnAttack(action_id, nil, results, attack_args) LogAttack(action, attack_args, results) for i, attack in ipairs(attacks) do grenade:OnLand(self, attack, visual_objs[i]) end if destroy_grenade then DoneObject(grenade) end AttackReaction(action, attack_args, results) Msg(CurrentThread()) end) Sleep(self:TimeToAnimEnd()) self:SetRandomAnim(self:GetIdleBaseAnim()) if IsValidThread(thread) then WaitMsg(thread) end self:ProvokeOpportunityAttacks(action, "attack reaction") self:PopAndCallDestructor() end function Unit:RemoteDetonate(action_id, cost_ap, args) local target_pos = args.target local action = CombatActions[action_id] local detonator = action:GetAttackWeapons(self) local traps = detonator:GetAttackResults(false, { target_pos = target_pos }) self.performed_action_this_turn = true -- the action doesn't consume ap, so set it manually for i, t in ipairs(traps) do t.obj:TriggerTrap(nil, self) end end function Unit:FaceAttackerCommand(attacker, angle) angle = angle or CalcOrientation(self, attacker) self:AnimatedRotation(angle) self:SetRandomAnim(self:GetIdleBaseAnim(), const.eKeepComponentTargets) self:SetCommand("WaitAttacker") end function Unit:WaitAttacker(timeout) Sleep(timeout or 2000) end function Unit:MeleeAttack(action_id, cost_ap, args) local new_stance = self.stance ~= "Standing" and self.species == "Human" and "Standing" local stealth_attack = not not self:GetStatusEffect("Hidden") if new_stance then local stance = self.stance self:PushDestructor(function(self) self:GainAP(cost_ap) self:ChangeStance(nil, nil, stance) end) if new_stance then self:ChangeStance(nil, nil, "Standing") end self:PopDestructor() end local action = CombatActions[action_id] local weapon = action:GetAttackWeapons(self) local target = args.target if IsKindOf(target, "Unit") and not IsMeleeRangeTarget(self, nil, self.stance, target, nil, target.stance) then self:GainAP(cost_ap) ShowBadgeOfAttacker(self, false) return end -- do the attack/crit rolls args.attack_roll = 1 + self:Random(100) args.crit_roll = 1 + self:Random(100) if action.StealthAttack then args.stealth_attack = stealth_attack args.stealth_kill_roll = 1 + self:Random(100) end args.prediction = false local face_thread self:PushDestructor(function(self) table.remove(g_CurrentAttackActions) if IsValid(target) and (target.command == "WaitAttacker" or target.command == "FaceAttackerCommand") then target:SetCommand("Idle") end DeleteThread(face_thread) end) local results, attack_args = action:GetActionResults(self, args) results.attack_from_stealth = stealth_attack local ap = (cost_ap and cost_ap > 0) and cost_ap or action:GetAPCost(self, attack_args) table.insert(g_CurrentAttackActions, { action = action, cost_ap = ap, attack_args = attack_args, results = results }) self:AttackReveal(action, attack_args, results) self.marked_target_attack_args = nil self:ProvokeOpportunityAttacks(action, "attack interrupt", nil, "melee") -- camera effects if not attack_args.opportunity_attack_type == "Retaliation" then if g_Combat and IsEnemyKill(self, results) then g_Combat:CheckPendingEnd(results.killed_units) --cameraTac.SetForceMaxZoom(false) --SetAutoRemoveActionCamera(self, results.killed_units[1], 1000) end if IsKindOf(target, "Unit") and IsCinematicAttack(self, results, attack_args, action) then SetAutoRemoveActionCamera(self, target, false, false, false, default_interpolation_time) end end self:EndInterruptableMovement() local anim, face_angle, fx_actor if self.species == "Human" then local base_anim if self.infected then fx_actor = "fist" base_anim = "inf_Standing_Attack" else local spot_relative_z if IsKindOf(target, "Unit") then local BodyParts = UnitColliders[target.species].BodyParts local idx = table.find(BodyParts, "id", attack_args.target_spot_group) if not idx then if attack_args.target_spot_group == "Neck" then idx = table.find(BodyParts, "id", "Head") end end local target_spot = idx and BodyParts[idx].TargetSpots[1] if target_spot and target:HasSpot(target_spot) then local spot_pos = target:GetSpotLocPos(target:GetSpotBeginIndex(target_spot)) local aposx, aposy, aposz = self:GetPosXYZ() spot_relative_z = spot_pos:z() - (aposz or terrain.GetHeight(aposx, aposy)) end end local attach_forward = not spot_relative_z or spot_relative_z >= 700 if weapon.IsUnarmed then fx_actor = "fist" base_anim = attach_forward and "nw_Standing_Attack_Forward" or "nw_Standing_Attack_Down" else fx_actor = "knife" if IsKindOf(weapon, "MacheteWeapon") then fx_actor = "machete" base_anim = attach_forward and "mk_Standing_Machete_Attack_Forward" or "mk_Standing_Machete_Attack_Down" else base_anim = attach_forward and "mk_Standing_Attack_Forward" or "mk_Standing_Attack_Down" end end end anim = self:GetRandomAnim(base_anim) face_angle = CalcOrientation(self, target) else anim = "attack" fx_actor = "jaws" local can_attack can_attack, face_angle = IsMeleeRangeTarget(self, nil, self.stance, target, nil, target.stance) if self.species == "Crocodile" then local head_pos = SnapToVoxel(RotateRadius(const.SlabSizeX, face_angle, self)) local adiff = AngleDiff(CalcOrientation(head_pos, target), face_angle) local variant = Clamp(4 + adiff/(45*60), 1, 7) if variant > 1 then anim = anim .. variant end end end fx_actor = self:CallReactions_Modify("OnUnitChooseMeleeAttackFxActor", fx_actor, action, weapon, target) face_thread = CreateGameTimeThread(function(self, anim, face_angle) self:PlayTransitionAnims(anim) if face_angle then self:AnimatedRotation(face_angle) end self:SetRandomAnim(self:GetIdleBaseAnim("anim")) Msg(self) end, self, anim, face_angle) -- target face attacker if g_Combat and action_id ~= "Charge" and not args.opportunity_attack and IsKindOf(target, "Unit") and not target:IsDead() and target.stance ~= "Prone" and not target:IsDowned() and not target:HasStatusEffect("ManningEmplacement") then local target_face_angle if target.body_type == "Large animal" then if abs(target:AngleToObject(self)) > 90*60 then target_face_angle = target:GetAngle() + 180*60 end else local target_angle = CalcOrientation(target, self) if abs(AngleDiff(target_angle, target:GetOrientationAngle())) > 45*60 then target_face_angle = target_angle end end if target_face_angle then if target:IsCommandThread() then local speed_mod = target:GetAnimSpeedModifier() target:SetAnimSpeedModifier(1000) -- restore the move speed modified in Unit:InterruptBegin() target:AnimatedRotation(target_face_angle) target:SetAnimSpeedModifier(speed_mod) else if target:IsInterruptable() then target:SetCommand("FaceAttackerCommand", self, target_face_angle) for i = 1, 200 do if target.command ~= "FaceAttackerCommand" then break end Sleep(50) end end end end end if IsValidThread(face_thread) then WaitMsg(self, 2000) end DeleteThread(face_thread) face_thread = nil if g_AIExecutionController and not ActionCameraPlaying then local targetPos = target:GetVisualPos() local cameraIsNear = DoPointsFitScreen({targetPos}, nil, const.Camera.BufferSizeNoCameraMov) if not cameraIsNear then AdjustCombatCamera("set", nil, targetPos, GetStepFloor(targetPos), nil, "NoFitCheck") end end --handle badges as melee doesn't use prepare to attack logic if not g_AITurnContours[self.handle] and g_Combat and g_AIExecutionController then local enemy = self.team.side == "enemy1" or self.team.side == "enemy2" or self.team.side == "neutralEnemy" g_AITurnContours[self.handle] = SpawnUnitContour(self, enemy and "CombatEnemy" or "CombatAlly") ShowBadgeOfAttacker(self, true) end ShowBadgesOfTargets({target}, "show") self:SetAnim(1, anim) local fx_target if IsKindOf(target, "Unit") then fx_target = target elseif IsValid(target) then fx_target = GetObjMaterial(target:GetPos(), target) or target elseif IsPoint(target) then fx_target = GetObjMaterial(target) or "air" end PlayFX("MeleeAttack", "start", fx_actor, fx_target, self:GetVisualPos()) local tth = self:TimeToMoment(1, "hit") or (self:TimeToAnimEnd() / 2) repeat Sleep(tth) tth = self:TimeToMoment(1, "hit", 2) if tth and not results.miss and IsKindOf(target, "Unit") then target:Pain() end until not tth local attack_roll= results.attack_roll local roll = type(attack_roll) == "number" and attack_roll or type(attack_roll) == "table" and Untranslated(table.concat(attack_roll, ", ")) or nil if results.miss then CreateFloatingText(target, T(699485992722, "Miss"), "FloatingTextMiss") PlayFX("MeleeAttack", "miss", fx_actor, false, self:GetVisualPos()) else local resolve_steroid_punch = action_id == "SteroidPunch" and IsValidTarget(target) and IsKindOf(target, "Unit") for _, hit in ipairs(results) do local obj = hit.obj if IsValid(obj) and not obj:IsDead() and hit.damage > 0 then if IsKindOf(obj, "Unit") then obj:ApplyDamageAndEffects(self, hit.damage, hit, hit.armor_decay) else obj:TakeDamage(hit.damage, self, hit) end end end PlayFX("MeleeAttack", "hit", fx_actor, fx_target, IsValid(target) and target:GetVisualPos() or self:GetVisualPos()) if resolve_steroid_punch then self:ResolveSteroidPunch(args, results) end end self:OnAttack(action_id, target, results, attack_args) Sleep(self:TimeToAnimEnd()) LogAttack(action, attack_args, results) self:ProvokeOpportunityAttacks(action, "attack reaction", nil, "melee") AttackReaction(action, attack_args, results, "can retaliate") ShowBadgesOfTargets({target}, "hide") if IsValid(target) then ObjModified(target) end self.last_attack_session_id = false self:PopAndCallDestructor() end function Unit:ExplodingPalm(action_id, cost_ap, args) return self:MeleeAttack(action_id, cost_ap, args) end function Unit:GetNumBrutalizeAttacks(goto_pos) local ap = self:GetUIActionPoints() if goto_pos then local cp = GetCombatPath(self) local cost = cp and cp:GetAP(goto_pos) or 0 ap = Min(self:GetUIActionPoints(), self.ActionPoints - cost) end local action = self:GetDefaultAttackAction() local base_cost = action:GetAPCost(self) local num = 3 if base_cost then num = ap / MulDivRound(base_cost, 66, 100) end return Max(3, num) end function Unit:Brutalize(action_id, cost_ap, args) local target = args.target if not IsKindOf(target, "Unit") then return end local action = CombatActions[action_id] local weapon = action:GetAttackWeapons(self) if not IsKindOf(weapon, "MeleeWeapon") then return end local bodyParts = target:GetBodyParts(weapon) local num_attacks = args.num_attacks or 3 for i = 1, num_attacks do local bodyPart = table.interaction_rand(bodyParts, "Combat") args.target_spot_group = bodyPart.id args.target_spot_group = bodyPart.id self:MeleeAttack(action_id, 0, args) if not IsValid(self) or self:IsIncapacitated() or not IsValidTarget(target) then break end end local target = args.target if IsKindOf(target, "Unit") and IsValidTarget(target) then target:AddStatusEffect("Exposed") end self.ActionPoints = 0 if self:IsLocalPlayerControlled() then GetInGameInterfaceModeDlg():NextUnit(self.team, "force") end end function Unit:MarkTarget(action_id, cost_ap, attack_args) --for _, unit in ipairs(g_Units) do --unit.marked_target_attack_args = nil --end if not g_Combat then self.marked_target_attack_args = attack_args ShowSneakModeTutorialPopup(self) ShowSneakApproachTutorialPopup(self) local target = attack_args.target CreateBadgeFromPreset("MarkedBadge", target, target) end end function Unit:CancelMark() self.marked_target_attack_args = nil end function Unit:IsMarkedForStealthAttack(attacker) for _, unit in ipairs(g_Units) do if unit ~= self and unit.marked_target_attack_args and unit.marked_target_attack_args.target == self then if (not attacker) or (attacker == unit) then return true end end end end function Unit:ThrowKnife(action_id, cost_ap, args) local target = args.target if not IsPoint(target) and not IsValidTarget(target) then self:GainAP(cost_ap) CombatActionInterruped(self) return end local action = CombatActions[action_id] if action.StealthAttack then args.stealth_kill_roll = 1 + self:Random(100) end args.prediction = false local results, attack_args = action:GetActionResults(self, args) self:ExecKnifeThrow(action, cost_ap, attack_args, results) end function Unit:ExecKnifeThrow(action, cost_ap, attack_args, results) local target = attack_args.target local target_unit = IsKindOf(target, "Unit") and IsValidTarget(target) and target local target_pos = IsPoint(target) and target or target:GetPos() results.attack_from_stealth = not not self:HasStatusEffect("Hidden") self:AttackReveal(action, attack_args, results) self:ProvokeOpportunityAttacks(action, "attack interrupt") if self.stance == "Prone" then self:DoChangeStance("Standing") end self:PrepareToAttack(attack_args, results) self:ProvokeOpportunityAttacks(action, "attack interrupt") self:EndInterruptableMovement() -- animation local weapon = action:GetAttackWeapons(self) self:PushDestructor(function(self) self:AttachActionWeapon(action) local visual_obj = self.custom_weapon_attach or weapon:GetVisualObj(self) -- camera effects if g_Combat and IsEnemyKill(self, results) then g_Combat:CheckPendingEnd(results.killed_units) local target for _, unit in ipairs(results.killed_units) do if unit ~= self then target = unit break end end local isKillCinematic, dontPlayForLocalPlayer = IsEnemyKillCinematic(self, results, attack_args) if target and isKillCinematic then cameraTac.SetForceMaxZoom(false) SetAutoRemoveActionCamera(self, target, nil, nil, nil, nil, nil, dontPlayForLocalPlayer) end end -- throw anim self:SetState("mk_Standing_Fire", const.eKeepComponentTargets) local time_to_hit = self:TimeToMoment(1, "hit") or 20 self:Face(target_pos, time_to_hit/2) Sleep(time_to_hit) -- grenade trajectory --local weapon_pos = visual_obj:GetSpotLocPos() local visual_attach_spot = visual_obj:GetAttachSpot() local visual_attach_parent = visual_obj:GetParent() local start_pos, start_angle, start_axis = visual_obj:GetSpotLoc(-1) visual_obj:Detach() visual_obj:SetPos(start_pos) visual_obj:SetAxisAngle(start_axis, start_angle, 0) --visual_obj:SetPos(weapon_pos) PlayFX("ThrowKnife", "start", visual_obj) local trajectory = results.trajectory local throw_thread if #trajectory > 0 then local rotation_axis = RotateAxis(axis_y, axis_z, CalcOrientation(trajectory[2].pos, trajectory[1].pos)) local rpm_range = const.Combat.KnifeMaxRPM - const.Combat.KnifeMinRPM local rpm = const.Combat.KnifeMinRPM + self:Random(rpm_range) throw_thread = CreateGameTimeThread(AnimateThrowTrajectory, visual_obj, trajectory, rotation_axis, -rpm) end while IsValidThread(throw_thread) do Sleep(20) end if self:IsMerc() and not self:HasStatusEffect("HundredKnives") then -- move the item to the proper container (unit or otherwise) local container = target if results.miss or not IsKindOf(target, "Unit") or not target:CanAddItem("Inventory", weapon) then local drop_pos = terrain.FindPassable(visual_obj, 0, -1, -1, const.pfmVoxelAligned) container = GetDropContainer(self, drop_pos) end local slot = self:GetItemSlot(weapon) local thrownKnife = weapon:SplitStack(1, "splitIfEqual") assert(thrownKnife) if container then thrownKnife.drop_chance = 100 -- make sure the weapon will be properly dropped as loot once the enemy dies AddItemsToInventory(container, {thrownKnife}) end local item_class = weapon.class local spare self:ForEachItemInSlot("Inventory", item_class, function(item) if item.class == item_class then spare = item return "break" end end) if slot and spare then if weapon:MergeStack(spare) then self:RemoveItem("Inventory", spare) DoneObject(spare) end end if slot and weapon.Amount <= 0 then self:RemoveItem(slot, weapon) DoneObject(weapon) end self:FlushCombatCache() self:RecalcUIActions() ObjModified(self) ObjModified(container) if IsValid(visual_obj) then DoneObject(visual_obj) end self:UpdateOutfit() else if IsValid(visual_obj) and IsValid(visual_attach_parent) then visual_attach_parent:Attach(visual_obj, visual_attach_spot) else DoneObject(visual_obj) weapon:GetVisualObj(self) -- recreate end end if results.miss then CreateFloatingText(target, T(699485992722, "Miss"), "FloatingTextMiss") else for _, hit in ipairs(results) do if IsValid(hit.obj) and not hit.obj:IsDead() and hit.damage > 0 then if IsKindOf(hit.obj, "Unit") then hit.obj:ApplyDamageAndEffects(self, hit.damage, hit, hit.armor_decay) else hit.obj:TakeDamage(hit.damage, self, hit) end end end local fx_actor = "knife" PlayFX("MeleeAttack", "hit", fx_actor, self:GetVisualPos()) end self:OnAttack(action.id, target, results, attack_args) LogAttack(action, attack_args, results) AttackReaction(action, attack_args, results, "can retaliate") if not attack_args.keep_ui_mode then SetInGameInterfaceMode("IModeCombatMovement") -- revert to movement mode first to avoid having attack modes try to do stuff with a missing weapon end self:ProvokeOpportunityAttacks(action, "attack reaction") self.last_attack_session_id = false table.remove(g_CurrentAttackActions) end) local ap = (cost_ap and cost_ap > 0) and cost_ap or action:GetAPCost(self, attack_args) table.insert(g_CurrentAttackActions, { action = action, cost_ap = ap, attack_args = attack_args, results = results }) self:RemoveStatusEffect("FirstThrow") self:PopAndCallDestructor() end function Unit:UpdateBandageConsistency() if self:HasStatusEffect("BeingBandaged") then local medic for _, unit in ipairs(g_Units) do if unit:GetBandageTarget() == self then return end end self:RemoveStatusEffect("BeingBandaged") end if self:HasStatusEffect("BandageInCombat") then local patient = self:GetBandageTarget() if not patient or not patient:HasStatusEffect("BeingBandaged") then self:RemoveStatusEffect("BandageInCombat") end end end function Unit:Bandage(action_id, cost_ap, args) local goto_ap = args.goto_ap or 0 local action_cost = cost_ap - goto_ap local pos = args.goto_pos local target = args.target local sat_view = args.sat_view or false -- in sat_view form inventory, skip all sleeps and anims local target_self = target == self if g_Combat then if goto_ap > 0 then self:PushDestructor(function(self) self:GainAP(action_cost) end) local result = self:CombatGoto(action_id, goto_ap, args.goto_pos) self:PopDestructor() if not result then self:GainAP(action_cost) return end end elseif not target_self then self:GotoSlab(pos) end local myVoxel = SnapToPassSlab(self:GetPos()) if pos and myVoxel:Dist(pos) ~= 0 then if self.behavior == "Bandage" then self:SetBehavior() end if self.combat_behavior == "Bandage" then self:SetCombatBehavior() end self:GainAP(action_cost) return end local action = CombatActions[action_id] local medicine = GetUnitEquippedMedicine(self) if not medicine then if self.behavior == "Bandage" then self:SetBehavior() end if self.combat_behavior == "Bandage" then self:SetCombatBehavior() end self:GainAP(action_cost) return end self:SetBehavior("Bandage", {action_id, cost_ap, args}) self:SetCombatBehavior("Bandage", {action_id, cost_ap, args}) if not target_self then self:Face(target, 200) Sleep(200) end if not sat_view then if self.stance ~= "Crouch" then self:ChangeStance(false, 0, "Crouch") end self:SetState(target_self and "nw_Bandaging_Self_Start" or "nw_Bandaging_Start") Sleep(self:TimeToAnimEnd() or 100) if not args.provoked then self:ProvokeOpportunityAttacks(action, "attack interrupt") args.provoked = true self:SetBehavior("Bandage", {action_id, cost_ap, args}) self:SetCombatBehavior("Bandage", {action_id, cost_ap, args}) end self:SetState(target_self and "nw_Bandaging_Self_Idle" or "nw_Bandaging_Idle") if not g_Combat and not GetMercInventoryDlg() then SetInGameInterfaceMode("IModeExploration") end elseif not g_Combat then -- insta-heal in sat view while IsValid(target) and not target:IsDead() and target.HitPoints < target.MaxHitPoints and medicine.Condition > 0 do target:GetBandaged(medicine, self) end end self:SetCommand("CombatBandage", target, medicine) end function Unit:IsBeingBandaged() for _, unit in ipairs(g_Units) do if unit:GetBandageTarget() == self then return true end end end function Unit:GetBandageTarget() if self.combat_behavior == "Bandage" and not self:IsDead() then local args = self.combat_behavior_params[3] return args.target end end function Unit:GetBandageMedicine() if self.combat_behavior == "Bandage" and not self:IsDead() then return GetUnitEquippedMedicine(self) end end function Unit:CombatBandage(target, medicine) target:AddStatusEffect("BeingBandaged") ObjModified(target) if IsValid(target) then self:Face(target, 0) end if g_Combat then -- play anim, etc local heal_anim if self == target then heal_anim = "nw_Bandaging_Self_Idle" else heal_anim = "nw_Bandaging_Idle" PlayVoiceResponse(self, "BandageDownedUnit") end self:SetState(heal_anim, const.eKeepComponentTargets) self:AddStatusEffect("BandageInCombat") if not GetMercInventoryDlg() then SetInGameInterfaceMode("IModeCombatMovement") end Halt() else self:PushDestructor(function() self:SetCombatBehavior() self:SetBehavior() self:RemoveStatusEffect("BandageInCombat") target:RemoveStatusEffect("BeingBandaged") ObjModified(target) ObjModified(self) end) self:AddStatusEffect("BandageInCombat") while IsValid(target) and not target:IsDead() and (target.HitPoints < target.MaxHitPoints or target:HasStatusEffect("Bleeding")) and medicine.Condition > 0 do Sleep(5000) target:GetBandaged(medicine, self) end self:SetState(self == target and "nw_Bandaging_Self_End" or "nw_Bandaging_End") Sleep(self:TimeToAnimEnd() or 100) self:PopAndCallDestructor() end end function Unit:EndCombatBandage(no_ui_update, instant) local target = self:GetBandageTarget() self:RemoveStatusEffect("BandageInCombat") ObjModified(self) if IsValid(target) then target:RemoveStatusEffect("BeingBandaged") ObjModified(target) end local normal_anim = self:TryGetActionAnim("Idle", self.stance) if not instant then self:PlayTransitionAnims(normal_anim) end self:SetCombatBehavior() self:SetBehavior() if not no_ui_update and ((self == SelectedObj or target == SelectedObj)) and g_Combat then SetInGameInterfaceMode("IModeCombatMovement") -- force update to redraw the combat path areas now that movement is allowed end if self.command == "EndCombatBandage" then self:SetCommand("Idle") end end function OnMsg.UnitMovementStart(unit) for _, u in ipairs(g_Units) do if u:GetBandageTarget() == unit then u:SetCommand("EndCombatBandage", "no update") end end end function Unit:DownedRally(medic, medicine) self:SetCombatBehavior() self:RemoveStatusEffect("Stabilized") self:RemoveStatusEffect("BleedingOut") self:RemoveStatusEffect("Unconscious") self:RemoveStatusEffect("Downed") self:SetTired(Min(self.Tiredness, 2)) self.downed_check_penalty = 0 if medic then if medicine then medicine.Condition = medicine.Condition - CombatActions.Bandage:ResolveValue("ReviveConditionLoss") end self:GetBandaged(medicine, medic) local slot = medic:GetItemSlot(medicine) if slot and medicine.Condition <= 0 then CombatLog("short", T{831717454393, "'s has been depleted", merc = medic.Nick, item = medicine.DisplayName}) medic:RemoveItem(slot, medicine) DoneObject(medicine) end medic:SetCommand("EndCombatBandage") else -- still check if another unit is tending to us for _, unit in ipairs(self.team.units) do if unit:GetBandageTarget() == self then unit:SetCommand("EndCombatBandage") end end end local stance = self.immortal and "Standing" or self.stance self.stance = stance local normal_anim = self:TryGetActionAnim("Idle", self.stance) self:PlayTransitionAnims(normal_anim) if g_Combat then self:GainAP(self:GetMaxActionPoints() - self.ActionPoints) -- rally happens at the start of turn, restore to full ap end self.TempHitPoints = 0 ObjModified(self) ObjModified(self.team) ForceUpdateCommonUnitControlUI("recreate") CreateFloatingText(self, T(979333850225, "Recovered")) PlayFX("UnitDownedRally", "start", self) Msg("OnDownedRally", medic, self) self:CallReactions("OnUnitRallied", medic, self) if medic ~= self and IsKindOf(medic, "Unit") then medic:CallReactions("OnUnitRallied", medic, self) end self:SetCommand("Idle") end function Unit:Retaliate(attacker, attack_reason, fnGetAttackAndWeapon) if not IsKindOf(attacker, "Unit") or attacker.team ~= g_Teams[g_CurrentTeam] or attacker == self then return false end if self:IsDead() or self:IsDowned() or not self:IsAware() or self:HasPreparedAttack() then return false end local retaliated = false local num_attacks = HasPerk(self, "Killzone") and 2 or 1 for i = 1, num_attacks do local action, weapon if fnGetAttackAndWeapon then action, weapon = fnGetAttackAndWeapon(self) else weapon = self:GetActiveWeapons("Firearm") if IsKindOf(weapon, "HeavyWeapon") then weapon = nil else action = self:GetDefaultAttackAction() end end if not weapon or not action or not self:CanAttack(attacker, weapon, action, 0, nil, "skip_ap_check") then break end local lof_data = GetLoFData(self, { attacker }, { action_id = action.id }) if lof_data[1].los == 0 then break end if i == 1 then self:SetAttackReason(attack_reason, true) attacker:InterruptBegin() end if IsValidTarget(attacker) then retaliated = true self:QueueCommand("RetaliationAttack", attacker, false, action) while not self:IsIdleCommand() do WaitMsg("Idle") end end end ClearAITurnContours() g_Interrupt = true self:SetAttackReason() return retaliated end function NetSyncEvents.InvetoryAction_RealoadWeapon(session_id, ap, weapon_args, src_ammo_type) local combat_mode = g_Units[session_id] and InventoryIsCombatMode(g_Units[session_id] ) local unit = (not gv_SatelliteView or combat_mode) and g_Units[session_id] or gv_UnitData[session_id] if combat_mode and gv_SatelliteView then unit:SyncWithSession("session") end if combat_mode and ap>0 and unit:UIHasAP(ap) then assert(IsKindOf(unit,"Unit"), "Consume AP called for UnitData") unit:ConsumeAP(ap, "Reload") end local weapon = g_ItemIdToItem[weapon_args.item_id] assert(weapon) unit:ReloadWeapon(weapon, src_ammo_type) if combat_mode and gv_SatelliteView then unit:SyncWithSession("map") end if unit:CanBeControlled() then InventoryUpdate(unit) end end function NetSyncEvents.InvetoryAction_UnjamWeapon(session_id, ap, weapon_args) local combat_mode = g_Units[session_id] and InventoryIsCombatMode(g_Units[session_id] ) local unit = (not gv_SatelliteView or combat_mode) and g_Units[session_id] or gv_UnitData[session_id] if combat_mode and gv_SatelliteView then unit:SyncWithSession("session") end if combat_mode and ap>0 and unit:UIHasAP(ap) then assert(IsKindOf(unit,"Unit"), "Consume AP called for UnitData") unit:ConsumeAP(ap, "Unjam") end local weapon = g_ItemIdToItem[weapon_args.item_id] assert(weapon) weapon:Unjam(unit) if combat_mode and gv_SatelliteView then unit:SyncWithSession("map") end if unit:CanBeControlled() then InventoryUpdate(unit) end end function NetSyncEvents.InvetoryAction_SwapWeapon(session_id, ap) local combat_mode = g_Units[session_id] and InventoryIsCombatMode(g_Units[session_id] ) local unit = (not gv_SatelliteView or combat_mode) and g_Units[session_id] or gv_UnitData[session_id] if combat_mode and gv_SatelliteView then unit:SyncWithSession("session") end if combat_mode and ap>0 and unit:UIHasAP(ap) then assert(IsKindOf(unit,"Unit"), "Consume AP called for UnitData") unit:ConsumeAP(ap, "ChangeWeapon") end unit:SwapActiveWeapon() if combat_mode and gv_SatelliteView then unit:SyncWithSession("map") end if unit:CanBeControlled() then InventoryUpdate(unit) end end function NetSyncEvents.InvetoryAction_UseItem(session_id, item_id) local combat_mode = g_Units[session_id] and InventoryIsCombatMode(g_Units[session_id] ) local unit = (not gv_SatelliteView or combat_mode) and g_Units[session_id] or gv_UnitData[session_id] if combat_mode and gv_SatelliteView then unit:SyncWithSession("session") end local item = g_ItemIdToItem[item_id] if not item then return end if combat_mode then unit:ConsumeAP(item.APCost * const.Scale.AP) end ExecuteEffectList(item.Effects, unit) unit:CallReactions("OnItemUsed", item) if combat_mode and gv_SatelliteView then unit:SyncWithSession("map") end if unit:CanBeControlled() then InventoryUpdate(unit) end end function Unit:ReloadAction(action_id, cost_ap, args) if args.reload_all then local _, _, weapons = self:GetActiveWeapons() for _, weapon in ipairs(weapons) do local ammo = weapon.ammo and weapon.ammo.class self:ReloadWeapon(weapon, ammo, args.reload_all) end else local ammo if args and args.target then ammo = self:GetItem(args.target) end if not ammo then local bag = self.Squad and GetSquadBagInventory(self.Squad) if bag then ammo = bag:GetItem(args.target) end end local weapon = args and args.weapon -- Index if type(weapon) == "number" then local w1, w2, wl = self:GetActiveWeapons() weapon = wl[weapon] -- Template name or nothing else weapon = self:GetWeaponByDefIdOrDefault("Firearm", weapon, args and args.pos, args and args.item_id) end self:ReloadWeapon(weapon, ammo, args and args.delayed_fx) end end function Unit:UnjamWeapon(action_id, cost_ap, args) self:ProvokeOpportunityAttacks(action_id and CombatActions[action_id], "attack interrupt") local weapon = false if args and args.pos then weapon = self:GetItemAtPackedPos(args.pos) elseif args and args.weapon then weapon = self:GetWeaponByDefIdOrDefault("Firearm", args and args.weapon, args and args.pos, args and args.item_id) end if weapon then --Inventory Weapon weapon:Unjam(self) else local weapon1, weapon2 = self:GetActiveWeapons() if weapon1.jammed and not weapon1:IsCondition("Broken") then weapon1:Unjam(self) elseif weapon2.jammed and not weapon2:IsCondition("Broken") then weapon2:Unjam(self) end end end function Unit:EnterEmplacement(obj, instant) local fire_pos = obj:GetOperatePos() if not instant then if self.stance == "Prone" then self:DoChangeStance("Standing") end if not IsCloser(self, fire_pos, const.SlabSizeX/2) then self:Goto(fire_pos, "sl") end end self:SetAxis(axis_z) self:SetAngle(obj:GetAngle(), instant and 0 or 200) self:SetTargetDummy(nil, nil, "hmg_Crouch_Idle", 0, "Crouch") self:AddStatusEffect("ManningEmplacement") -- affect weapon holster too if instant then self:SetPos(fire_pos) self:SetState("hmg_Crouch_Idle", 0, 0) else self:SetState("hmg_Standing_to_Crouch") self:SetPos(fire_pos, 500) Sleep(self:TimeToAnimEnd()) self:SetState("hmg_Crouch_Idle") end if self.stance ~= "Crouch" then self.stance = "Crouch" Msg("UnitStanceChanged", self) end self:SetEffectValue("hmg_emplacement", obj.handle) self:SetEffectValue("hmg_sector", gv_CurrentSectorId) obj.manned_by = self obj.weapon.owner = self.session_id end function Unit:LeaveEmplacement(instant, exit_combat) if not self:HasStatusEffect("ManningEmplacement") then return end local handle = self:GetEffectValue("hmg_emplacement") local obj = HandleToObject[handle] if obj then if exit_combat and obj.exploration_manned and self.team.player_enemy then -- enemy units manning important emplacements should stay in them return end obj.manned_by = nil end local exit_pos = not IsPassSlab(self) and SnapToPassSlab(self) if instant then if exit_pos then self:SetPos(exit_pos) end else self:SetAnim(1, "hmg_Crouch_to_Standing") if exit_pos then Sleep(Max(0, self:TimeToAnimEnd() - 500)) self:SetPos(exit_pos, 200) end Sleep(self:TimeToAnimEnd()) end if self.stance ~= "Standing" then self.stance = "Standing" Msg("UnitStanceChanged", self) end self:RemoveStatusEffect("ManningEmplacement") self:SetEffectValue("hmg_emplacement") if obj and obj.weapon then obj.weapon.owner = nil end self:InterruptPreparedAttack() self:FlushCombatCache() self:RecalcUIActions(true) self:UpdateOutfit() ObjModified(self) end function Unit:StartBombard() local weapon = self:GetActiveWeapons("Mortar") if weapon and self.prepared_bombard_zone then weapon:ApplyAmmoUse(self, self.prepared_bombard_zone.num_shots, false, self.prepared_bombard_zone.weapon_condition) end local fired = self.prepared_bombard_zone.num_shots and self.prepared_bombard_zone.num_shots > 0 if not fired and self.prepared_bombard_zone then DoneObject(self.prepared_bombard_zone) end self.prepared_bombard_zone = nil self:SetCombatBehavior() end function Unit:ExplorationStartCombatAction(action_id, ap, args) local action = CombatActions[action_id] if g_Combat or not action then return end self.ActionPoints = self:GetMaxActionPoints() -- always start combat actions out of combat at max ap ap = action:GetAPCost(self, args) self:AddStatusEffect("SpentAP", ap) end function Unit:LightningReactionCheck(effect) if g_Combat and g_Teams[g_Combat.team_playing] == self.team then return end -- Don't proc in your turn if self.stance == "Prone" or self:HasStatusEffect("ManningEmplacement") then return end local chance = effect:ResolveValue("chance") if not chance or (self:Random(100) < chance) then self:SetActionCommand("ChangeStance", nil, nil, "Prone") CreateFloatingText(self, T(726050447294, "Lightning Reaction"), nil, nil, true) return true end end -- signature abilities function Unit:AddSignatureRechargeTime(id, duration, recharge_on_kill) if CheatEnabled("SignatureNoCD") then return end local recharges = self.signature_recharge or {} self.signature_recharge = recharges if string.match(id, "DoubleToss") then id = "DoubleToss" end local idx = recharges[id] if not idx then idx = #recharges + 1 recharges[id] = idx end recharges[idx] = { id = id, expire_campaign_time = Game.CampaignTime + duration, on_kill = recharge_on_kill } self:RecalcUIActions() ObjModified(self) end function Unit:GetSignatureRecharge(id) if string.match(id, "DoubleToss") then id = "DoubleToss" end local idx = self.signature_recharge and self.signature_recharge[id] return idx and self.signature_recharge[idx] or false end function Unit:UpdateSignatureRecharges(trigger) local recharges = self.signature_recharge or empty_table for i = #recharges, 1, -1 do local recharge = recharges[i] if (trigger == "kill" and recharge.on_kill) or (Game.CampaignTime > recharge.expire_campaign_time) then local id = recharge.id self:RechargeSignature(id) end end end function Unit:RechargeSignature(id) local i = self.signature_recharge[id] table.remove(self.signature_recharge, i) self.signature_recharge[id] = nil end function Unit:HasSignatures() local perks = self:GetPerks() for _, perk in ipairs(perks) do if perk.Tier == "Personal" and self.ui_actions[perk.class] and self.ui_actions[perk.class] ~= "hidden" then return true end end return false end local function UpdateAllRecharges() for _, unit in ipairs(g_Units) do unit:UpdateSignatureRecharges() end end OnMsg.SatelliteTick = UpdateAllRecharges function Unit:Nazdarovya(action_id, cost_ap, args) -- restore hp & gain status local action = CombatActions[action_id] self:ApplyTempHitPoints(action:ResolveValue("tempHp")) self:AddStatusEffect("Drunk") local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) end function Unit:DoubleToss(action_id, cost_ap, args) self:ThrowGrenade(action_id, cost_ap, args) local action = CombatActions[action_id] local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime("DoubleToss", const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) end function Unit:OnMyTarget(action_id, cost_ap, args) local action = CombatActions[action_id] -- find allies who can make an attack, have them make a basic attack at the target (so long as target isn't dead) local fired = {} for _, ally in ipairs(self.team.units) do if ally ~= self then local attack = ally:OnMyTargetGetAllyAttack(args.target) if attack then local ap = ally.ActionPoints -- give temporary ap to avoid safeguards that would stop us from firing ally.ActionPoints = ally:GetMaxActionPoints() ally:SetCommand("FirearmAttack", attack.id, 0, args) fired[#fired + 1] = ally ally.ActionPoints = ap end end end while #fired > 0 do Sleep(100) for i = #fired, 1, -1 do if fired[i]:IsIdleCommand() then table.remove(fired, i) end end end local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) SetInGameInterfaceMode("IModeCombatMovement") end function Unit:OnMyTargetGetAllyAttack(target) local attack = self.command ~= "Downed" and self:GetDefaultAttackAction("ranged") local weapon = attack:GetAttackWeapons(self) if attack and attack.id ~= "UnarmedAttack" and HasVisibilityTo(self, target) and IsKindOf(weapon, "Firearm") and not IsKindOf(weapon, "HeavyWeapon") and self:CanAttack(target, weapon, attack, nil, nil, "skip_ap_check") then return attack end end function Unit:SteroidPunch(action_id, cost_ap, args) self:MeleeAttack(action_id, cost_ap, args) end function Unit:ResolveSteroidPunch(args, results) local target = args.target if target:IsDead() then if target.on_die_attacker == self then target.on_die_hit_descr = target.on_die_hit_descr or {} target.on_die_hit_descr.death_blow = true target.on_die_hit_descr.falldown_callback = "SteroidPunchExplosion" end return end if target.stance == "Prone" or target:HasStatusEffect("Unconscious") then return end local angle = CalcOrientation(self, target) local pushSlabs = CombatActions.SteroidPunch:ResolveValue("pushSlabs") local fromPos = GetPassSlab(target) or target:GetPos() local curPos = fromPos local toPos = fromPos local free_slabs = 0 while free_slabs < pushSlabs + 1 do local nextPos = GetPassSlab(RotateRadius((free_slabs + 1) * const.SlabSizeX, angle, fromPos)) if not nextPos then break elseif not IsPassSlabStep(curPos, nextPos, const.TunnelTypeWalk) then break elseif IsOccupiedExploration(nil, nextPos:xyz()) then break end toPos = curPos curPos = nextPos free_slabs = free_slabs + 1 end local anim local toMove = Max(0, free_slabs - 1) angle = angle + 180*60 if free_slabs > 0 then anim = self:GetRandomAnim("civ_KnockDown_B") else anim = self:GetRandomAnim("civ_KnockDown_OnSpot_B") if self.species == "Human" then angle = FindProneAngle(self, toPos, angle) end end target:SetCommand("Punched", self, toPos, angle, anim) end function SteroidPunchExplosion(attacker, target, pos) local mockGrenade = PlaceInventoryItem("SteroidPunchGrenade") local ignore_targets = { [attacker] = true, [target] = true } ExplosionDamage(attacker, mockGrenade, pos, nil, nil, "disableBurnFx", ignore_targets) end function Unit:Punched(attacker, pos, angle, anim) anim = anim or "civ_KnockDown_OnSpot_B" local hit_moment = self:GetAnimMoment(anim, "hit") or self:GetAnimMoment(anim, "end") or self:GetAnimDuration(anim) - 1 CreateGameTimeThread(function(delay, target, pos, attacker) Sleep(delay) SteroidPunchExplosion(attacker, target, pos) end, hit_moment, self, pos, attacker) if self.species == "Human" then self.stance = "Prone" end self:MovePlayAnim(anim, self:GetPos(), pos, 0, nil, true, angle, nil, nil, nil, true) end function Unit:TakeSuppressionFire() self:Pain() if self.stance ~= "Prone" then self:SetActionCommand("ChangeStance", nil, nil, "Prone") end end function Unit:AlwaysReadyFindCover(enemy) -- calc CombatPath with a set amount of AP (70% of max) local ap = MulDivRound(self:GetMaxActionPoints(), const.Combat.RepositionAPPercent, 100) local path = CombatPath:new() local cost_extra = GetStanceToStanceAP("Standing", "Crouch") path:RebuildPaths(self, ap) local best_ppos, best_score, best_ap, stance, score -- find nearest reachable position that has cover from the attacker DbgClearVectors() for ppos, ap in pairs(path.paths_ap) do local x, y, z = point_unpack(ppos) local pos = point(x, y, z) local cover, any, coverage = self:GetCoverPercentage(enemy:GetPos(), pos, "Crouch") DbgAddVector(point(x, y, z), guim, const.clrGray) if cover and cover == const.CoverLow and self.stance == "Standing" and ap < cost_extra then DbgAddVector(point(x, y, z), 2*guim, const.clrRed) cover = false end if cover then DbgAddVector(point(x, y, z), 2*guim, const.clrYellow) score = cover * coverage if not best_ppos then best_ppos, best_score, best_ap, stance = ppos, score, ap if cover == const.CoverLow and self.stance == "Standing" then stance = "Crouch" else stance = nil end elseif score > best_score or (score > MulDivRound(best_score, 90, 100) and ap > best_ap) then best_ppos, best_score, best_ap, stance = ppos, score, ap if cover == const.CoverLow and self.stance == "Standing" then stance = "Crouch" else stance = nil end end end end end MapVar("g_AlwaysReadyThread", false) function Unit:TryActivateAlwaysReady(enemy) if IsValidThread(g_AlwaysReadyThread) then return end local cover, any, coverage = self:GetCoverPercentage(enemy:GetPos()) if cover then if cover == const.CoverLow and self.stance == "Standing" then -- change stance first to get in the cover CancelWaitingActions(-1) NetStartCombatAction("StanceCrouch", self, 0) end return end -- override standard reposition dest picking logic -- calc CombatPath with a set amount of AP (70% of max) local ap = MulDivRound(self:GetMaxActionPoints(), const.Combat.RepositionAPPercent, 100) local cost_extra = GetStanceToStanceAP("Standing", "Crouch") local path = CombatPath:new() path:RebuildPaths(self, ap) local best_ppos, best_score, best_ap, stance -- find nearest reachable position that has cover from the attacker for ppos, ap in pairs(path.paths_ap) do local pos = point(point_unpack(ppos)) local cover, any, coverage = self:GetCoverPercentage(enemy:GetPos(), pos, "Crouch") if cover and cover == const.CoverLow and self.stance == "Standing" and ap < cost_extra then cover = false end if cover then local score = cover * coverage if not best_ppos then best_ppos, best_score, best_ap, stance = ppos, score, ap if cover == const.CoverLow and self.stance == "Standing" then stance = "Crouch" else stance = nil end elseif score > best_score or (score > MulDivRound(best_score, 90, 100) and ap > best_ap) then best_ppos, best_score, best_ap, stance = ppos, score, ap if cover == const.CoverLow and self.stance == "Standing" then stance = "Crouch" else stance = nil end end end end if not best_ppos then -- no cover available, abort CreateFloatingText(self, T(103063369185, "Always Ready: No covers nearby"), "FloatingTextMiss") return end local path_to_dest = path:GetCombatPathFromPos(best_ppos) DoneObject(path) g_AlwaysReadyThread = CreateGameTimeThread(Unit.ActivateAlwaysReady, self, best_ppos, path_to_dest, stance) end function Unit:ActivateAlwaysReady(reposition_dest, reposition_path, stance) local controller = CreateAIExecutionController{ -- todo: custom notifications maybe label = "AlwaysReady", reposition = true, activator = self, } -- hide AP changes & store current AP to restore them at the end -- note: this isn't a command (the execution would override it) so make sure nothing can break in the code below -- as we can't rely on command destructors to restore the state local start_ap = self.ActionPoints self.ui_override_ap = self:GetUIActionPoints() -- setup the reposition dest - the controller will not use the default logic with "AlwaysReady" label local x, y, z = point_unpack(reposition_dest) self.reposition_dest = stance_pos_pack(x, y, z, StancesList[stance or self.stance]) self.reposition_path = reposition_path -- exec the reposition CancelWaitingActions(-1) controller.restore_camera_obj = SelectedObj g_Combat:SetRepositioned(self, false) -- we can do this more than once controller:Execute({self}) -- internally it is sprocall so the code below will execute even if something breaks DoneObject(controller) -- restore ap to their previous value self.ActionPoints = start_ap self.ui_override_ap = false g_AlwaysReadyThread = false end function Unit:ChargeAttack(action_id, cost_ap, args) self:PushDestructor(function() g_TrackingChargeAttacker = false self.move_attack_action_id = nil end) --handle badges as melee doesn't use prepare to attack logic if not g_AITurnContours[self.handle] and g_Combat and g_AIExecutionController then local enemy = self.team.side == "enemy1" or self.team.side == "enemy2" or self.team.side == "neutralEnemy" g_AITurnContours[self.handle] = SpawnUnitContour(self, enemy and "CombatEnemy" or "CombatAlly") ShowBadgeOfAttacker(self, true) end --handle camera for charge attack ShouldTrackMeleeCharge(self, args.target) self.move_attack_action_id = action_id self:SetCommandParamValue(self.command, "move_anim", "Run") if args.goto_pos then args.unit_moved = true self:CombatGoto(action_id, args.goto_ap or 0, args.goto_pos) end self:MeleeAttack(action_id, cost_ap, args) self:PopAndCallDestructor() end function Unit:GloryHogCharge(action_id, cost_ap, args) local action = CombatActions[action_id] self:ApplyTempHitPoints(action:ResolveValue("tempHp")) self:ChargeAttack(action_id, cost_ap, args) local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) end function Unit:HyenaCharge(action_id, cost_ap, args) if self.species ~= "Hyena" then self:GainAP(cost_ap) CombatActionInterruped(self) return end local target = args.target local action = CombatActions[action_id] args.prediction = false args.unit_moved = true local results, attack_args = action:GetActionResults(self, args) local atk_pos, atk_jmp_pos = GetHyenaChargeAttackPosition(self, target, attack_args.move_ap, attack_args.jump_dist, action_id) if not atk_pos then self:GainAP(cost_ap) CombatActionInterruped(self) return end self:PushDestructor(function() g_TrackingChargeAttacker = false table.remove(g_CurrentAttackActions) self.move_attack_action_id = nil end) --handle badges as melee doesn't use prepare to attack logic if not g_AITurnContours[self.handle] and g_Combat and g_AIExecutionController then local enemy = self.team.side == "enemy1" or self.team.side == "enemy2" or self.team.side == "neutralEnemy" g_AITurnContours[self.handle] = SpawnUnitContour(self, enemy and "CombatEnemy" or "CombatAlly") ShowBadgeOfAttacker(self, true) end --handle camera for charge attack ShouldTrackMeleeCharge(self, target) self.move_attack_action_id = action_id table.insert(g_CurrentAttackActions, { action = action, cost_ap = cost_ap, attack_args = attack_args, results = results }) self:AttackReveal(action, attack_args, results) self:SetCommandParamValue(self.command, "move_anim", "Run") self:CombatGoto(action_id, attack_args.move_ap, atk_jmp_pos) self:Face(atk_pos) if self:CallReactions_And("OnCheckInterruptAttackAvailable", target, action) then self:ProvokeOpportunityAttacks(action, "attack interrupt", nil, "melee") end --handle badges as melee doesn't use prepare to attack logic ShowBadgesOfTargets({target}, "show") self:SetState("attack_Charge") local fx_actor = "jaws" PlayFX("MeleeAttack", "start", fx_actor, self:GetVisualPos()) local tth = self:TimeToMoment(1, "hit") or (self:TimeToAnimEnd() / 2) self:SetPos(atk_pos, self:TimeToAnimEnd()) Sleep(tth) if results.miss then CreateFloatingText(target, T(699485992722, "Miss"), "FloatingTextMiss") else for _, hit in ipairs(results) do if IsValid(hit.obj) and not hit.obj:IsDead() and hit.damage > 0 then if IsKindOf(hit.obj, "Unit") then hit.obj:ApplyDamageAndEffects(self, hit.damage, hit, hit.armor_decay) else hit.obj:TakeDamage(hit.damage, self, hit) end end end PlayFX("MeleeAttack", "hit", fx_actor, self:GetVisualPos()) end self:OnAttack(action_id, target, results, attack_args) Sleep(self:TimeToAnimEnd()) LogAttack(action, attack_args, results) AttackReaction(action, attack_args, results, "can retaliate") if IsValid(target) then ObjModified(target) end self.last_attack_session_id = false ShowBadgesOfTargets({target}, "hide") self:PopAndCallDestructor() end function Unit:DanceForMe(action_id, cost_ap, args) local action = CombatActions[action_id] local weapon = self:GetActiveWeapons() local aoeParams = weapon:GetAreaAttackParams(action_id, self) local attackData = self:ResolveAttackParams(action_id, args.target, {}) local attackerPos = attackData.step_pos local attackerPos3D = attackerPos if not attackerPos3D:IsValidZ() then attackerPos3D = attackerPos3D:SetTerrainZ() end local targetPos = args.target local targetAngle = CalcOrientation(attackerPos, targetPos) local distance = Clamp(attackerPos3D:Dist(targetPos), aoeParams.min_range * const.SlabSizeX, aoeParams.max_range * const.SlabSizeX) local enemies = GetEnemies(self) local maxValue, losValues = CheckLOS(enemies, attackerPos, distance, attackData.stance, aoeParams.cone_angle, targetAngle, false) if maxValue then for i, los in ipairs(losValues) do if los then local defaultAttack = self:GetDefaultAttackAction("ranged") local tempArgs = table.copy(args) tempArgs.target = enemies[i] tempArgs.target_spot_group = "Legs" if defaultAttack and self:CanAttack(tempArgs.target, weapon, defaultAttack, nil, nil, "skip_ap_check") then self:FirearmAttack(defaultAttack.id, 0, tempArgs) end end end end local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) self:SetActionCommand("OverwatchAction", action_id, cost_ap, args) end -- attack each body part function Unit:IceAttack(action_id, cost_ap, args) local target = args.target if not IsKindOf(target, "Unit") then return end local action = CombatActions[action_id] local weapon = self:GetActiveWeapons() local bodyParts = target:GetBodyParts(weapon) for i=#bodyParts, 1, -1 do if not self:CanUseWeapon(weapon, 1) then break end local bodyPart = bodyParts[i] args.target_spot_group = bodyPart.id args.ice_attack_num = i self:FirearmAttack(action_id, 0, args) end local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) end -- bypass armor function Unit:KalynaShot(action_id, cost_ap, args) self:FirearmAttack(action_id, 0, args) local action = CombatActions[action_id] local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) end function Unit:EyesOnTheBack(action_id, cost_ap, args) local action = CombatActions[action_id] local recharge_on_kill = action:ResolveValue("recharge_on_kill") or 0 self:AddSignatureRechargeTime(action_id, const.Combat.SignatureAbilityRechargeTime, recharge_on_kill > 0) self:SetActionCommand("OverwatchAction", action_id, cost_ap, args) end function Unit:BulletHell(action_id, cost_ap, args) args.attack_anim_delay = 50 self:SetActionCommand("FirearmAttack", action_id, cost_ap, args) end -- makes the actual bullets do no damage and spreads them in a repeating pattern function BulletHellOverwriteShots(attack) local weapon = attack.weapon local halfAngle = DivRound(weapon.OverwatchAngle, 2) local newAngle = halfAngle local angleStep = MulDivRound(weapon.OverwatchAngle, 2, #attack.shots) for i, shot in ipairs(attack.shots) do shot.target_pos = RotateAxis(shot.target_pos, point(0, 0, 4069) , newAngle, shot.attack_pos) shot.stuck_pos = RotateAxis(shot.stuck_pos, point(0, 0, 4069) , newAngle, shot.attack_pos) if abs(newAngle) >= halfAngle then angleStep = -angleStep end newAngle = newAngle + angleStep end end function Unit:GrizzlyPerk(action_id, cost_ap, args) self:FirearmAttack(action_id, 0, args) end function OnMsg.CombatEnd() for _, unit in ipairs(g_Units) do unit.last_attack_pos = false end end