const.AnimChannel_RightHandGrip = 2 const.AnimChannel_Pain = 3 const.PathTurnAnimChnl = 4 --const.AnimChannel_VerticalGrip const.PainAnimWeight = 1000 const.PainAnimGrazingWeight = 300 const.PainAnimWeightMoment = 100 local PainEasing = GetEasingIndex("Sin in/out") const.StopAnimCrossfadeTime = 400 DefineConstInt("Camera", "BufferSizeNoCameraMov", 30, 1, "Defines how much buffer (100% - the_value % from each side) of the screen will NOT be taken into account when deciding if camera should snap from one point to another.") if FirstLoad then g_LocPollyActors = { { value = "Nicole", text = "Nicole (female)", }, { value = "Amy", text = "Amy (female)", }, { value = "Emma", text = "Emma (female)", }, { value = "Aditi", text = "Aditi (female)", }, { value = "Raveena", text = "Raveena (female)", }, { value = "Joanna", text = "Joanna (female)", }, { value = "Kendra", text = "Kendra (female)", }, { value = "Kimberly", text = "Kimberly (female)", }, { value = "Salli", text = "Salli (female)", }, -- { value = "Tatyana", text = "Tatyana (female)", }, -- disabled because other Russian-accent voice, Maxim below, produces garbled voice, don't use without checking it it's fixed by Amazon { value = "Russell", text = "Russell (male)", }, { value = "Brian", text = "Brian (male)", }, { value = "Joey", text = "Joey (male)", }, { value = "Matthew", text = "Matthew (male)", }, { value = "Geraint", text = "Geraint (male)", }, -- { value = "Maxim", text = "Maxim (male)" }, -- produces garbled voice, don't use without checking it it's fixed by Amazon { value = "Ivy", text = "Ivy (child)", }, { value = "Justin", text = "Justin (child)", }, } g_LocPollyActorsMatchTable = {} g_StanceActionDefault = "do not change" end local RandomWalkAnimBehaviors = { ["Visit"] = true, ["Roam"] = true, ["RoamSingle"] = true, ["Ambient"] = true, } HpToText = { Hidden = T{""}, Dead = T(541985975283, "Dead"), Dying = T(179713645915, "Almost dead"), Critical = T(127741862032, "Severely Wounded"), Wounded = T(811971736433, "Wounded"), Poor = T(121790238609, "Weak"), Healthy = T(150774995803, "Healthy"), Strong = T(830864531146, "Strong"), Excellent = T(341748793018, "Very strong"), Uninjured = T(586026778443, "Uninjured"), } DieChanceToText = { None = T(971598159331, --[[chance to die]] "None"), Low = T(283826279782, --[[chance to die]] "Low"), Moderate = T(904355103349, --[[chance to die]] "Moderate"), High = T(295409078209, --[[chance to die]] "High"), VeryHigh = T(426026245987, --[[chance to die]] "Very High"), } local KeepAimIKCommands = { Idle = true, AimIdle = true, OpportunityAttack = true, PreparedAttackIdle = true, ExecFirearmAttacks = true, HeavyWeaponAttack = true, FirearmAttack = true, } DefineClass.UnitBase = { __parents = {"UnitProperties", "UnitInventory", "StatusEffectObject", "ReactionObject" }, } DefineReactionsPreset("Unit", "UnitBase", "unit_reactions", "MsgReactionsPreset") -- DefineClass.UnitReactionsPreset function UnitBase:UnregisterReactions() for _, effect in ipairs(self.StatusEffects) do self:RemoveReactions(effect) end self:ForEachItem(false, function(item, slot, left, top, self) if slot ~= "SetpieceWeapon" then item:UnregisterReactions(self) end end, self) end function UnitBase:RegisterReactions() for _, effect in ipairs(self.StatusEffects) do self:AddReactions(effect, effect.unit_reactions) end self:ForEachItem(false, function(item, slot, left, top, self) if slot ~= "SetpieceWeapon" then item:RegisterReactions(self) -- will add reactions from item and components (in case of firearm with components) end end, self) end function OnMsg.MercHired(merc_id, price, days, alreadyHired) if not alreadyHired then gv_UnitData[merc_id]:RegisterReactions() end end function UnitBase:GetPersonalMorale() local teamMorale = self.team and self.team.morale or 0 local personalMorale = 0 --reduce morale for at least one disliked merc in team local isDisliking = false for _, dislikedMerc in ipairs(self.Dislikes) do local dislikedIndex = table.find(self.team.units, "session_id", dislikedMerc) if dislikedIndex and not self.team.units[dislikedIndex]:IsDead() then personalMorale = personalMorale - 1 isDisliking = true break end end --increase morale for no disliked and at least one liked merc if not isDisliking then for _, likedMerc in ipairs(self.Likes) do local likedIndex = table.find(self.team.units, "session_id", likedMerc) if likedIndex and not self.team.units[likedIndex]:IsDead() then personalMorale = personalMorale + 1 break end end end --lower morale if below 50% or 3+ wounds (REVERT for psycho perk) local isWounded = false local idx = self:HasStatusEffect("Wounded") if idx and self.StatusEffects[idx].stacks >= 3 then isWounded = true end if self.HitPoints < MulDivRound(self.MaxHitPoints, 50, 100) or isWounded then if HasPerk(self, "Psycho") then personalMorale = personalMorale + 1 else personalMorale = personalMorale - 1 end end --lower morale if liked merc has died recently for _, likedMerc in ipairs(self.Likes) do local ud = gv_UnitData[likedMerc] if ud and ud.HireStatus == "Dead" then local deathDay = ud.HiredUntil if deathDay + 7 * const.Scale.day > Game.CampaignTime then personalMorale = personalMorale - 1 break end end end personalMorale = self:CallReactions_Modify("OnCalcPersonalMorale", personalMorale) return Clamp(personalMorale + teamMorale, -3, 3) end DefineClass.Unit = { __parents = { "Movable", "CombatObject", "UnitBase", "GameDynamicSpawnObject", "AppearanceObject", "Interactable", "StepObject", "SyncObject", "ComponentSound", "SpawnFXObject", "HittableObject", "ComponentCustomData", "CombatTaskOwner", "AmbientLifeZoneUnit", }, properties = { {category = "Ambient Life", id = "ViewPerpetual", editor = "buttons", default = false, no_edit = function(self) return not self.perpetual_marker end, buttons = { { name = "View Perpetual Marker", func = function(self) ViewObject(self.perpetual_marker) end}, { name = "Select Perpetual Marker", func = function(self) editor.ClearSel() editor.AddObjToSel(self.perpetual_marker) end}, }, }, {category = "Ambient Life", id = "ViewSpawner", editor = "buttons", default = false, no_edit = function(self) return not self.spawner end, buttons = { { name = "View Spawner", func = function(self) ViewObject(self.spawner) end}, { name = "Select Spawner", func = function(self) editor.ClearSel() editor.AddObjToSel(self.spawner) end}, }, }, {category = "Ambient Life", id = "ViewRoutineSpawner", editor = "buttons", default = false, no_edit = function(self) return not self.routine_spawner end, buttons = { { name = "View Routine Spawner", func = function(self) ViewObject(self.routine_spawner) end}, { name = "Select Routine Spawner", func = function(self) editor.ClearSel() editor.AddObjToSel(self.routine_spawner) end}, }, }, -- hidden overridden properties {id = "animWeight"}, {id = "animBlendTime"}, {id = "anim2"}, {id = "anim2BlendTime"}, }, flags = { efUnit = true, cofComponentColorizationMaterial = true, gofUnitLighting = true, gofOnRoof = false, gofAdjustZ = true }, pfclass = 3, pfflags = const.pfmDestlockSmart + const.pfmCollisionAvoidance + const.pfmImpassableSource + const.pfmVoxelAligned + const.pfmOrient, pfclass_overwritten = false, ground_orient = false, anim_moments_single_thread = true, material_type = "Flesh", species = "Human", body_type = "Human", default_move_style = false, cur_move_style = false, cur_idle_style = false, move_step_fx = false, move_stop_anim_len = 0, move_stop_foot_left_anim = false, move_stop_foot_right_anim = false, -- Do not forget to add new class members to Get/SetDynamicData function to save in Game state (see GetCurrentGameVarValues) Appearance = false, -- reference to the corresponding UnitData in gv_UnitData[unit.session_id], all units (except setpiece testing clones) have this session_id = false, team = false, collision_radius = const.SlabSizeX/3, radius = const.SlabSizeX/4, stance = "Standing", unitdatadef_id = false, -- id in UnitDataDefs group = false, -- from UnitDataDefs[self.unitdatadef_id] target_dummy = false, cover_last_face_time = false, last_attack_session_id = false, current_weapon = "Handheld A", modify_animations_ar = false, aim_action_id = false, aim_action_params = false, aim_results = false, aim_attack_args = false, aim_fx_target = false, aim_fx_thread = false, aim_rotate_last_angle = false, aim_rotate_cooldown_time = false, action_visual_weapon = false, weapon_light_fx = false, return_pos = false, play_sequential_actions = false, command_specific_params = false, prepared_attack_obj = false, melee_threat_contour = false, prepared_bombard_zone = false, bombard_weapon = false, queued_action_id = false, queued_action_visual = false, queued_action_pos = false, is_melee_aim_last_turn = false, downing_action_start_time = false, last_turn_movement = false, last_turn_damaged = false, start_turn_pos = false, reposition_dest = false, reposition_path = false, reposition_marker = false, last_orientation_angle = false, last_attack_pos = false, god_mode = false, infinite_ammo = false, infinite_ap = false, infinite_dmg = false, infinite_condition = false, action_command = false, actions_nettravel = 0, -- sent but not received yet ui_reserved_ap = 0, ui_override_ap = false, -- when set to something GetUIActionPoints will return this value to hide underlying AP shenanigans free_move_ap = 0, start_move_total_ap = 0, start_move_cost_ap = 0, start_move_free_ap = 0, combat_path = false, combat_path_obj = false, using_cumbersome = false, -- has any equipped Cumbersome items (cached, no need to save/load) attacked_this_turn = false, hit_this_turn = false, performed_action_this_turn = false, wounded_this_turn = false, downed_check_penalty = 0, ai_context = false, interruptable = true, interrupted = false, goto_interrupted = false, interrupt_callback = false, action_interrupt_callback = false, is_moving = false, goto_target = false, goto_stance = false, goto_hide = false, in_combat_movement = false, visibility_override = false, traverse_tunnel = false, tunnel_blockers = false, fallback_walk_speed = 3*guim, -- when the walk anim has no step perks_activated = false, attack_reason = false, -- additional prefix text to attack log entry (e.g. retaliation, overwatch, etc) opportunity_attack = false, effect_values = false, -- storage for status effect data/state enemy_visual_contact = false, -- does any enemy has visual contact with this unit alerted_by_enemy = false, last_known_enemy_pos = false, -- last location an enemy unit was seen marked_target_attack_args = false, pending_aware_state = false, pending_awareness_role = false, -- used for awareness visualization; not persisted suspicion = false, suspicious_body_seen = false, aware_reason = false, auto_face = true, -- the unit look at the nearest enemy reorientation_thread = false, setik_thread = false, spawner = false, --NPC synced_anim = false, synced_anim_time = 0, synced_angle = 0, die_anim_prefix = false, on_die_attacker = false, on_die_hit_descr = false, death_explosion_played = false, death_fx_object = false, interacting_unit = false, killed_stance = false, combat_badge = false, ui_badge = false, ui_actions = false, combat_cache = false, villain_defeated = false, retreating = false, angle_before_interaction = false, highlight_reasons = false, banters = false, banters_played_lines = false, sequential_banter = false, approach_banters = false, approach_banters_distance = false, approach_banters_cooldown_id = false, last_played_banter_id = false, -- No need to serialize this visible = true, interactable_highlight_ctr = false, highlight_collection = false, -- general (out-of-combat) behaviors behavior = false, -- command to set behavior_params = false, -- parameters to set -- in-combat behaviors combat_behavior = false, -- command to set combat_behavior_params = false, -- parameters to set entrance_marker = false, neutral_ai_dont_move = false, neutral_retal_attacked = false, pain_thread = false, update_attached_weapons_thread = false, routine = "StandStill", routine_area = "self", routine_spawner = false, ephemeral = false, perpetual_marker = false, teleport_allowed_once = false, conflict_ignore = false, visit_command = false, visit_marker = false, visit_reached = false, cower_forbidden = false, cower_from = false, cower_angle = false, cower_cooldown = false, last_roam = false, last_visit = false, dead_markers_tried = false, mourn = false, maraud = false, enter_map_wait_time = false, enter_map_pos = false, seen_bodies = false, visit_test = false, carry_flare = false, infected = false, innerInfoRevealed = false, --used to mark the unit when revealed from the perk innerinfo fx_in_water = false, indoors = false, unarmed_weapon = false, warned_traps_pos = false, move_follow_target = false, move_follow_dest = false, move_attack_target = false, move_attack_action_id = false, move_attack_in_progress = false, waiting_attack = false, last_idle_aiming_time = false, place_wind_mod_trails = false, stain_update_times = false, -- async, visual, not saved __toluacode = empty_func, PrePlay = empty_func, PostPlay = empty_func, throw_died_message = false, is_despawned = false, -- Set briefly in Unit:Despawn before it is destroyed but after the sync with unitdata. } local function StoreBehaviorParamTbl(tbl) if not tbl then return end local stored, stored_handles for k, v in pairs(tbl) do if IsKindOf(v, "Object") then if not stored_handles then stored_handles = {} stored = stored or {} stored.__stored_obj_value = stored_handles end stored[k] = v.handle stored_handles[k] = true elseif type(v) == "table" then local new_value = StoreBehaviorParamTbl(v) if new_value ~= v then if not stored then stored = {} end stored[k] = new_value end end end if not stored then return tbl end -- copy the skipped values for k, v in pairs(tbl) do if stored[k] == nil then stored[k] = v end end return stored end local function RestoreBehaviorParamTbl(stored) if not stored then return end local stored_handles = stored.__stored_obj_value if stored_handles then stored.__stored_obj_value = nil end for k, v in pairs(stored) do if type(v) == "table" then RestoreBehaviorParamTbl(v) end end for k in pairs(stored_handles) do stored[k] = HandleToObject[ stored[k] ] end end function Unit:GetDynamicData(data) local class = g_Classes[self.class] data.session_id = self.session_id or nil data.unitdatadef_id = self.unitdatadef_id or nil data.ground_orient = self.ground_orient or nil data.stance = self.stance ~= class.stance and self.stance or nil data.return_pos = self.return_pos or nil data.current_weapon = self.current_weapon ~= class.current_weapon and self.current_weapon or nil data.perks_activated = next(self.perks_activated) and self.perks_activated or nil data.Groups = #(self.Groups or "") > 0 and self.Groups or nil data.villain_defeated = self.villain_defeated or nil data.retreating = self.retreating or nil if self.command == "BanterIdle" then data.command = "Idle" end data.command_specific_params = next(self.command_specific_params) and self.command_specific_params or nil data.last_attack_session_id = self.last_attack_session_id or nil data.banters_played_lines = self.banters_played_lines or nil data.free_move_ap = self.free_move_ap ~= 0 and self.free_move_ap or nil data.neutral_ai_dont_move = self.neutral_ai_dont_move or nil data.enemy_visual_contact = self.enemy_visual_contact or nil if next(self.effect_values) then local effect_values = {} for k, v in pairs(self.effect_values) do effect_values[k] = v or nil end data.effect_values = next(effect_values) and effect_values or nil end data.is_melee_aim_last_turn = self.is_melee_aim_last_turn or nil data.performed_action_this_turn = self.performed_action_this_turn or nil data.carry_flare = self.carry_flare or nil for i, unit in ipairs(self.attacked_this_turn) do data.attacked_this_turn = data.attacked_this_turn or {} data.attacked_this_turn[i] = unit.handle end for i, unit in ipairs(self.hit_this_turn) do data.hit_this_turn = data.hit_this_turn or {} data.hit_this_turn[i] = unit.handle end for unit, _ in pairs(self.seen_bodies) do data.seen_bodies = data.seen_bodies or {} data.seen_bodies[unit:GetHandle()] = true end data.visit_test = self.visit_test or nil data.wounded_this_turn = self.wounded_this_turn or nil data.downed_check_penalty = self.downed_check_penalty ~= 0 and self.downed_check_penalty or nil data.last_known_enemy_pos = self.last_known_enemy_pos or nil data.last_turn_damaged = self.last_turn_damaged or nil if self.target_dummy then data.pos = self.target_dummy:GetPos() data.vpos = nil data.vpos_time = nil elseif self.traverse_tunnel then data.pos = self.traverse_tunnel.end_point data.vpos = nil data.vpos_time = nil end if self.behavior then data.behavior = self.behavior data.behavior_params = StoreBehaviorParamTbl(self.behavior_params) if data.behavior == "ExitMap" and data.behavior_params[2] then data.behavior_params[2] = Max(0, data.behavior_params[2] - GameTime()) end end if self.combat_behavior then data.combat_behavior = self.combat_behavior data.combat_behavior_params = StoreBehaviorParamTbl(self.combat_behavior_params) end if self.marked_target_attack_args then data.marked_target_attack_args = StoreBehaviorParamTbl(self.marked_target_attack_args) end if self.spawner then data.spawner = self.spawner.handle end if IsValid(self.prepared_bombard_zone) then data.prepared_bombard_zone = self.prepared_bombard_zone.handle end if next(self.banters) then data.banters = self.banters data.sequential_banter = self.sequential_banter or nil end if next(self.approach_banters) then data.approach_banters = self.approach_banters data.approach_banters_distance = self.approach_banters_distance or nil data.approach_banters_cooldown_id = self.approach_banters_cooldown_id or nil end data.die_anim_prefix = self.die_anim_prefix or nil data.aware = self:IsAware() or nil data.pending_aware_state = self.pending_aware_state or nil data.suspicion = (self.suspicion or 0) > 0 and self.suspicion or nil data.suspicious_body_seen = self.suspicious_body_seen or nil if IsValid(self.alerted_by_enemy) then data.alerted_by_enemy = self.alerted_by_enemy.handle end data.routine = (self.routine ~= class.routine) and self.routine or nil data.routine_area = (self.routine_area ~= class.routine_area) and self.routine_area or nil data.routine_spawner = self.routine_spawner and self.routine_spawner.handle or nil data.zone = self.zone and self.zone.handle or nil data.ephemeral = self.ephemeral or nil data.perpetual_marker = self.perpetual_marker and self.perpetual_marker.handle or nil data.teleport_allowed_once = self.teleport_allowed_once or nil data.conflict_ignore =self.conflict_ignore or nil data.visit_command = self.visit_command or nil data.visit_reached = self.visit_reached or nil data.max_dead_slot_tiles = self.max_dead_slot_tiles or nil if self.visit_marker then data.visit_marker = self.visit_marker.handle end data.cower_forbidden = self.cower_forbidden or nil if self.cower_from then data.cower_from = self.cower_from data.cower_angle = self.cower_angle or nil end data.cower_cooldown = self.cower_cooldown or nil data.last_roam = self.last_roam and self.last_roam.handle or nil data.last_visit = self.last_visit and self.last_visit.handle or nil data.dead_markers_tried = self.dead_markers_tried or nil data.mourn = self.mourn and self.mourn.handle or nil data.maraud = self.maraud and self.maraud.handle or nil data.enter_map_wait_time = self.enter_map_wait_time and (self.enter_map_wait_time - GameTime()) or nil data.enter_map_pos = self.enter_map_pos or nil data.last_attack_pos = self.last_attack_pos or nil local unit_data = UnitDataDefs[self.unitdatadef_id] if self.Name ~= (unit_data and unit_data.Name) then data.Name = self.Name end data.default_move_style = self.default_move_style or nil data.cur_move_style = self.cur_move_style or nil data.cur_idle_style = self.cur_idle_style or nil data.on_die_hit_descr = self.on_die_hit_descr or nil data.death_explosion_played = self.death_explosion_played or nil data.infected = self.infected or nil data.innerInfoRevealed = self.innerInfoRevealed or nil data.throw_died_message = self.throw_died_message or nil data.lastFiringMode = self.lastFiringMode or nil data.queued_action_id = self.queued_action_id or nil end function Unit:SetDynamicData(data) self.session_id = data.session_id self.unitdatadef_id = data.unitdatadef_id or data.template_name self:UpdateFXClass() -- IsMerc() depends on .unitdatadef_id, e.g. ImportantUnit self.spawner = HandleToObject[data.spawner] self.target_dummy = HandleToObject[data.target_dummy] -- these are needed to be restored before calling EnterMap command of AL self.zone = HandleToObject[data.zone] or false for _, group_name in ipairs(data.Groups) do self:AddToGroup(group_name) end self.enter_map_wait_time = data.enter_map_wait_time or false self.enter_map_pos = data.enter_map_pos or false self.last_attack_pos = data.last_attack_pos or false if data.combat_behavior then RestoreBehaviorParamTbl(data.combat_behavior_params) self:SetCombatBehavior(data.combat_behavior, data.combat_behavior_params) end if data.behavior then RestoreBehaviorParamTbl(data.behavior_params) if data.behavior == "OverwatchAction" or data.behavior == "PinDown" then self:SetCombatBehavior(data.behavior, data.behavior_params) self:SetBehavior(data.behavior, data.behavior_params) elseif data.behavior == "Dead" or data.behavior == "VillainDefeat" or data.behavior == "Hang" then self:SetCombatBehavior(data.behavior, data.behavior_params) self:SetBehavior(data.behavior, data.behavior_params) elseif data.behavior == "ExitMap" then if data.behavior_params[2] then data.behavior_params[2] = GameTime() + data.behavior_params[2] end self:SetBehavior(data.behavior, data.behavior_params) else self:SetBehavior(data.behavior, data.behavior_params) end end if data.marked_target_attack_args then self.marked_target_attack_args = RestoreBehaviorParamTbl(data.marked_target_attack_args) end -- this needs to be after restoring self.spawner - long chain of invocations that ultimately results in a neutral unit if self.enter_map_wait_time and not IsKindOf(self.zone, "AmbientZoneMarker") then -- NOTE: old save with new map source for which unit's spawner zone has been deleted from the map DoneObject(self) return end self.last_roam = HandleToObject[data.last_roam] or false self.last_visit = HandleToObject[data.last_visit] or false self.perpetual_marker = HandleToObject[data.perpetual_marker] or false -- needed to restore Visit cmd self.visit_reached = data.visit_reached or false self:FillMerc() self.stance = self.species ~= "Human" and "" or data.stance -- Weird save game compat if self.stance and type(self.stance) == "boolean" then self.stance = self.species ~= "Human" and "" or "Standing" end self.return_pos = data.return_pos self.current_weapon = data.current_weapon self.perks_activated = data.perks_activated or {} self.villain_defeated = data.villain_defeated self.retreating = data.retreating self.command_specific_params = data.command_specific_params self.last_attack_session_id = data.last_attack_session_id self.free_move_ap = data.free_move_ap self.neutral_ai_dont_move = data.neutral_ai_dont_move self.effect_values = table.copy(data.effect_values or data.effect_expirations) -- savegame compat support (effect_expirations) self.is_melee_aim_last_turn = data.is_melee_aim_last_turn or false self.enemy_visual_contact = data.enemy_visual_contact self.suspicion = data.suspicion self.suspicious_body_seen = data.suspicious_body_seen self.pending_aware_state = data.pending_aware_state if self.pending_aware_state == "reposition" then self.pending_aware_state = nil end self.max_dead_slot_tiles = data.max_dead_slot_tiles or false if data.aware then self:RemoveStatusEffect("Unaware") self:RemoveStatusEffect("Suspicious") end self.alerted_by_enemy = HandleToObject[data.alerted_by_enemy] self.last_known_enemy_pos = data.last_known_enemy_pos self.last_turn_damaged = data.last_turn_damaged self.performed_action_this_turn = data.performed_action_this_turn if data.carry_flare then self:RoamAttachFlare() end self.attacked_this_turn = {} for i, handle in ipairs(data.attacked_this_turn) do self.attacked_this_turn[i] = HandleToObject[handle] end self.hit_this_turn = {} for i, handle in ipairs(data.hit_this_turn) do self.hit_this_turn[i] = HandleToObject[handle] end self.seen_bodies = {} for handle, _ in pairs(data.seen_bodies) do local unit = HandleToObject[handle] or false self.seen_bodies[unit] = true end self.visit_test = data.visit_test or false self.wounded_this_turn = data.wounded_this_turn self.downed_check_penalty = data.downed_check_penalty if data.prepared_bombard_zone then self.prepared_bombard_zone = HandleToObject[data.prepared_bombard_zone] end self.die_anim_prefix = data.die_anim_prefix self:UpdateMoveAnim() self:OnGearChanged("isLoad") -- shuffle looping animations if self:IsAnimLooping(1) then local phase = self:Random(GetAnimDuration(self:GetEntity(), self:GetAnim(1))) self:SetAnimPhase(1, phase) end self.banters = data.banters self.sequential_banter = data.sequential_banter or false self.banters_played_lines = data.banters_played_lines or false self.approach_banters = data.approach_banters self.approach_banters_distance = data.approach_banters_distance self.approach_banters_cooldown_id = data.approach_banters_cooldown_id self.routine = data.routine or "StandStill" self.routine_area = data.routine_area or "self" self.routine_spawner = HandleToObject[data.routine_spawner] or false self.ephemeral = data.ephemeral or false self.teleport_allowed_once = data.teleport_allowed_once or false self.conflict_ignore = data.conflict_ignore or false self.visit_command = data.visit_command or false self.visit_marker = HandleToObject[data.visit_marker] or false self.cower_forbidden = data.cower_forbidden or false self.cower_from = data.cower_from or false self.cower_angle = data.cower_angle or false self.cower_cooldown = data.cower_cooldown or false self.dead_markers_tried = data.dead_markers_tried or false self.mourn = HandleToObject[data.mourn] or false self.maraud = HandleToObject[data.maraud] or false if data.Name then self.Name = data.Name end if data.ground_orient then self:SetFootPlant(true, 0) else self:SetAxis(axis_z) end self.default_move_style = data.default_move_style or false self.cur_move_style = data.cur_move_style or false self.cur_idle_style = data.cur_idle_style or false self.on_die_hit_descr = data.on_die_hit_descr or false if data.death_explosion_played then self.death_explosion_played = data.death_explosion_played self:PlaceDeathFXObject(true) end self.infected = data.infected or false self.innerInfoRevealed = data.innerInfoRevealed or false if self.command == "Idle" and self:IsValidPos() then local x, y, z = GetPassSlabXYZ(self) if not x or not CanDestlock(self, x, y, z) then local has_path, pos = pf.HasPosPath(self, self) if pos then self:SetPos(GetPassSlab(pos)) end end end self.throw_died_message = data.throw_died_message or false self.lastFiringMode = data.lastFiringMode or false self.queued_action_id = data.queued_action_id or false if data.spawner and not self.spawner then self:SetCommand("Despawn") -- the spawner is not present in the new map version end end function OnMsg.UnitCreated(self) if self.unitdatadef_id and next(UnitDataDefs[self.unitdatadef_id].AdditionalGroups) then self:AddAdditionalGroups() end end function Unit:SetQueuedAction(id) self.queued_action_id = id or false if self.ui_badge then self.ui_badge:UpdateEnemyVisibility() end if not self.queued_action_id then if IsValid(self.queued_action_visual) then DoneObject(self.queued_action_visual) self.queued_action_visual = false end self.queued_action_pos = false end end function Unit:AddAdditionalGroups() if next(self.additional_groups) then for _, group in ipairs(self.additional_groups) do self:AddToGroup(group) end else self.additional_groups = {} local exclusiveTable = {} for _, group in ipairs(UnitDataDefs[self.unitdatadef_id].AdditionalGroups) do if group.Exclusive then table.insert(exclusiveTable, {Name = group.Name, Weight = group.Weight}) else local roll = InteractionRand(100, "BanterGroup") if roll < group.Weight then self:AddToGroup(group.Name) table.insert(self.additional_groups, group.Name) end end end if next(exclusiveTable) then local exclusiveGroup = table.weighted_rand(exclusiveTable, "Weight", InteractionRand(1000000, "BanterGroup")) self:AddToGroup(exclusiveGroup.Name) table.insert(self.additional_groups, exclusiveGroup.Name) end end end function Unit:Init() -- copy base values from material def if self.unitdatadef_id then self:AddToGroup(self.unitdatadef_id) end if not self.command_specific_params then self.command_specific_params = {} if self.spawner then local params = { weapon_anim_prefix = not self.spawner.use_weapons and "civ_" or nil, } params.idle_stance = self.spawner.idle_stance ~= g_StanceActionDefault and self.spawner.idle_stance or nil params.idle_action = self.spawner.idle_action ~= g_StanceActionDefault and self.spawner.idle_action or nil self:SetCommandParams("Idle", params) self:SetCommandParams("Visit", params) self:SetCommandParams("Roam", params) self:SetCommandParams("RoamSingle", params) end end if self.session_id then self:InitMerc() self:UpdateOutfit() self.retreating = nil -- spawning anew resets this status if self.villain then self:AddToGroup("Villains") end end self.perks_activated = {} self.seen_bodies = {} self.stain_update_times = {} self.AIKeywords = table.copy(self.AIKeywords) if IsMerc(self) then self.fx_actor_class = "ImportantUnit" end Msg("UnitCreated", self) end function Unit:InitFromMaterialPreset(preset) end function Unit:Done() self:TunnelsUnblock() self:RemoveALDeadMarkers() if self.team then table.remove_value(self.team.units, self) if not self.team.neutral then for team, list in pairs(g_RevealedUnits) do table.remove_value(list, self) end end end table.remove_value(g_Units, self) if self.session_id and g_Units[self.session_id] == self then g_Units[self.session_id] = nil end if self.unarmed_weapon then self.unarmed_weapon = nil end if self.bombard_weapon then DoneObject(self.bombard_weapon) end SelectionRemove(self) SetCombatActionState(self, nil) DeleteBadgesFromTarget(self) DeleteThread(self.pain_thread) DeleteThread(self.update_attached_weapons_thread) DeleteThread(self.setik_thread) if self:IsSyncObject() then Msg("UnitDespawned", self) end self:FreeVisitable() if self.perpetual_marker then self.perpetual_marker.perpetual_unit = false end self:SetTargetDummy(false) InvalidateUnitLOS(self) local idx = g_AIExecutionController and next(g_AIExecutionController.group_to_follow) and table.find(g_AIExecutionController.group_to_follow, "session_id", self.session_id) if idx then table.remove(g_AIExecutionController.group_to_follow, idx) end end function Unit:SetBehavior(behavior, params) self.behavior = behavior self.behavior_params = params if self.carry_flare and behavior and behavior ~= "Roam" and behavior ~= "RoamSingle" then self:RoamDropFlare() end end function Unit:SetCombatBehavior(behavior, params) self.combat_behavior = behavior self.combat_behavior_params = params end function Unit:ClearBehaviors(behavior) if self.behavior == behavior then self:SetBehavior() end if self.combat_behavior == behavior then self:SetCombatBehavior() end end function CopyPropertiesShallow(to,source, properties, copy_values) assert(IsKindOf(to, "Modifiable") and IsKindOf(source, "Modifiable")) local mods = source.modifications or empty_table for i = 1, #properties do local prop = properties[i] if prop_eval(prop.dont_save, source, prop) then goto continue end local prop_id = prop.id if prop.modifiable then to:SetBase(prop_id, source["base_" .. prop_id]) else local value = source:GetProperty(prop_id) local source_value_is_dest_default = value == nil or value == to:GetDefaultPropertyValue(prop_id, prop) local to_value = to:GetProperty(prop_id) local dest_value_is_default = to_value == nil or to_value == value local is_default = source_value_is_dest_default and dest_value_is_default if is_default then goto continue end if copy_values and type(to_value) == "table" then table.clear(to_value) local copy = table.copy(value) for key, val in pairs(copy) do if IsKindOf(val, "PropertyObject") then to_value[key] = val:Clone() else to_value[key] = val end end else to:SetProperty(prop_id, value) end end ::continue:: end end function Unit:InitMerc() g_UnarmedWeapon = g_UnarmedWeapon or PlaceInventoryItem("Unarmed") local unitData = gv_UnitData[self.session_id] if unitData.species ~= "Human" then self.stance = "" end self.fallback_body = unitData.gender or self.fallback_body self:SyncWithSession("session") self:InitMercWeaponsAndActions() self:OnGearChanged() ObjModified(self) end function Unit:InitMercWeaponsAndActions() self:ReloadAllEquipedWeapons() self:RecalcUIActions() self:SetCommand("Idle") end function Unit:GetSide(reset_teams) local side = not reset_teams and self.team and self.team.side or false -- If the unit is not a part of any team, decide his team based on his campaign side. assert(self.session_id) -- all units should be represented in gv_UnitData local unit_data = self.session_id and gv_UnitData[self.session_id] if unit_data then if unit_data.Squad and gv_Squads[unit_data.Squad] then side = side or gv_Squads[unit_data.Squad].Side elseif unit_data.CurrentSide ~= "" then -- The unit could've changed sides side = side or unit_data.CurrentSide end end if not side and self.spawner then side = self.spawner.Side end -- Ephemeral units side = side or "neutral" return side end function Unit:FillMerc() local ud = gv_UnitData[self.session_id] if ud then ud:StatusEffectsCleanUp() end self:SyncWithSession("session") self:UpdateOutfit() self:OnGearChanged("isLoad") CombatPathReset(self) self:UpdateStatusEffectIndex() self:UpdateSignatureRecharges() if self.enter_map_wait_time then self.zone:InitUnit(self) self:SetCommand("EnterMap", self.zone, self.enter_map_pos, self.enter_map_wait_time) elseif self.behavior == "Hang" then self:SetCommand("Hang") elseif self.behavior == "Visit" then local marker = self.behavior_params[1] and self.behavior_params[1][1] if marker then local new_visitable = marker:GetVisitable() new_visitable.reserved = self.handle self.behavior_params[1] = new_visitable if marker.tool_attached then -- Tool is not a permanent object so it needs to be respawned if IsKindOf(marker, "AL_Carry") then self:SetState(marker.VisitIdle) end marker:SpawnTool(self) end if not (marker and marker:CanVisit(self, "for perpetual")) then self:ResetAmbientLife() elseif self.visit_reached then -- walking to the marker can fail do to PF issues self:SetCommand("Visit", new_visitable, self.perpetual_marker) elseif IsKindOf(marker, "AL_Carry") then self:SetCommand("Visit", new_visitable, marker, "already in perpetual") else self:SetBehavior() self:SetCommand("Idle") end else -- persisted marker is deleted from the map self:SetBehavior() self:SetCommand("Idle") end else assert(self:IsValidPos()) if self:IsValidPos() then self:SetCommand("Idle") end end ObjModified(self) end function Unit:SyncWithSession(source) -- sync map objects with session data if not self.session_id then -- early out for setpiece testing, done with Unit clones with no session_id return end local unit_data = self.session_id and gv_UnitData[self.session_id] if not unit_data then assert(false, string.format("All units should have corresponding unit data: session_id (%s), template name (%s)", self.session_id or "", self.unitdatadef_id or "")) return end local from, to if source == "map" then from = self to = unit_data elseif source == "session" then from = unit_data to = self end -- we need to remove reaction handlers and then apply them anew, as this can potentially change the list of status effect in the target object to:UnregisterReactions() CopyPropertiesShallow(to, from, UnitProperties:GetProperties()) CopyPropertiesShallow(to, from, UnitInventory:GetProperties()) -- StatusEffects table reference shouldn't be changed as it is observed by various UI at any given point. CopyPropertiesShallow(to, from, StatusEffectObject:GetProperties(), "copy_values") to:RegisterReactions() to:ApplyModifiersList(self.applied_modifiers) if source == "session" then to:OnSetActiveWeapon() to.AIKeywords = table.copy(to.AIKeywords) end if (not g_Combat and self.behavior == "Dead") or (g_Combat and self.combat_behavior == "Dead") then self.HitPoints = 0 end ObjModified(from) ObjModified(to) ObjModified(to.StatusEffects) ObjModified(from.StatusEffects) end function NetSyncEvents.SyncUnitProperties(source) SyncUnitProperties(source) end function SyncUnitProperties(source) if not GameState.entered_sector then return end for _, unit in ipairs(g_Units) do unit:SyncWithSession(source) end Msg("UnitPropertiesSynced") end function OnMsg.LoadSessionData() SyncUnitProperties("session") -- apply modifiers on load for units that are not on current map and have no g_Unit --(syncwith session apply the modifiers for g_Units) for session_id, unit in pairs(gv_UnitData) do if not g_Units[session_id] then unit:ApplyModifiersList(unit.applied_modifiers) if unit.HireStatus and unit.HireStatus == "Hired" then unit:RegisterReactions() end end end end function OnMsg.GatherSessionData() if not gv_SatelliteView then -- in satellite view units session data is updated SyncUnitProperties("map") end end local function restore_default_props(unit, data, properties) for id, prop in ipairs(properties) do local id = prop.id local editor = prop_eval(prop.editor, unit, prop) if editor and not prop_eval(prop.dont_save, unit, prop) then local value, default = unit:GetProperty(id), data:GetProperty(id) if value ~= default and (type(value) == "function" or type(value) == "table" and data:IsDefaultPropertyValue(id, prop, value)) then unit:SetProperty(id, default) end end end end -- On Lua reload, put the defaults from UnitData classes back into Unit instances -- (important for function values, so they don't get saved) function OnMsg.Autorun() for _, unit in ipairs(g_Units) do local unit_data = unit.session_id and gv_UnitData[unit.session_id] if unit_data then restore_default_props(unit, unit_data, UnitProperties:GetProperties()) end end end function Unit:GetSatelliteSquad() local squad if self:IsDead() then squad = self.Squad else local unitData = gv_UnitData[self.session_id] squad = unitData and unitData.Squad end return gv_Squads and gv_Squads[squad] or false end function Unit:GameInit() local side = self.team and self.team.side if CheatEnabled("GodMode", side) then self:GodMode("god_mode", true) end if CheatEnabled("InfiniteAP", side) then self:GodMode("infinite_ap", true) end if CheatEnabled("OneHpEnemies") and (side == "enemy1" or side == "enemy2") then self.HitPoints = 1 end if self:HasStatusEffect("ManningEmplacement") then local handle = self:GetEffectValue("hmg_emplacement") local obj = HandleToObject[handle] if obj then self:EnterEmplacement(obj, true) -- instant take the right position end end self:UpdateBandageConsistency() self:SetContourOuterOccludeRecursive(true) self:UpdateGroundOrientParams() if not self:IsDead() then self:SetTargetDummyFromPos() end end function Unit:GetSyncedAnim() if self.synced_anim then return self.synced_anim, GameTime() - self.synced_anim_time end return self:GetStateText(), self:GetAnimPhase() end function Unit:GetStaticSpotPos(spot) local obj = self.target_dummy or self if type(spot) == "string" then local first, last = obj:GetSpotRange(obj:GetAnim(), spot) if first < 0 or last < 0 then return obj:GetRelativePoint(0, 0, guim) end spot = first end return obj:GetSpotLocPos(obj:GetAnim(), obj:GetAnimPhase(), spot) end function Unit:TriggerAction(cmd, ...) --[[if not g_Combat and cmd == "ChangeStance" then return self:InterruptCommand(cmd, ...) end--]] self:QueueCommand(cmd, ...) end function Unit:ReviveOnHealth(hp) self.HitPoints = hp or self.MaxHitPoints self:RemoveStatusEffect("Bleeding") self:NetUpdateHash("Revive", self.HitPoints) self:ClearHierarchyGameFlags(const.gofOnRoof) if self.behavior == "Dead" or self.behavior == "VillainDefeat" then self:SetBehavior() self:SetCombatBehavior() end InvalidateDiplomacy() self:FlushCombatCache() ObjModified(self) end function Unit:DropLoot(container) local is_npc = self:IsNPC() -- not a merc local debugText = _InternalTranslate(self.Name) .. " dropping loot: (roll must be lower)" -- Locked items never drop. -- Go over the equipped items, drop them to "Inventory" based on their drop chance, -- Equipped items from Mercs always drop(except locked items). Otherwise check the drop chance. local droped_items = 0 self:ForEachItem(function(item, slot_name, left, top, self, container, is_npc) if slot_name == "InventoryDead" then return end self:RemoveItem(slot_name, item) local dropped local roll = self:Random(100) local slot = container and "Inventory" or "InventoryDead" debugText = debugText .. "\n " .. _InternalTranslate(item.DisplayName) .. ": roll " .. roll .. "/" .. item.drop_chance .. "% chance" if not item.locked and (not is_npc or roll < item.drop_chance) then if IsGameRuleActive("AmmoScarcity") and is_npc and IsKindOf(item, "InventoryStack") and IsKindOfClasses(item,{"Ammo", "Ordnance", "Grenade", "ThrowableTrapItem", "Flare"}) then local percent = GameRuleDefs.AmmoScarcity:ResolveValue("LootDecrease") item.Amount = Max(1,item.Amount - MulDivRound(item.Amount, percent, 100)) end local addTo = container or self local pos, err = addTo:CanAddItem(slot, item) assert(pos, "Couldn't FIND pos in Inventory to place dropped item. Err: '" .. err .. "'") if pos then dropped, err = addTo:AddItem(slot, item, point_unpack(pos)) assert(dropped, "Couldn't PLACE dropped item in Inventory. Err: '" .. err .. "'") end end if not dropped then DoneObject(item) elseif slot == "InventoryDead" then droped_items = droped_items + (item:IsLargeItem() and 2 or 1) end end, self, container, is_npc) if droped_items > 0 then self.max_dead_slot_tiles = droped_items end CombatLog("debug", debugText) end function OnMsg.UnitDiedOnSector(unit, sector_id) local sector = gv_Sectors[sector_id] sector.dead_units = sector.dead_units or {} table.insert(sector.dead_units, unit.session_id) end function Unit:DropAllItemsInAContainer(fall_pos) if not self:GetItem() then return end local container = GetDropContainer(self,fall_pos) self:ForEachItem(function(item, slot) self:RemoveItem(slot, item) if not container:AddItem("Inventory", item) then -- Fallback for too many items container = PlaceObject("ItemDropContainer") local drop_pos = terrain.FindPassable(container, 0, const.SlabSizeX/2) container:SetPos(drop_pos or self:GetPos()) container:SetAngle(container:Random(360*60)) container:AddItem("Inventory", item) end end) return container end function Unit:ShouldGetDowned(hit_descr) if not self:IsMerc() or not self.team or not self.team.player_team or (hit_descr and hit_descr.was_downed) then return false end if self.team and self.team:IsDefeated() then return false end if IsGameRuleActive("LethalWeapons") then return false end if IsGameRuleActive("ForgivingMode") then return true end if hit_descr then local value = GameDifficulties[Game.game_difficulty]:ResolveValue("InstantDeathHp") or -50 if hit_descr.prev_hit_points - hit_descr.raw_damage <= value then return false end end if g_Combat then return not self:IsDowned() end return hit_descr and hit_descr.prev_hit_points > 1 end function Unit:OnDie(attacker, hit_descr) CombatActionInterruped(self) RemoveFloatingTextsFrom(self, "DamageFloatingText") self.on_die_attacker = IsKindOf(attacker, "Trap") and attacker.attacker or attacker self.on_die_hit_descr = table.copy(hit_descr) self.on_die_hit_descr.armor_decay = nil self.on_die_hit_descr.armor_pen = nil if self:ShouldGetDowned(hit_descr) then hit_descr.explosion_fly = nil -- never do this when downing self.HitPoints = 1 -- make sure the unit is not considered dead and evicted from the UI local value = GameDifficulties[Game.game_difficulty]:ResolveValue("DownedTempHp") or 30 if g_Combat then self:ApplyTempHitPoints(value) end --count downed units for tacticalsituation vr if attacker and IsKindOf(attacker, "Unit") and attacker.team.side ~= self.team.side then self.team.tactical_situations_vr.downedUnits = self.team.tactical_situations_vr.downedUnits and self.team.tactical_situations_vr.downedUnits attacker.team.tactical_situations_vr.downedUnitsByTeam = attacker.team.tactical_situations_vr.downedUnitsByTeam and attacker.team.tactical_situations_vr.downedUnitsByTeam + 1 or 1 PlayVoiceResponseTacticalSituation(table.find(g_Teams, attacker.team), "now") end self:SetCommand("GetDowned") else --printf("%s dies", _InternalTranslate(self.Name or "")) self.on_die_hit_descr = self.on_die_hit_descr or {} -- Roam is considered visiting but doesn't have a last_visit if self:IsVisiting() and self.last_visit and self.visit_reached then self.on_die_hit_descr.die_pos = self.last_visit:GetPos() end if string.match(self.session_id, "ClonedFootballPartner") then self.SetCommand = Unit.SetCommand self.zone.player_killed = true end if IsKindOf(self.last_visit, "AL_Football") then self.last_visit.player_killed = true end self:SetCommand("Die") end end function Unit:SetTired(value) UnitProperties.SetTired(self, value) if value==3 then self:SetCommand("GetDowned", "tired") end end function Unit:IsGettingDowned() return self.command == "GetDowned" end function Unit:IsDowned() return self.command == "Downed" or self.combat_behavior == "Downed" end function Unit:IsIncapacitated() return self:IsDead() or self:IsDowned() or self:IsGettingDowned() or self.command == "Die" end function Unit:CanPassThroughInCombat() return self:GetEnumFlags(const.efResting) == 0 or self:IsDowned() end function Unit:CanContinueCombat() local isDead = self.command == "Die" or self:IsDead() local isDowned = self:IsDowned() local isTempUncontrollable = (self:HasStatusEffect("Unconscious") or self:HasStatusEffect("Stabilized")) and (not self:HasStatusEffect("Downed") or not self:HasStatusEffect("BleedingOut")) return not isDead and (not isDowned or isTempUncontrollable) end MapVar("g_NextUnitThread", false) MapVar("g_LastUnitToShoot", false) function Unit:GetDowned(tired, skip_anim) if not tired then self.HitPoints = 1 -- restore hp so Downed can be applied --printf("%s is downed", _InternalTranslate(self.Name or "")) end self.ActionPoints = 0 self.stance = self:GetValidStance("Prone") self:InterruptPreparedAttack() self:RemovePreparedAttackVisuals() self:RemoveEnemyPindown() self:AlignOnDeath() self:RemoveStatusEffect("FreeMove") self:RemoveStatusEffect("Bleeding") self:RemoveStatusEffect("BandageInCombat") self:RemoveStatusEffect("StationedMachineGun") self:AddWounds(1) CombatActionInterruped(self) ObjModified(self) self:ClearPath() self:SetTargetDummyFromPos() if not tired then PlayVoiceResponse(self, "Downed") end SetCombatActionState(self, nil) local dlg = GetInGameInterfaceModeDlg() if IsKindOf(dlg, "IModeCommonUnitControl") and Selection and table.find(Selection, self) then dlg:NextUnit() end if IsMerc(self) then PlayFX("MercDowned", "start") end self.combat_behavior = "GetDowned" CheckGameOver() local base_idle = self:TryGetActionAnim("Idle", "Downed") if not (base_idle and IsAnimVariant(self:GetStateText(), base_idle)) then local pos = self:GetPos() if ShouldDoDestructionPass() then WaitMsg("DestructionPassDone", 1000) --wait for destro if slabs beneath us get destroyed, so we can get a falldown point end local x, y, z = FindFallDownPos(pos) if x and not pos:Equal(x, y, z) then self:FallDown(point(x, y, z)) pos = self:GetPos() end local angle = self:GetOrientationAngle() self:SetPos(pos) if not skip_anim then local base_anim = self:GetActionBaseAnim("Downed", self.stance) local anim = self:GetStateText() if IsAnimVariant(anim, base_anim) then Sleep(self:TimeToAnimEnd()) else anim = self:GetNearbyUniqueRandomAnim(base_anim) self:MovePlayAnim(anim, pos, pos, 0, nil, true, angle) end end if base_idle then if not IsAnimVariant(self:GetStateText(), base_idle) then local anim = self:GetNearbyUniqueRandomAnim(base_idle) self:SetState(anim) end else -- missing downed idle animation support local base_anim = self:GetActionBaseAnim("Downed", self.stance) local anim = self:GetStateText() if not IsAnimVariant(anim, base_anim) then anim = self:GetNearbyUniqueRandomAnim(base_anim) end local duration = GetAnimDuration(self:GetEntity(), anim) self:SetState(anim, 0, 0) self:SetAnimPhase(1, duration - 1) end self:SetGroundOrientation(angle, 0) end self.combat_behavior = false if not g_Combat and not self:IsDead() then Sleep(5000) self:SetCommand("DownedRally") return end if g_Combat and self == SelectedObj and not IsValidThread(g_NextUnitThread) then g_NextUnitThread = CreateMapRealTimeThread(function() -- Somebody could be panicking, during which units cannot be controlled. while g_AIExecutionController do WaitMsg("ExecutionControllerDeactivate", 50) end -- If the last unit is killed combat will be ended. if g_Combat then g_Combat:NextUnit(nil, "force") end end) end self.return_pos = nil if tired then self:AddStatusEffect("Unconscious") else MoraleModifierEvent("UnitDowned", self) self:AddStatusEffect("Downed") end end function Unit:Downed() Msg("UnitDowned", self) local base_idle = self:TryGetActionAnim("Idle", "Downed") if base_idle then if not IsAnimVariant(self:GetStateText(), base_idle) then local anim = self:GetNearbyUniqueRandomAnim(base_idle) self:SetState(anim) if GameState.sync_loading then self:SetAnimPhase(1, GetAnimDuration(self:GetEntity(), anim) - 1) end end else -- missing downed idle animation support local base_anim = self:GetActionBaseAnim("Downed", self.stance) local anim = self:GetStateText() if not IsAnimVariant(anim, base_anim) then anim = self:GetNearbyUniqueRandomAnim(base_anim) end local duration = GetAnimDuration(self:GetEntity(), anim) self:SetState(anim, 0, 0) self:SetAnimPhase(1, duration - 1) end self:SetFootPlant(true, 0) self:SetCombatBehavior("Downed") local target_dummy_phase = GetAnimDuration(self:GetEntity(), self:GetState()) self:SetTargetDummy(nil, nil, nil, target_dummy_phase) -- current position, animation phase 0 Halt() end local HeadshotHideParts = { "Head", "Hat", "Hat2", "Hair" } function Unit:SetHeadshot(value) self.Headshot = value if not self.parts or self.species ~= "Human" then return end for i, name in ipairs(HeadshotHideParts) do local part = self.parts[name] if part then if value then part:ClearEnumFlags(const.efVisible) else part:SetEnumFlags(const.efVisible) end end end if value then local headshot_entity if self.gender == "Male" then headshot_entity = "FX_HeadMale_Headshot" elseif self.gender == "Female" then headshot_entity = "FX_HeadFemale_Headshot" end if headshot_entity then local part = PlaceObject("AppearanceObjectPart") if self:GetGameFlags(const.gofRealTimeAnim) ~= 0 then part:SetGameFlags(const.gofRealTimeAnim) end part:ChangeEntity(headshot_entity) self:Attach(part, self.parts.Head:GetAttachSpot()) self.parts.Headshot = part end else DoneObject(self.parts.Headshot) self.parts.Headshot = nil end end function Unit:AlignOnDeath(dont_snap) local pos = self:GetVoxelSnapPos() if pos and self:GetDist(pos) > 0 and (not (self.on_die_hit_descr and self.on_die_hit_descr.die_pos)) then if not dont_snap then self:SetPos(pos) end -- make sure the unit dies on place in this case self.on_die_attacker = nil self.on_die_hit_descr = nil end end function Unit:Die(skip_anim) local attacker = self.on_die_attacker local hit_descr = self.on_die_hit_descr or {} local target_spot_group = hit_descr.spot_group local headshot = target_spot_group == "Head" local attack_action_id = attacker and CombatActions_LastStartedAction and CombatActions_LastStartedAction.unit == attacker and CombatActions_LastStartedAction.action_id local zoom_in = (self:IsLocalPlayerControlled() or headshot or attack_action_id == "KnifeThrow") and attacker and CurrentActionCamera and CurrentActionCamera[1] ~= self and CurrentActionCamera[2] == self and not self.villain local results = {} self:AlignOnDeath("don't snap in voxel") self:RoamDropFlare() if not skip_anim then if zoom_in then ZoomActionCamera() end -- Action camera should wait for this to be over before closing. if CurrentActionCamera then CurrentActionCamera.wait_signal = true end end if self.reincarnate then self:PlayDying() self:ReviveOnHealth() self:SetBehavior() self:SetCombatBehavior() self:SetCommand("Idle") return elseif self.immortal then self:ReviveOnHealth() self:AddStatusEffect("Unconscious") return end self.throw_died_message = "all" self.HitPoints = 0 -- needs to be before RemoveAllStatusEffects so that Wounded can detect the death and leave any blood stains on self:RemoveAllStatusEffects("death") if self.villain then local attackerMerc = attacker and IsMerc(attacker) if self.DefeatBehavior == "Defeated" then self:SetBehavior("VillainDefeat") self:SetCombatBehavior("VillainDefeat") self.villain_defeated = true self:SetCommand("VillainDefeat") elseif self.DefeatBehavior == "Dead" then if attackerMerc and SideIsEnemy(self.team.side, attacker.team.side) then PlayVoiceResponse(self, "DramaticDeath") end Msg("VillainDefeated", self, attacker) self.villain_defeated = true end end self.HitPoints = 0 -- in case statuses do some shenanigans I guess self.time_of_death = Game.CampaignTime self.pending_aware_state = nil self.killed_stance = self.stance Msg("UnitDieStart", self, attacker) Msg("UnitDiedOnSector", self, gv_CurrentSectorId) self.throw_died_message = "pre-sync" self:InterruptPreparedAttack() self:EndInterruptableMovement() CombatActionInterruped(self) for _, unit in ipairs(g_Units) do if unit:GetBandageTarget() == self then unit:SetCommand("EndCombatBandage") end end local stealth_kill = hit_descr.stealth_kill if not attacker then CombatLog("debug", T{Untranslated(" was killed"), name = self:GetLogName()}) end results.glory_kill = not self.immortal and not hit_descr.grazing and self:Random(100) < const.Combat.GloryKillChance local death_explosion = hit_descr.death_explosion if self:GetItemInSlot("Inventory", "Valuables") or self:GetItemInSlot("Inventory", "QuestItem") then death_explosion = false hit_descr.death_explosion = false end if death_explosion then PlayFX("DeathExplosion", "start", self, target_spot_group) elseif results.glory_kill and IsKindOf(hit_descr.weapon, "Firearm") and not self.immortal and headshot and self.species == "Human" and self.parts.Head then self:SetHeadshot(true) PlayFX("Death", "start", self, "Headshot") else PlayFX("Death", "start", self, target_spot_group) end if IsMerc(self) then PlayFX("MercDeath", "start", self) end self:SetPos(self:GetVisualPos()) self:SetHierarchyGameFlags(const.gofOnRoof) --hide with roofs and walls if too close to one self:ClearPath() self:ClearEnumFlags(const.efResting) self:SetTargetDummy(false) SetCombatActionState(self, nil) -- Remember the anim prefix when the unit died as it will drop its items self.die_anim_prefix = self:GetWeaponAnimPrefix() local container if not self.immortal then if death_explosion then container = GetDropContainer(self) container:SetVisible(false) end self:DropLoot(container) if container and not container:HasItem() then DoneObject(container) container = false end end self:SyncWithSession("map") self.throw_died_message = "after-start" if self.villain and self.DefeatBehavior == "Dead" then MoraleModifierEvent("LieutenantDefeated", self) else MoraleModifierEvent("UnitDied", self) if attacker and self.team.side ~= "neutral" and self.team.side ~= "player1" and self.team.side ~= "player2" and results.glory_kill then MoraleModifierEvent("SpectacularKill", attacker) end end -- alerts: noise (non-stealth only) and killed (always) local alerted, suspicious = PushUnitAlert("death", self) if not stealth_kill then local noise_alerted, noise_suspicious = PushUnitAlert("noise", self, const.Combat.DeathNoiseRange, Presets.NoiseTypes.Default.Pain.display_name) alerted, suspicious = alerted + noise_alerted, suspicious + noise_suspicious end if g_Combat and IsKindOf(attacker, "Unit") and not g_Combat:ShouldEndCombat() then if stealth_kill and alerted == 0 then PlayVoiceResponse(attacker, "OpponentKilledStealth") end end -- skip end combat check if the death made someone suspicious or alert -- note: a stealth kill attack wouldn't alert/raise suspicion in other units except through the "death" trigger local end_combat_check = g_Combat and suspicious + alerted == 0 and not g_AIExecutionController self:PushDestructor(function(self) self:RemovePreparedAttackVisuals() Msg("ActionCameraWaitSignalEnd") if container then container:SetVisible(true) end end) self:PushDestructor(function(self) assert(not self.command and not self:IsValid(), "Die command should not be interrupted (new command: " .. tostring(self.command) .. ").") end) -- wait a bit for possible destruction around or below the unit (bullets and explosions might destroy stuff after the unit) if not skip_anim then Sleep(100) self:StopPain() end self:PlayDying(skip_anim, end_combat_check) self:PopDestructor() self:PopAndCallDestructor() ObjModified(self) Msg("UnitDied", self, attacker, results) self.throw_died_message = false if IsValid(self) then self:BeginInterruptableMovement() self:SetCommand("Dead") end end local FX_Explosion_Variants = { "FX_Explosion_Human_01", "FX_Explosion_Human_02", "FX_Explosion_Human_03", } function Unit:PlaceDeathFXObject(quick_play, pos, angle) local x, y, z = FindFallDownPos(pos or self) if not x then if pos then x, y, z = pos:xyz() else x, y, z = self:GetPosXYZ() end end angle = angle or self:GetOrientationAngle() self:SetOpacity(0) local o = self.death_fx_object if not IsValid(o) then if not FX_Explosion_Variants[self.death_explosion_played] then self.death_explosion_played = 1 + self:Random(#FX_Explosion_Variants) end o = PlaceObject(FX_Explosion_Variants[self.death_explosion_played]) o:SetStateText("idle") self.death_fx_object = o end if quick_play then o:SetAnimPhase(1, GetAnimDuration(o:GetEntity(), o:GetAnim(1)) - 1) end local orient_time = (quick_play or not o:IsValidPos()) and 0 or 500 o:SetPos(x, y, z or const.InvalidZ, orient_time) -- fall down when destruction o:SetGroundOrientation(angle, orient_time, const.SlabSizeX * 40 / 100) -- one tile inclination end function Unit:PlayDying(quick_play, end_combat_check, anim, pos, angle, break_obj) local in_dead_anim local hit_descr = self.on_die_hit_descr local death_explosion = hit_descr and hit_descr.death_explosion or self.species == "Hen" local falldown_callback = hit_descr and hit_descr.falldown_callback and _G[hit_descr.falldown_callback] if death_explosion or string.match(self:GetStateText(), "Death") then in_dead_anim = true anim = self:GetStateText() end if not in_dead_anim and ShouldDoDestructionPass() then WaitMsg("DestructionPassDone", 1000) end if (not anim or not self:HasAnim(anim)) and not death_explosion then anim, pos, angle, break_obj = GetRandomDeathAnim(self, { attacker = self.on_die_attacker, hit_descr = hit_descr } ) end local x, y, z = FindFallDownPos(pos or self) if not pos or x and not pos:Equal(x, y, z) then pos = x and point(x, y, z) or self:GetPos() end angle = angle or self:GetOrientationAngle() local orient_time = quick_play and 0 or 500 self:DestroyAttaches("WeaponVisual") local behavior_params = { anim, angle } self:SetBehavior("Dead", behavior_params) self:SetCombatBehavior("Dead", behavior_params) if death_explosion then if self.death_explosion_played then quick_play = true end if not quick_play then Sleep(const.Combat.DeathExplosion_AnimationDelay) end self:SetBehavior("Despawn", behavior_params) self:SetCombatBehavior("Despawn", behavior_params) if self.species == "Hen" then local dlg = GetInGameInterfaceModeDlg() if IsKindOf(dlg, "IModeCombatAttackBase") and dlg:GetAttackTarget() == self then if g_Combat or g_StartingCombat then SetInGameInterfaceMode("IModeCombatMovement") else SetInGameInterfaceMode("IModeExploration") end end self:QueueCommand("Despawn") return end if IsValid(self.death_fx_object) then self:PlaceDeathFXObject(true, pos, angle) return end self:PlaceDeathFXObject(quick_play, pos, angle) if end_combat_check and g_Combat then g_Combat:EndCombatCheck() end if not quick_play and IsValid(self.death_fx_object) then Sleep(self.death_fx_object:TimeToAnimEnd()) end return end if in_dead_anim then -- unconscious if end_combat_check and g_Combat then g_Combat:EndCombatCheck() end if quick_play then self:SetAnimPhase(1, GetAnimDuration(self:GetEntity(), anim) - 1) else Sleep(self:TimeToAnimEnd()) end return end if quick_play then self:SetState(anim, 0, 0) local duration = GetAnimDuration(self:GetEntity(), anim) self:SetAnimPhase(1, duration - 1) self:SetPos(pos) self:SetFootPlant(true, 0) self:SetOrientationAngle(angle) if falldown_callback then falldown_callback(self.on_die_attacker, self, pos) end if IsValid(break_obj) and break_obj.pass_through_state == "intact" then break_obj:SetWindowState("broken") end if end_combat_check and g_Combat then g_Combat:EndCombatCheck() end return end local thread1, thread2 self:PushDestructor(function(self) DeleteThread(thread1) DeleteThread(thread2) if IsValid(self) then self:PlayDying(true, false, anim, pos, angle, break_obj) end end) if falldown_callback then local delay = self:GetAnimMoment(anim, "hit") or self:GetAnimMoment(anim, "end") or self:GetAnimDuration(anim) - 1 thread1 = CreateGameTimeThread(function(self, delay, pos, end_combat_check) Sleep(delay) local hit_descr = self.on_die_hit_descr local falldown_callback = hit_descr.falldown_callback and _G[hit_descr.falldown_callback] if falldown_callback then falldown_callback(self.on_die_attacker, self, pos) end if end_combat_check and g_Combat then g_Combat:EndCombatCheck() end end, self, delay, pos, end_combat_check) end if break_obj then local delay = self:GetAnimMoment(anim, "explosion") or 0 thread2 = CreateGameTimeThread(function(delay, obj, end_combat_check) Sleep(delay) if IsValid(obj) and obj.pass_through_state == "intact" then obj:SetWindowState("broken") end if end_combat_check and g_Combat then g_Combat:EndCombatCheck() end end, delay, break_obj, end_combat_check) else if end_combat_check and g_Combat then g_Combat:EndCombatCheck() end end self:MovePlayAnim(anim, self:GetPos(), pos, 0, nil, true, angle) self:PopDestructor() end Unit.IsDead = UnitProperties.IsDead function Unit:IsDefeatedVillain() return self.villain and self.villain_defeated end function Unit:RemoveEnemyPindown() if g_Combat then for attacker, descr in pairs(g_Pindown) do if descr.target == self then attacker:InterruptPreparedAttack() end end end end DefineClass.DecFXBlood_01 = {__parents = {"Object"}} function Unit:PlaceBlood() if self:HasPassedTimeAfterDeath(const.Satellite.RemoveBloodAfter) then --do not place blood if const.Satellite.RemoveBloodAfter has passed return end local blood_pos = self:GetSpotVisualPos(self:GetSpotBeginIndex("Blood")) blood_pos = blood_pos:SetZ(self:GetVisualPos():z()) local blood = PlaceObject("DecFXBlood_01") blood:SetGameFlags(const.gofDecalOpacityAlphaTest) blood:SetPos(blood_pos) blood:SetAngle(self:Random(360 * 60)) blood:SetOpacity(0) blood:SetOpacity(95, 4000 + self:Random(2000)) end function Unit:Dead(anim, angle) local behavior = g_Combat and self.combat_behavior or self.behavior if behavior == "Despawn" then return end -- Savegame fixup for 241898 -- Errored/save-load during death before loot is dropped if self:CountItemsInSlot("Inventory") ~= 0 and self:CountItemsInSlot("InventoryDead") == 0 then self.throw_died_message = "pre-sync" end -- Savegame fixup for 238866 -- Errored in RemoveStatusEffect BandageInCombat prior to moving throw_died_message above that call. if not self.time_of_death then self.throw_died_message = "all" end -- This can happen when a save is made while the unit was running the "Die" command. -- Upon load the unit will be dead (go straight here) without having thrown the message, -- which could mean that the conflict isn't resolved. if self.throw_died_message then if self.throw_died_message == true then -- legacy self.throw_died_message = "all" end local killer = self.on_die_attacker if self.throw_died_message == "all" then if self.villain then Msg("VillainDefeated", self, killer) end self.time_of_death = Game.CampaignTime Msg("UnitDieStart", self, killer) Msg("UnitDiedOnSector", self, gv_CurrentSectorId) self.throw_died_message = "pre-sync" end if self.throw_died_message == "pre-sync" then local container = GetDropContainer(self) if not self.immortal then self:DropLoot(container) end if container and not container:HasItem() then DoneObject(container) container = false end self:SyncWithSession("map") self.throw_died_message = "after-start" end Msg("UnitDied", self, killer, empty_table) self.throw_died_message = false end -- some Die command code is repeated here for the cases when the Die command was skipped (load a game) self:SetHierarchyGameFlags(const.gofOnRoof) --hide with roofs and walls if too close to one self:ClearPath() self:ClearEnumFlags(const.efResting) self:SetTargetDummy(false) SetCombatActionState(self, nil) self:RemoveEnemyPindown() if self.species == "Human" then self.stance = "Prone" end if not anim and self.behavior == "Dead" then anim, angle = table.unpack(self.behavior_params or empty_table) end self:PlayDying(true, false, anim, nil, angle) self:PlaceALDeadMarkers() Halt() end function Unit:Hang() SetCombatActionState(self, nil) self:SetBehavior("Hang") self:SetCombatBehavior("Hang") local ropes = MapGet("map", "World_HangingRope") or empty_table if #ropes ~= 1 then StoreErrorSource(point30, "There should be one rope on the map.") return end local rope = ropes[1] local hanging_spot_idx = rope:GetSpotBeginIndex("Hanging") if hanging_spot_idx == -1 then StoreErrorSource(rope, "Rope missing Hanging spot") return end rope.SetAutoAttachMode = empty_func self:ClearEnumFlags(const.efApplyToGrids) self:ClearEnumFlags(const.efCollision) --this unit is goind to be attached to a bone anim; --in order to not net check its saved position/axis/angle on enter sector which could be a little different per client --we clear the sync flag and switch its class to a class with gofSyncObject = false self:MakeNotSync() rope:Attach(self, hanging_spot_idx) self:SetState("nw_Hanging", 0, 0) self.HitPoints = 0 self.immortal = false InvalidateDiplomacy() Msg("UnitDieStart", self) Halt() end DefineClass.NonSyncUnit = { __parents = { "Unit" }, flags = { gofSyncObject = false }, } function Unit:MakeNotSync() if not self:IsSyncObject() then return end self:ClearGameFlags(const.gofSyncObject) --self:SetHandle(self:GenerateHandle()) --its probably fine to leave the old handle in case someone is refering to this setmetatable(self, g_Classes.NonSyncUnit) end function Unit:IsPersistentDead() return self.command == "Hang" end function Unit:VillainDefeat() if not self.villain or not self.villain_defeated then if self.behavior == "VillainDefeat" then self:SetBehavior() end if self.combat_behavior == "VillainDefeat" then self:SetCombatBehavior() end return end SetCombatActionState(self, nil) if SideIsEnemy(self.team.side, "player1") then PlayVoiceResponse(self, "VillainDefeated") end self:SetBehavior("VillainDefeat") self:SetCombatBehavior("VillainDefeat") self.invulnerable = true self.conflict_ignore = true -- prevent ambient life from messing around with this unit self.neutral_retaliate = false self.HitPoints = 1 self:DoChangeStance("Crouch") self.ActionPoints = 0 self.villain_defeated = true self:ClearPath() self:InterruptPreparedAttack() self:RemoveAllStatusEffects() local squad = gv_Squads[self.Squad] if squad then local newSquadId = SplitSquad(squad, {self.session_id}) assert(self.Squad == newSquadId) SetSatelliteSquadSide(self.Squad, "neutral") end self:SetSide("neutral") MoraleModifierEvent("LieutenantDefeated", self) if g_Combat and not g_AIExecutionController then g_Combat:EndCombatCheck() end self:RemoveEnemyPindown() -- temporary anim self.command_specific_params = self.command_specific_params or {} self.command_specific_params.VillainDefeat = { weapon_anim_prefix = "civ_" } self:SetRandomAnim(self:GetIdleBaseAnim("Crouch"), const.eKeepComponentTargets, 0) self:SyncWithSession("map") Msg("VillainDefeated", self, self.on_die_attacker) Halt() end function Unit:Despawn() self:AutoRemoveCombatEffects() self:InterruptPreparedAttack() self:RemoveEnemyPindown() --remove crosshair on despawned unit local dlg = GetInGameInterfaceModeDlg() local crosshair = dlg and dlg.crosshair if crosshair and crosshair.context.target == self then dlg:RemoveCrosshair("Despawn") if g_Combat then if g_Combat:ShouldEndCombat() then g_Combat:EndCombatCheck(true) else SetInGameInterfaceMode("IModeCombatMovement") end else SetInGameInterfaceMode("IModeExploration", {suppress_camera_init = true}) end end if not self:IsAmbientUnit() then self:SyncWithSession("map") end self.is_despawned = true --self:RemoveAllStatusEffects() if SelectedObj == self then SelectObj() end local cameraFollowTarget = cameraTac.GetFollowTarget() if cameraFollowTarget == self then cameraTac.SetFollowTarget(false) end if self.Squad then local squad = gv_Squads[self.Squad] if squad and (squad.Side == "enemy1" or squad.Side == "enemy2") then RemoveUnitFromSquad(gv_UnitData[self.session_id]) end elseif not IsMerc(self) and not self.PersistentSessionId then gv_UnitData[self.session_id] = nil end if g_Combat and not self:IsLocalPlayerControlled() and CountAnyEnemies()==1 then g_Combat.retreat_enemies = true end DoneObject(self) if g_Combat and not g_AIExecutionController then g_Combat:EndCombatCheck("force") end end function Unit:CanActivatePerk(id) return HasPerk(self, id) and not self.perks_activated[id] and not self:IsDead() end function Unit:ActivatePerk(id) if self.perks_activated then self.perks_activated[id] = true end end function Unit:GetUIActionPoints() return self.ui_override_ap or Max(0, (self.ActionPoints or 0) - self.ui_reserved_ap - self.free_move_ap) end function Unit:GetUIAdjustedActionCost(ap, movement, use_free_move) local ap_scale = const.Scale.AP local ap_now = self:GetUIActionPoints() / ap_scale local free if movement then local unit_ap = self.ActionPoints if not use_free_move then unit_ap = Max(0, unit_ap - SelectedObj.free_move_ap) end local ap_after = Max(0, unit_ap - ap) / ap_scale if ap_after >= ap_now then ap = 0 if use_free_move and ap <= self.free_move_ap then free = true end else ap = ap_now - ap_after end else ap = ap / const.Scale.AP end return ap, ap_now, free end function Unit:UIHasAP(ap, action_id, args) return self:HasAP((ap or 0) + self.ui_reserved_ap, action_id, args) end function Unit:HasAP(ap, action_id, args) if not g_Combat and not g_StartingCombat and not g_TestingSaveLoadSystem then return true --self.ActionPoints = self:GetMaxActionPoints() end local move_ap = 0 local action = CombatActions[action_id] if action and action.UseFreeMove then if args and args.ap_cost_breakdown then move_ap = args.ap_cost_breakdown.move_cost or ap else move_ap = ap end else move_ap = args and args.goto_ap or 0 end -- available ap for the action are the base AP + free move ap up to move_ap (when 0, that's the base AP only) local available = Max(0, (self.ActionPoints or 0) - self.free_move_ap) + Min(move_ap, self.free_move_ap) return (available or 0) >= (ap or 0), (ap or 0) - (available or 0) end function Unit:GainAP(ap) if not g_Combat or (ap or 0) <= 0 then return end if g_Pindown[self] or (g_Overwatch[self] and not g_Overwatch[self].permanent) or self:IsDowned() then return end if self:HasStatusEffect("Panicked") or self:HasStatusEffect("Berserk") or self:HasStatusEffect("Protected") then return end local spentAp = self:GetStatusEffect("SpentAP") if spentAp and spentAp.stacks >= self:GetMaxActionPoints() then return end self.ActionPoints = self.ActionPoints + ap CombatPathReset(self) Msg("UnitAPChanged", self) ObjModified(self) end function Unit:ConsumeAP(ap, action_id, args) ap = ap or 0 local action = action_id and CombatActions[action_id] if action and ap > 0 then if action.ActionType ~= "Ranged Attack" and action.ActionType ~= "Melee Attack" then self:RemoveStatusEffect("Focused") end if action.ActionType == "Ranged Attack" or action.ActionType == "Melee Attack" then self:RemoveStatusEffect("Mobile") self:RemoveStatusEffect("FreeMove") end end if action then if action_id ~= "ChangeWeapon" then self.performed_action_this_turn = action_id end end local move_ap = 0 if action and action.UseFreeMove then move_ap = ap else move_ap = args and args.goto_ap or 0 end if move_ap > 0 then self.start_move_total_ap = self.ActionPoints self.start_move_cost_ap = move_ap self.start_move_free_ap = self.free_move_ap local reduce = Min(move_ap, self.free_move_ap) self.free_move_ap = self.free_move_ap - reduce end if not g_Combat or ap <= 0 or self.infinite_ap then return end self.ActionPoints = Max(0, self.ActionPoints - ap) Msg("UnitAPChanged", self, action_id, -ap) ObjModified(self) end function OnMsg.UnitAPChanged() if not GetDialog("PDADialogSatellite") then ObjModified("combat_bar") end end function OnMsg.CombatActionEnd(unit) unit.start_move_total_ap = nil unit.start_move_cost_ap = nil unit.start_move_free_ap = nil end function Unit:AddStatusEffect(effect, ...) NetUpdateHash("AddStatusEffect", self, effect) if self:IsDead() then return end if effect == "Bleeding" and self:IsDead() then return end if (effect == "Tired" or effect == "Exhausted") and not g_Combat then PlayVoiceResponse(self, effect) end return StatusEffectObject.AddStatusEffect(self, effect, ...) end function Unit:RemoveStatusEffect(effect, ...) if self.StatusEffects[effect] then NetUpdateHash("RemoveStatusEffect", self, effect) end return StatusEffectObject.RemoveStatusEffect(self, effect, ...) end function NetEvents.UnitAddPerk(session_id, perk_id) local unit = g_Units[session_id] if unit then unit:AddStatusEffect(perk_id) end local unit = gv_UnitData[session_id] if unit then unit:AddStatusEffect(perk_id) end end function Unit:TakeDamage(dmg, attacker, hit_descr, ...) if g_Combat then g_Combat:OnUnitDamaged(self, attacker) end -- lose Hidden and alert units around visually and vocally (unless killed from stealth) self:RemoveStatusEffect("Hidden") if not hit_descr.stealth_kill and not hit_descr.melee_attack and not hit_descr.setpiece then TriggerUnitAlert("noise", self, const.Combat.PainNoiseRangeStealthKill, Presets.NoiseTypes.Default.Gunshot.display_name, attacker) end if IsKindOf(attacker, "Unit") then self.hit_this_turn = self.hit_this_turn or {} table.insert(self.hit_this_turn, attacker) end if self:IsInvulnerable() and not hit_descr.setpiece then CreateFloatingText(self:GetVisualPos(), T(759740141426, "Invulnerable")) CombatLog("debug", T{Untranslated(" has ignored damage (invulnerable)"), name = self:GetLogName(), num = dmg}) return end return CombatObject.TakeDamage(self, dmg, attacker, hit_descr, ...) end function Unit:TakeDirectDamage(dmg, floating, log_type, log_msg, attacker, hit_descr) hit_descr = hit_descr or {} -- ignore damage from the same action that downed us if self:IsDowned() and self.downing_action_start_time == CombatActions_LastStartedAction.start_time then return end local data = { dmg = dmg, hit_descr = hit_descr } Msg("PreUnitDamaged", attacker, self, data) dmg = data.dmg dmg = self:CallReactions_Modify("PreUnitTakeDamage", dmg, attacker, self, hit_descr) if IsKindOf(attacker, "Unit") then dmg = attacker:CallReactions_Modify("PreUnitTakeDamage", dmg, attacker, self, hit_descr) end if dmg <= 0 then return end local hp = self.HitPoints local tempHp = self.TempHitPoints hit_descr.was_downed = self:IsDowned() CombatObject.TakeDirectDamage(self, dmg, floating, log_type, log_msg, attacker, hit_descr) self.last_turn_damaged = true if not self:IsDead() then local healthDamageTaken = self.HitPoints < hp or (dmg > 0 and (self.invulnerable or CheatEnabled("WeakDamage"))) local gritDamageTaken = self.TempHitPoints < tempHp or (dmg > 0 and (self.invulnerable or CheatEnabled("WeakDamage"))) if healthDamageTaken then MoraleModifierEvent("UnitDamaged", self, hp - self.HitPoints) if not (hit_descr and hit_descr.grazing) then self:AccumulateDamageTaken(hp - self.HitPoints) end end if healthDamageTaken or gritDamageTaken or hit_descr.setpiece then if not hit_descr.grazing then self:Pain(hit_descr, attacker) end self:RemoveStatusEffect("Hidden") end end if hit_descr and hit_descr.explosion_fly and not self:IsMerc() then if not self:IsDead() then self:Interrupt() end self:InterruptCommand("ExplosionFly", hp) end ObjModified(self) end function Unit:OnHPLoss(hp, attacker) if g_Combat and g_Combat.hp_loss then g_Combat.hp_loss[self.session_id] = (g_Combat.hp_loss[self.session_id] or 0) + hp end end function Unit:StopPain() if self.pain_thread then self:ClearAnim(const.AnimChannel_Pain) DeleteThread(self.pain_thread) self.pain_thread = false end end function Unit:WaitPain() while IsValidThread(self.pain_thread) do WaitMsg(self.pain_thread, 1000) end end function Unit:PlayPainAnim(alert_units) self:SetPos(self:GetVisualPos()) self:SetAngle(self:GetVisualAngle()) local anim = self:GetRandomAnim("pain") self:SetState(anim) repeat until not WaitWakeup(self:TimeToAnimEnd()) self:SetState(self:GetIdleBaseAnim()) if alert_units then AlertPendingUnits() end end local function GetPainAnim(unit, prefix, stance, variant) local anim = string.format("%s_%s_Pain%s", prefix, stance, variant == 1 and "" or variant) if IsValidAnim(unit, anim) then return anim end if variant > 1 then anim = string.format("%s_%s_Pain", prefix, stance) if IsValidAnim(unit, anim) then return anim end end end function Unit:Pain(hit_descr, attacker) local setpiece = hit_descr and hit_descr.setpiece local alert_units = not setpiece if alert_units then TriggerUnitAlert("noise", self, const.Combat.PainNoiseRange, Presets.NoiseTypes.Default.Pain.display_name, attacker) -- dead units do not play pain end if self.species ~= "Human" then DeleteThread(self.pain_thread) self.pain_thread = nil if self:HasStatusEffect("Unconscious") then return end if self.interrupted then self.pain_thread = CreateGameTimeThread(function(self, alert_units) self:PlayPainAnim(alert_units) self.pain_thread = false Msg(CurrentThread()) end, self, alert_units) elseif self:IsInterruptable() and CurrentThread() ~= self.command_thread then self:SetCommand("PlayPainAnim", alert_units) elseif alert_units then AlertPendingUnits() end return end local anim if self:HasAnimMask("PainMask") then --"UpperBodyMask" local prefix = string.match(self:GetStateText(), "^(%a+)_%a+_.*") if not prefix and setpiece then prefix = string.match(self:GetWeaponAnimPrefix(), "^(%a+)_") end local target_spot_group = hit_descr and hit_descr.spot_group local variant = target_spot_group == "Head" and 3 or 1 + self:Random(2) local stance = self.infected and "Standing" or self.stance anim = GetPainAnim(self, prefix, stance, variant) if not anim and prefix == "arg" then anim = GetPainAnim(self, "ar", stance, variant) end if not anim and prefix == "inf" then anim = GetPainAnim(self, "nw", stance, variant) end end if not (anim and IsValidAnim(self, anim)) then if alert_units then AlertPendingUnits() end return end local channel = const.AnimChannel_Pain local pain_anim_weight = hit_descr and hit_descr.grazing and const.PainAnimGrazingWeight or const.PainAnimWeight self:SetAnimMask(channel, "PainMask") --"UpperBodyMask" self:SetAnim(channel, anim, 0, -1, 1000, 0) self:SetAnimWeight(channel, pain_anim_weight, const.PainAnimWeightMoment, PainEasing) self:SetAnimBlendComponents(channel, false, true, false) DeleteThread(self.pain_thread) self.pain_thread = CreateGameTimeThread(function(self, anim, alert_units) Sleep(const.PainAnimWeightMoment) local channel = const.AnimChannel_Pain repeat local t = self:TimeToAnimEnd(channel) self:SetAnimWeight(channel, 0, t, 1, PainEasing) until not WaitWakeup(t) self:ClearAnim(channel) self.pain_thread = false if alert_units then AlertPendingUnits() end Msg(CurrentThread()) end, self, anim, alert_units) end function Unit:KnockDown() if self.species ~= "Human" or self:IsDead() then return end local base_anim = self:GetActionBaseAnim("Downed", self.stance) local anim = self:GetStateText() if not IsAnimVariant(anim, base_anim) then anim = self:GetNearbyUniqueRandomAnim(base_anim) end --self:PushDestructor(function(self) self.return_pos = nil self:InterruptPreparedAttack() -- force interrupt when the unit gets knocked down if self:GetStateText() ~= anim then local x, y, z = FindFallDownPos(self) local pos = x and point(x, y, z) or self:GetPos() local angle = self:GetOrientationAngle() self:SetOrientationAngle(angle, 300) self:MovePlayAnim(anim, pos, pos, 0, nil, true, angle) end local variation_suffix = string.match(anim, "(%d+)$") -- there are no variations for now local idle_anim = self:TryGetActionAnim("Idle", "Downed", variation_suffix) if idle_anim then self:SetState(idle_anim, 0, 0) end self.stance = self:GetValidStance("Prone") self:SetFootPlant(true) self:SetTargetDummy(nil, nil, nil, 0) -- current position, animation phase 0 --self:DoChangeStance("Prone") self:RemoveStatusEffect("KnockDown") self:RemoveStatusEffect("Protected") -- lose cover --end) --self:PopAndCallDestructor() if self:HasStatusEffect("Unconscious") then self:SetCommand("Downed") end end function Unit:Dodge() self:SetFootPlant(true) local anim = self:GetActionRandomAnim("Dodge", self.stance) self:SetState(anim, const.eKeepComponentTargets) Sleep(self:TimeToAnimEnd()) end function Unit:BeginTurn(new_turn) NetUpdateHash("BeginTurn_Start") self:SetAttackReason() local should_interrupt = true local pindown = g_Pindown[self] local overwatch = g_Overwatch[self] if pindown and IsValidTarget(pindown.target) and self:HasPindownLine(pindown.target, pindown.target_spot_group) then -- pindown will be handled differently when the attack is executed should_interrupt = false elseif overwatch and (overwatch.permanent or not g_Combat or overwatch.expiration_turn > g_Combat.current_turn) then should_interrupt = false elseif self.prepared_bombard_zone then should_interrupt = false end if new_turn and should_interrupt then self:InterruptPreparedAttack("begin turn") pindown = false end self:UpdateMeleeTrainingVisual() self:IsThreatened() -- update the is_melee_aim_last_turn flag for the vr if self.is_melee_aim_last_turn and IsMerc(self) then PlayVoiceResponse(self, "MeleeEnemiesClosing") self.is_melee_aim_last_turn = false end self.perks_activated = {} NetUpdateHash("BeginTurn_Progress") if new_turn then self:RemoveStatusEffect("FreeMove") if g_Overwatch[self] and not g_Overwatch[self].permanent then self.ActionPoints = 0 -- special-case for carrying an overwatch from exploration mode else local ap = self:GetMaxActionPoints() ap = self:CallReactions_Modify("OnCalcStartTurnAP", ap) self.ActionPoints = Max(0, ap) -- if g_Combat.current_turn == 1 and self:IsMerc() then --use this to test lower AP during the first turn -- self.ActionPoints = MulDivRound(self.ActionPoints, 75, 100) -- end end if g_Overwatch[self] then table.clear(g_Overwatch[self].triggered_by) -- reset triggers in case somebody already triggered in our turn if self:HasStatusEffect("ManningEmplacement") or self:HasStatusEffect("StationedMachineGun") then g_Overwatch[self].num_attacks = self:GetNumMGInterruptAttacks() self:UpdateOverwatchVisual() end end g_Pindown[self] = nil -- clear from the global table to stop the prepared attack blocking any AP gains self.ui_reserved_ap = 0 if self:GetEffectValue("missed_by_kill_shot") and not self:IsDead() and not self:IsDowned() then PlayVoiceResponse(self, "MissedByKillShot") self:SetEffectValue("missed_by_kill_shot", nil) end self:UpdateHidden() local voxels = self:GetVisualVoxels() local fire, dist = AreVoxelsInFireRange(voxels) if fire then local min, max = const.BurnDamageMin, const.BurnDamageMax local damage = self:RandRange(min, max) self:TakeDirectDamage(damage) if not self:IsIncapacitated() and not self:HasStatusEffect("Unconscious") and not RollSkillCheck(self, "Health") then self:ChangeTired(1) end if dist < const.SlabSizeX then self:AddStatusEffect("Burning") end end self.attacked_this_turn = false self.hit_this_turn = false self.wounded_this_turn = false NetUpdateHash("BeginTurn", self, self.using_cumbersome, HasPerk(self, "KillingWind"), HasPerk(self, "Ironclad")) if not self.using_cumbersome or HasPerk(self, "KillingWind") then self:AddStatusEffect("FreeMove") elseif self:CanUseIroncladPerk() then self:AddStatusEffect("FreeMove") self:ConsumeAP(DivRound(self.free_move_ap, 2), "Move") end -- ConsumeAP will flag this only when an action is given, so it is safe to mark this a bit earlier to allow OnBeginTurn effects to alter it self.performed_action_this_turn = false Msg("UnitBeginTurn", self) self:CallReactions("OnBeginTurn") local morale = self:GetPersonalMorale() if morale > 0 then self:GainAP(morale * const.Scale.AP) elseif morale < 0 then self:ConsumeAP(Min(self.ActionPoints, -morale * const.Scale.AP)) end if self:GetItemInSlot("Head", "GasMaskBase") then self:ConsumeAP(const.Scale.AP) end -- special-case: if the unit dies as a result of a status effect, show them and wait the command to end -- doing this here makes sure the camera will not immediately jump to another unit (dying or selected) -- similarly, executing the pindown attack has to be waited until it finishes if self.command == "Die" then SnapCameraToObj(self) while self.command == "Die" do WaitMsg("UnitDied", 20) -- can also go in VillainDefeat instead, so wait with timeout end elseif pindown then pindown.target:ProvokeOpportunityAttack_Pindown(self, pindown) elseif self.prepared_bombard_zone then self:StartBombard() end end if self.dummy or self:IsDowned() then self.ActionPoints = 0 end self.start_turn_pos = self:GetVisualPos() NetUpdateHash("BeginTurn", self, self:GetPos()) Msg("UnitAPChanged", self) end function AdjustWoundsToHP(obj,stacks) local effect = obj:GetStatusEffect("Wounded") local maxhp = obj:GetInitialMaxHitPoints() local value = Wounded:ResolveValue("MaxHpReductionPerStack") local maxreduce = Wounded:ResolveValue("MinMaxHp") local min = MulDivRound(maxhp, maxreduce, 100) local cur_stacks = effect and effect.stacks or 0 local count = stacks and (cur_stacks + stacks) or cur_stacks while count>=0 do if maxhp - count * value >= min then if stacks then return count - cur_stacks end return count end count = count - 1 end if stacks then return count - cur_stacks end return count end function RecalcMaxHitPoints(unit) -- unit can be Unit or UnitData local count = AdjustWoundsToHP(unit) if count and count>0 then local effect = unit:GetStatusEffect("Wounded") local to_remove = effect.stacks - count if to_remove>0 then unit:RemoveStatusEffect("Wounded", to_remove) end end local maxhp = unit:GetModifiedMaxHitPoints() local prev_maxhp = unit.MaxHitPoints unit.MaxHitPoints = maxhp if maxhp > prev_maxhp then unit.HitPoints = unit.HitPoints + maxhp - prev_maxhp end unit.HitPoints = Min(unit.HitPoints, unit.MaxHitPoints) ObjModified(unit) end function GetMedsAndOwners(units, healer, healed) local total_amount = 0 local list, meds_list = {}, {} local function add_meds(unit, list) local meds = unit:GetItem("Meds") if meds then total_amount = total_amount + meds.Amount list[#list + 1] = meds list[#list + 1] = unit meds_list[unit.session_id] = (meds_list[unit.session_id] or 0) + meds.Amount end end if healer then add_meds(healer, list) end if healed and healed ~= healer then add_meds(healed, list) end for _, unit in ipairs(units) do if unit ~= healer and unit ~= healed then add_meds(unit, list) end end local squad_id = units and units[1] and units[1].Squad if squad_id then for _, meds in ipairs(GetSquadBag(squad_id)) do if meds.class == "Meds" then total_amount = total_amount + meds.Amount list[#list + 1] = meds list[#list + 1] = squad_id meds_list[squad_id] = (meds_list[squad_id] or 0) + meds.Amount end end end return total_amount, list, meds_list end function Unit:CalcHealAmount(medkit, target) if not medkit then return 0 end local base_heal = CombatActions.Bandage:ResolveValue("base_heal") local medical_heal = CombatActions.Bandage:ResolveValue("medical_max_heal") local selfheal = CombatActions.Bandage:ResolveValue("selfheal") local heal_percent = base_heal + MulDivRound(self.Medical, medical_heal, 100) local data = { heal_amount = 0, heal_percent = heal_percent, self_heal_percent = 50, heal_modifier = 100, } self:CallReactions("OnCalcHealAmount", target, self, medkit, data) if target ~= self and IsKindOf(target, "UnitBase") then target:CallReactions("OnCalcHealAmount", target, self, medkit, data) end local heal_percent = data.heal_percent if target == self then heal_percent = MulDivRound(heal_percent, data.self_heal_percent, 100) end local heal_mod = MulDivRound(heal_percent, data.heal_modifier, 100) -- convert to raw hp local max = IsValid(target) and target.MaxHitPoints or self.MaxHitPoints return data.heal_amount + MulDivRound(max, heal_mod, 100), MulDivRound(heal_percent, 100, heal_mod) end function Unit:OnEndTurn() self:RemoveStatusEffect("FreeMove") -- cleanup unused free move ap if self.start_turn_pos then -- Units hired during this turn don't have start_turn_pos self.last_turn_movement = self:GetVisualPos() - self.start_turn_pos else self.last_turn_movement = nil end self.last_turn_damaged = nil -- reset here, we're tracking damage done between the end of our turn and the start of our next one SetCombatActionState(self, false) -- add attacks from remaining AP to permanent overwatch self:UpdateNumOverwatchAttacks() Msg("UnitEndTurn", self) self:CallReactions("OnEndTurn") for i = #self.StatusEffects, 1, -1 do local effect = self.StatusEffects[i] if effect.lifetime ~= "Indefinite" then local expiration = self:GetEffectExpirationTurn(effect.class, "expiration") if g_Combat and g_Combat.current_turn >= expiration then self:RemoveStatusEffect(effect.class, "all") end end end -- special-case: if the unit dies as a result of a status effect, show them and wait the command to end -- doing this here makes sure the camera will not immediately jump to another unit (dying or selected) if self.command == "Die" then SnapCameraToObj(self) while self.command == "Die" do WaitMsg("UnitDied", 20) -- can also go in VillainDefeat instead, so wait with timeout end end end function Unit:OnCommandStart() if self.interrupted then self:InterruptEnd() end self.cur_idle_style = false self.cur_move_style = false self.goto_target = false self.goto_stance = false self.goto_hide = false self.visibility_override = false self.passed_interrupts = nil self:TunnelsUnblock() self.action_visual_weapon = false if IsValid(self) then self:SetGravity(0) self:StopMoving() self.interrupted = false if not self:IsDead() and not IsActivePaused() and not IsSetpieceActor(self) then self:ClearPath() if self.traverse_tunnel then local tpos = self.traverse_tunnel:GetExit() if tpos then local pos = GetPassSlab(tpos) or FindPassable(tpos, 0, -1, -1, const.pfmVoxelAligned) or tpos self:SetPos(pos) end end end if not KeepAimIKCommands[self.command] then self:SetWeaponLightFx(false) self:SetIK("AimIK", false) end self:SetIK("LookAtIK", false) if not self.interruptable and self.command then self:BeginInterruptableMovement() end self:SetFootPlant(true) end self.traverse_tunnel = false self:SetAimFX(false, self.command and "delayed") if self.action_command then SetCombatActionState(self, self.command == self.action_command and "start" or nil) end end function Unit:SetActionCommand(command, combatActionId, ...) -- the interface should clear the visualisations DbgClearVectors() DbgClearTexts() if IsActivePaused() then if CombatActions_RunningState[self] then SetCombatActionState(self) else self:SetQueuedAction() end end self.action_command = command SetCombatActionState(self, "wait") self:InterruptCommand(command, combatActionId, ...) end function Unit:IsLocalPlayerControlled(player_control_mask) return IsControlledByLocalPlayer(self.team and self.team.side, player_control_mask or self.ControlledBy) end function Unit:IsLocalPlayerTeam() if not netInGame then return self:IsLocalPlayerControlled() else local squad = self.team if not squad then return true end return squad.side == NetPlayerSide(netUniqueId) end end function Unit:IsControlledBy(mask) return self.ControlledBy & mask ~= 0 end function Unit:IsDisabled() --the unit is in an uncontrollable state return self:IsIncapacitated() or self:HasStatusEffect("Panicked") or self:HasStatusEffect("Berserk") end --CanBeControlled() without args returns whether unit can be controlled by local player --CanBeControlled("sync_code") returns whether unit can be controlled by any player in general omiting async reasons for control loss function Unit:CanBeControlled(sync_code) if not IsValid(self) then return false end if GetDialog("ConversationDialog") then return false end --disabled units cannot be controlled if self:IsIncapacitated() then return false end --is it an AI team? if not self.team or self.team.control ~= "UI" then return false end --can it be controlled by the local player network wise? if not self:IsLocalPlayerControlled(sync_code and -1 or nil) then -- -1 is any net player return false end -- Don't do further checks in deployment mode. if gv_DeploymentStarted then return true end if g_AIExecutionController or g_UnitAwarenessPending == "alert" then -- AI units taking actions return false end if g_Combat then if not g_Combat.combat_started then return false end -- Not current team in combat if not g_Combat:HasTeamTurnStarted(self.team) then return false end if not sync_code and g_Combat:IsLocalPlayerEndTurn() or --this is async sync_code and not IsNetPlayerTurn() then --this is sync return false, "not_local_turn" end end return true end function Unit:TunnelBlock(tunnel_entrance, tunnel_exit) for i, o in ipairs(self.tunnel_blockers) do if o.tunnel_end_point == tunnel_exit and tunnel_entrance:Equal(o:GetPosXYZ()) then return end end local o = PlaceObject("TunnelBlocker") o.owner = self o.tunnel_end_point = tunnel_exit pf.SetCollisionRadius(o, self:GetCollisionRadius()) o:SetPos(tunnel_entrance) if not self.tunnel_blockers then self.tunnel_blockers = {} end table.insert(self.tunnel_blockers, o) if not (g_Combat and self:IsAware()) then MapForEach(tunnel_exit, 0, "Unit", function(o, self) if o:GetEnumFlags(const.efResting) == 0 or o:IsDead() then return end if o.command == "Idle" and o ~= self then o:SetCommand("GotoSlab", tunnel_exit) end end, self) end end function Unit:TunnelUnblock(tunnel_entrance, tunnel_exit) for i, o in ipairs(self.tunnel_blockers) do if o.tunnel_end_point == tunnel_exit and tunnel_entrance:Equal(o:GetPosXYZ()) then table.remove(self.tunnel_blockers, i) DoneObject(o) break end end end function Unit:TunnelsUnblock() local list = self.tunnel_blockers if not list or #list == 0 then return end for i, o in ipairs(list) do DoneObject(o) end table.iclear(list) end local function CheckInterruptCombatGotoPos(x, y, z, unit, ignore_pos) if not CanOccupy(unit, x, y, z) then return false end if ignore_pos and ignore_pos:Equal(x, y, z) then return false end local cost = unit.combat_path_obj:GetAP(point_pack(x, y, z)) if not cost or cost >= (unit.combat_path_obj:GetAP(unit.combat_path[1]) or 0) then return false end return true end function Unit:GetInterruptCombatPath() local path = self.combat_path if not path or #path == 0 then return end local idx = #path local cur_pos if self.traverse_tunnel then cur_pos = point(point_unpack(path[idx])) idx = idx - 1 else cur_pos = self:GetVisualPos() if not self:IsValidZ() then cur_pos = cur_pos:SetInvalidZ() end end for i = idx, 1, -1 do local x, y, z = point_unpack(path[i]) local next_pos = point(x, y, z) local back_pos if i == idx and not self.traverse_tunnel then local pass_pos = GetPassSlab(self) if pass_pos and pass_pos ~= cur_pos then local a1 = CalcOrientation(cur_pos, next_pos) local a2 = CalcOrientation(cur_pos, pass_pos) if abs(AngleDiff(a1, a2)) > 90 * 60 then back_pos = pass_pos end end end local interrupt_pos if cur_pos ~= next_pos and not pf.GetTunnel(cur_pos, next_pos) then interrupt_pos = RasterizeSegmentPassSlabs(cur_pos, next_pos, CheckInterruptCombatGotoPos, self, back_pos) elseif SnapToVoxel(next_pos) == next_pos and CheckInterruptCombatGotoPos(x, y, z, self, back_pos) then interrupt_pos = next_pos end if interrupt_pos then if i == 1 and interrupt_pos == next_pos then return end local new_path = { point_pack(interrupt_pos) } for j = i + 1, #path do table.insert(new_path, path[j]) end return new_path end cur_pos = next_pos end end function Unit:CombatGotoInterrupt(new_pos, restore_ap_only) if not self.combat_path or not self.combat_path_obj or self:IsDead() then return end local prev_pos = self.combat_path[1] local prev_cost = self.combat_path_obj:GetAP(prev_pos) or 0 local interrupt_path if new_pos then if IsPoint(new_pos) then new_pos = point_pack(new_pos) end interrupt_path = { new_pos } else interrupt_path = self:GetInterruptCombatPath() if not interrupt_path then return end new_pos = interrupt_path[1] end local new_cost = self.combat_path_obj:GetAP(new_pos) or 0 self.combat_path_obj = false self.combat_path = false if new_cost < prev_cost then self:GainAP(prev_cost - new_cost) if self.start_move_free_ap > 0 then -- restore ap -> free move ap local start_ui_ap = self.start_move_total_ap - self.start_move_free_ap self.free_move_ap = Max(0, self.ActionPoints - start_ui_ap) ObjModified(self) end end if restore_ap_only then return end SetCombatActionState(self, false) RunCombatAction("Move", self, new_cost, { goto_pos = point(point_unpack(new_pos)), path = interrupt_path }) end local function ForEachWalkStep(p0, p1, f, ...) local step = const.SlabSizeX local x0, y0, z0 = p0:xyz() local x1, y1, z1 = p1:xyz() local dx = x1 - x0 local dy = y1 - y0 if abs(dx) >= abs(dy) then local step = step * (x1 >= x0 and 1 or -1) for x = x0 + step, x1, step do local y = y0 + MulDivRound(x - x0, dy, dx) f(point(x, y, z0), ...) end else local step = step * (y1 >= y0 and 1 or -1) for y = y0 + step, y1, step do local x = x0 + MulDivRound(y - y0, dx, dy) f(point(x, y, z0), ...) end end end function Unit:CanQuickPlayInCombat(noQuickPlay) return g_Combat and not noQuickPlay and not self.visible and self.team and not self.team.player_team end local function IsSamePos(p1, p2) if not p1 ~= not p2 then return false end local x1, y1, z1 = p1:xyz() local x2, y2, z2 = p2:xyz() z1 = z1 or terrain.GetHeight(x1, y1) z2 = z2 or terrain.GetHeight(x2, y2) return (x1 == x2) and (y1 == y2) and (z1 == z2) end function Unit:CombatGoto(action_id, cost_ap, pos, interrupt_path, forced_run, stanceAtStart, stanceAtEnd, fallbackMoveTracking, visibleMovement) Msg("UnitAnyMovementStart", self, pos, stanceAtStart, stanceAtEnd) self:RemovePreparedAttackVisuals() if interrupt_path then self.combat_path = interrupt_path pos = point(point_unpack(interrupt_path[1])) else self.combat_path_obj = GetCombatPath(self, stanceAtStart, cost_ap, stanceAtEnd) self.combat_path = self.combat_path_obj:GetCombatPathFromPos(pos) if not stanceAtStart and self.combat_path and self.combat_path_obj.destination_stances and pos then stanceAtStart = self.combat_path_obj.destination_stances[point_pack(pos)] end local new_cost = self.combat_path_obj:GetAP(pos) if not new_cost or new_cost > cost_ap then self:GainAP(cost_ap) CombatActionInterruped(self) return false end if new_cost < cost_ap then self:GainAP(cost_ap - new_cost) cost_ap = new_cost end end if not self.combat_path then self:GainAP(cost_ap) return true end if self.combat_path_obj then self:SetActionInterruptCallback("CombatGotoInterrupt") end local thread_RunStop self:PushDestructor(function(self) DeleteThread(thread_RunStop) if IsValid(self) then if self.combat_path then self:CombatGotoInterrupt(nil, "restore_ap_only") end self:SetActionInterruptCallback() end self.combat_path_obj = false self.combat_path = false self.in_combat_movement = false end) self.in_combat_movement = true if stanceAtStart and self.stance ~= stanceAtStart then self:ChangeStance("Stance" .. stanceAtStart, 0, stanceAtStart) end -- update move anim local path = self.combat_path local pfclass = self:GetPfClass() + 1 local tunnel_mask = pathfind[pfclass].tunnel_mask local GetTunnel = pf.GetTunnel local run_dist = 0 local px, py, pz = self:GetPosXYZ() for i = #path, 1, -1 do if terrain.GetPassType(px, py, pz) == pathfind_water_pass_type_idx then break end local px2, py2, pz2 = point_unpack(path[i]) local tunnel = GetTunnel(px, py, pz, px2, py2, pz2, tunnel_mask) if tunnel and not tunnel:CanSprintThrough() then break end run_dist = run_dist + GetLen(px - px2, py - py2, pz and pz2 and pz - pz2 or 0) px, py, pz = px2, py2, pz2 end local move_anim_type if self.species == "Human" then if self.stance == "Standing" and (forced_run or run_dist >= 5 * const.SlabSizeX) then move_anim_type = "Run" end end local has_closed_door = false local px, py, pz = self:GetPosXYZ() local closed_door_mask = const.TunnelMaskClosedDoor for i = #path, 1, -1 do local px2, py2, pz2 = point_unpack(path[i]) local tunnel = GetTunnel(px, py, pz, px2, py2, pz2, closed_door_mask) if tunnel then has_closed_door = true break end px, py, pz = px2, py2, pz2 end self:SetIK("AimIK", false) self:SetFootPlant(true) local base_idle = self:GetIdleBaseAnim() local goto_dummies = self:GenerateTargetDummiesFromPath(self.combat_path) for _, dummy in ipairs(goto_dummies) do if dummy.insert_idx then dummy.insert_before_pos = self.combat_path[dummy.insert_idx] or "limit" end end local known_traps local all_move_interrupts = self:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", goto_dummies, true, "all") for i, t in ipairs(all_move_interrupts) do if t[1] == "trap" then known_traps = known_traps or {} table.insert(known_traps, t[2]) end end local provoke_idx, provoke_pos, interrupts local UpdateProvokePos = function(init) if provoke_idx then for i = 1, provoke_idx do table.remove(goto_dummies, 1) end elseif not init then return end interrupts, provoke_idx = self:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", goto_dummies, nil, nil, nil, known_traps) provoke_pos = provoke_idx and goto_dummies[provoke_idx].pos if provoke_pos then -- add the provoke point in the path if not present local insert_idx local insert_marker = goto_dummies[provoke_idx].insert_before_pos if insert_marker == "limit" then insert_idx = #path + 1 else local pp = point_pack(provoke_pos) local provoke_dist = self:GetDist(provoke_pos) for i, ppos in ipairs(path) do if ppos == pp then insert_idx = false break elseif insert_marker == ppos or (self:GetDist(point_unpack(ppos)) < provoke_dist) then insert_idx = i break end end end if insert_idx then table.insert(path, insert_idx, point_pack(provoke_pos)) end return end local goto_pos, angle if not next(self.combat_path) then goto_pos = self:GetPos() else goto_pos = point(point_unpack(self.combat_path[1])) if self.combat_path[2] then angle = CalcOrientation(point(point_unpack(self.combat_path[2])), goto_pos) end end angle = angle or self:GetOrientationAngle() self:SetTargetDummy(goto_pos, angle, base_idle, 0) self.target_dummy:SetEnumFlags(const.efResting) -- update occupy in advance to allow parallel unit movements if action_id and not has_closed_door and CombatActions[action_id].SimultaneousPlay and not fallbackMoveTracking then SetCombatActionState(self, "PostAction") end end UpdateProvokePos(true) if provoke_pos then self:SetTargetDummy(false) if self.command ~= "Reposition" then WaitOtherCombatActionsEnd(self) end end -- If was visible at the start of movement, should be visible throughout local povTeam = GetPoVTeam() if povTeam then if HasVisibilityTo(povTeam, self) then self.visibility_override = goto_dummies[1] end end -- If will enter overwatch, show me as if there. if not self.visibility_override then for i, int in ipairs(interrupts) do if int[1] == "overwatch" then self.visibility_override = goto_dummies[provoke_idx] end end end -- Update visibility as if at final pos otherwise. if not self.visibility_override then self.visibility_override = goto_dummies[#goto_dummies] end if self.command == "CombatGoto" and self:IsLocalPlayerControlled() then if not self:HasStatusEffect("Panicked") and not self:HasStatusEffect("Berserk") then local distanceMove = GetCombatPathLen(path, self) if distanceMove >= const.SlabSizeX * 3 then if self:HasStatusEffect("Hidden") then PlayVoiceResponse(self, "CombatMovementStealth") else PlayVoiceResponse(self, "CombatMovement") end end end end local cur_pos = GetPassSlab(self) or self:GetVisualPos() if provoke_pos then self:SetTargetDummy(false) -- the target used for opportunity attacks if IsSamePos(provoke_pos, cur_pos) then self:ProvokeOpportunityAttacksFromList(interrupts) UpdateProvokePos() end end local next_pos_idx = #path if self:GetDist2D(point_unpack(path[next_pos_idx])) == 0 then next_pos_idx = next_pos_idx - 1 if next_pos_idx > 0 and not IsValidPos(point_unpack(path[next_pos_idx])) then next_pos_idx = next_pos_idx - 1 -- skip tunnel marker end end local next_pt = next_pos_idx > 0 and point(point_unpack(path[next_pos_idx])) self:ClearEnumFlags(const.efResting) self:UpdateMoveAnim(action_id, move_anim_type, next_pt) local start_angle = next_pt and CalcOrientation(self, next_pt) local move_anim = GetStateName(self:GetMoveAnim()) self:ReturnToCover(nil, false) -- do not touch the target dummy self:PlayTransitionAnims(move_anim, start_angle) self:GotoTurnOnPlace(next_pt) VisibilityUpdate() self:StartMoving() -- follow path local prev_pos = cur_pos Msg("UnitMovementStart", self, cur_pos) local stop_anim_pos = self:CombatGoto_GetStopAnimPos() local play_stop_dist while true do self:UpdateMoveSpeed() self:UpdateInWaterFX() if IsSamePos(provoke_pos, cur_pos) then self:ProvokeOpportunityAttacksFromList(interrupts) UpdateProvokePos() end local next_pos = point(point_unpack(path[#path])) if next_pos == cur_pos then path[#path] = nil if #path == 0 then break end next_pos = point(point_unpack(path[#path])) end local quick_play = self:CanQuickPlayInCombat(visibleMovement) if cur_pos == stop_anim_pos and not provoke_pos and not quick_play then play_stop_dist = self:StartPlayRunStop(path) end if play_stop_dist then local anim = self:GetStateText() local has_end_phase, end_phase = self:IterateMoments(anim, 0, 1, "end") if not has_end_phase then end_phase = GetAnimDuration(self:GetEntity(), anim) - 1 end if not thread_RunStop then thread_RunStop = CreateGameTimeThread(function(self, pos, end_phase) local has_hit_phase, hit_phase = self:IterateMoments(anim, 0, 1, "hit") if not has_hit_phase then hit_phase = end_phase end local next_hit_time = self:TimeToPhase(1, hit_phase) if next_hit_time then Sleep(next_hit_time) end local surface_fx_type, surface_pos = GetObjMaterial(pos:IsValidZ() and pos or pos:SetTerrainZ()) PlayFX("RunStop", "hit", self, surface_fx_type, surface_pos) end, self, next_pos, end_phase) end local next_pos_time if #path == 1 then next_pos_time = self:TimeToPhase(1, end_phase) else local phase = self:GetAnimPhase(1) local next_step_dist = self:GetStepVector(anim, 0, phase, end_phase - phase):Len2D() * self:GetVisualDist2D(next_pos) / play_stop_dist local min = phase local max = end_phase while max - min > 10 do local m = min + (max - min) / 2 local len = self:GetStepVector(anim, 0, phase, m - phase):Len2D() if len < next_step_dist then min = m else max = m end end next_pos_time = self:TimeToPhase(1, min) end self:SetPos(next_pos, next_pos_time or 0) if next_pos_time then Sleep(next_pos_time) end if #path == 1 then local surface_fx_type, surface_pos = GetObjMaterial(next_pos:IsValidZ() and next_pos or next_pos:SetTerrainZ()) PlayFX("RunStop", "end", self, surface_fx_type, surface_pos) end else local tunnel = GetTunnel(cur_pos, next_pos, tunnel_mask) if tunnel then local can_use_tunnel can_use_tunnel, quick_play = self:InteractTunnel(tunnel, quick_play) if not can_use_tunnel then self:Interrupt(nil, cur_pos) -- locked door break end if not IsValid(tunnel) then -- the open door erased the tunnel tunnel = GetTunnel(cur_pos, next_pos, tunnel_mask) end if tunnel then self:TraverseTunnel(tunnel, cur_pos, next_pos, false, quick_play) if quick_play and self:GetStateText() ~= move_anim then self:SetState(move_anim, const.eKeepComponentTargets, 0) end elseif IsPassSlabStep(cur_pos, next_pos, 0, self:GetPfClass()) then next_pos = cur_pos else self:Interrupt() -- the path is no more valid break end else if self:GetStateText() ~= move_anim then self:SetState(move_anim, const.eKeepComponentTargets, quick_play and 0 or -1) self:SetFootPlant(true) end local angle = CalcOrientation(cur_pos, next_pos) local max_step_len = 5 * const.SlabSizeX local step_dist = cur_pos:Dist2D(next_pos) if step_dist > max_step_len then table.insert(path, point_pack(next_pos)) next_pos = RotateRadius(max_step_len, angle, cur_pos) step_dist = cur_pos:Dist2D(next_pos) end if quick_play then self:SetPos(next_pos) self:SetOrientationAngle(angle) else while step_dist > 0 do local time = MulDivRound(step_dist, 1000, Max(1, self:GetSpeed())) self:SetPos(next_pos, time) if self.ground_orient then local steps = 1 + step_dist / (const.SlabSizeX / 2) for i = 1, steps do local t = time * i / steps - time * (i-1) / steps self:SetOrientationAngle(angle, t) if WaitWakeup(t) then break end end else self:SetOrientationAngle(angle, 300) WaitWakeup(time) end step_dist = self:GetVisualDist2D(next_pos) end end end end cur_pos = next_pos Msg("CombatGotoStep", self) end if stanceAtEnd and self.stance ~= stanceAtEnd then self:ChangeStance("Stance" .. stanceAtEnd, 0, stanceAtEnd) end self.combat_path_obj = false self.combat_path = false self:PopAndCallDestructor() Msg("UnitMovementDone", self, action_id, prev_pos) return true end function Unit:CombatGoto_GetStopAnimPos() if self.species ~= "Human" or self.stance == "Prone" or self.infected then return end local path = self.combat_path local pfclass = self:GetPfClass() + 1 local tunnel_mask = pathfind[pfclass].tunnel_mask if path[#path] ~= point_pack(self:GetPosXYZ()) then table.insert(path, point_pack(self:GetPos())) end if #path < 2 then return end local p1 = point(point_unpack(path[1])) local p2 = point(point_unpack(path[2])) local tunnel = pf.GetTunnel(p2, p1, tunnel_mask) if tunnel then local tunnel_type = pf.GetTunnelType(tunnel) if tunnel_type ~= (tunnel_type & const.TunnelMaskWalk) then return end end local dir_pos = RotateRadius(const.SlabSizeX, CalcOrientation(p2, p1), p1) if GetCoverFrom(p1, dir_pos) == 0 then return -- there should be a cover ahead of us end local move_anim = GetStateName(self:GetMoveAnim()) local is_running = string.match(move_anim, ".*_CombatRun.*") and true or false local stop_distance, min_stop_distance if is_running then stop_distance = 5*guim min_stop_distance = stop_distance else stop_distance = 2*guim min_stop_distance = stop_distance end local start_stop_anim_pos = p1 local start_stop_anim_idx = 1 for i = 2, #path do local prev = point(point_unpack(path[i])) local tunnel = pf.GetTunnel(prev, start_stop_anim_pos, tunnel_mask) if tunnel then local tunnel_type = pf.GetTunnelType(tunnel) if tunnel_type ~= (tunnel_type & const.TunnelMaskWalk) then break end end if i > 2 and DistSegmentToPt2D(p1, p2, prev) >= 2 then break end start_stop_anim_pos = prev start_stop_anim_idx = i if not IsCloser2D(p1, start_stop_anim_pos, stop_distance) then break end end if IsCloser2D(p1, start_stop_anim_pos, min_stop_distance) then return end if not IsCloser2D(p1, start_stop_anim_pos, stop_distance) then if p1:IsValidZ() or start_stop_anim_pos:IsValidZ() then if not p1:IsValidZ() then p1 = p1:SetTerrainZ() end if not start_stop_anim_pos:IsValidZ() then start_stop_anim_pos = start_stop_anim_pos:SetTerrainZ() end end start_stop_anim_pos = p1 + SetLen(start_stop_anim_pos - p1, stop_distance) table.insert(path, start_stop_anim_idx, point_pack(start_stop_anim_pos)) end return start_stop_anim_pos end function Unit:StartPlayRunStop(path) local move_anim = GetStateName(self:GetMoveAnim()) local stop_anim = string.match(move_anim, "(.*_Combat%a*).*") if stop_anim then stop_anim = self:GetRandomAnim(stop_anim .. "Stop") end if not stop_anim or not IsValidAnim(self, stop_anim) then return false end local moments = self:GetAnimMoments(stop_anim) moments = table.ifilter(moments, function(_, m) return m.Type == "FootLeft" or m.Type == "FootRight" end) if #moments == 0 then print("once", string.format('missing "FootLeft" and "FootRight" moments in anim %s for %s (%s)', stop_anim, self.unitdatadef_id, self:GetEntity())) end local cur_anim = GetStateName(self:GetAnim(1)) local cur_phase = self:GetAnimPhase(1) local has_moment1, t1 = self:IterateMoments(cur_anim, cur_phase, 1, "FootLeft", false, true) local has_moment2, t2 = self:IterateMoments(cur_anim, cur_phase, 1, "FootRight", false, true) if not has_moment1 or not has_moment2 then t1 = false t2 = false end local next_moment_type, prc if not t1 or not t2 or t1 == t2 or #moments < 2 then prc = 0 elseif t1 < t2 then next_moment_type = "FootLeft" prc = MulDivRound(t1, 1000, t2 - t1) else next_moment_type = "FootRight" prc = MulDivRound(t2, 1000, t1 - t2) end local dist_to_stop = 0 local stop_pos = self:GetPos() for i = #path, 1, -1 do local p2 = point(point_unpack(path[i])) dist_to_stop = dist_to_stop + stop_pos:Dist2D(p2) stop_pos = p2 end local duration = GetAnimDuration(self:GetEntity(), stop_anim) local start_idx = (not next_moment_type or moments[1].Type == next_moment_type) and 1 or 2 local step = next_moment_type and 2 or 1 local best_phase, best_value for i = start_idx, #moments, step do local phase = moments[i].Time if prc > 0 then local d = i == 1 and moments[2].Time - phase or phase - moments[i-1].Time phase = phase - MulDivRound(d, prc, 1000) end if phase >= 0 then local len = self:GetStepVector(stop_anim, 0, phase, duration - phase):Len2D() local value = abs(len - dist_to_stop) if not best_value or value < best_value then best_phase = phase best_value = value end end end self:SetAnimChannel(1, stop_anim, 0, -1, 100, const.StopAnimCrossfadeTime) self:SetAnimPhase(1, best_phase or 0) self:Face(stop_pos, 200) return dist_to_stop end function Unit:Teleport(pos, angle) self:LeaveEmplacement(true) self:HolsterBombardWeapon() self.return_pos = false if not pos then pos = pos or GetPassSlab(self) or SnapToVoxel(self:GetPos()) end self:SetPos(pos) if self:IsDead() then self:SetFootPlant(true, 0) else Msg("UnitAnyMovementStart", self) local orientation_angle = self:GetPosOrientation(pos, angle, self.stance, true, true) local base_idle = self:GetIdleBaseAnim() self:SetRandomAnim(base_idle, 0, 0, true) self:SetOrientationAngle(orientation_angle, 0) self:SetFootPlant(true, 0) self:SetTargetDummy(pos, orientation_angle, base_idle, 0) CombatPathReset(self) self:ProvokeOpportunityAttacks(CombatActions.Move, "move") Msg("UnitMovementDone", self) RedeploymentCheckDelayed() end end function Unit:UpdateMoveAnimFromStyle(move_style_id, next_pt) move_style_id = move_style_id or self.cur_move_style local move_style = GetAnimationStyle(self, move_style_id) if not move_style then return false end local anim, rotation_time if self:GetStepLength() == 0 then if next_pt and (move_style.MoveStart_Left or "") ~= "" and (move_style.MoveStart_Right or "") ~= "" then local start_angle = self:GetVisualOrientationAngle() local angle = CalcOrientation(self, next_pt) local angle_diff = AngleDiff(angle, start_angle) if abs(angle_diff) >= 30*60 then local rotate_anim = angle_diff < 0 and move_style.MoveStart_Left or move_style.MoveStart_Right if rotate_anim and IsValidAnim(self, rotate_anim) then anim = rotate_anim rotation_time = GetAnimDuration(self:GetEntity(), anim) end end end if not anim and move_style.MoveStart and IsValidAnim(self, move_style.MoveStart) then anim = move_style.MoveStart end end if not anim then anim = self:GetStateText() if self:GetAnimPhase() == 0 or self:IsAnimEnd() or not move_style:HasMoveAnim(anim) then anim = move_style:GetRandomMoveAnim(self) if not anim or not IsValidAnim(self, anim) then local msg = string.format('Missing animation style "%s - %s" animation "%s". Gender: "%s". Entity: "%s". Appearance: %s', move_style.group, move_style.Name, anim or "", self.gender, self:GetEntity(), self.Appearance or "false") StoreErrorSource(self, msg) return false end end end if self:GetStepLength(anim) == 0 then local msg = string.format('Animation step is ziro! animation "%s". Entity: "%s"', anim or "", self:GetEntity()) StoreErrorSource(self, msg) return false end self:SetMoveAnim(anim) self:SetRotationTime(rotation_time or 0) self:SetMoveTurnAnim(nil, nil) self:ChangePathFlags(const.pfAnimEnd) if (move_style.StepFX or "") ~= "" then self.move_step_fx = move_style.StepFX else if string.match(move_style.VariationGroup, "Run") then self.move_step_fx = "StepRun" else self.move_step_fx = "StepWalk" end end self.move_stop_anim_len = 0 self.move_stop_foot_left_anim = false self.move_stop_foot_right_anim = false if (move_style.MoveStop_FootLeft or "") ~= "" and IsValidAnim(self, move_style.MoveStop_FootLeft) then self.move_stop_anim_len = self:GetStepLength(move_style.MoveStop_FootLeft) self.move_stop_foot_left_anim = move_style.MoveStop_FootLeft if (move_style.MoveStop_FootRight or "") ~= "" and IsValidAnim(self, move_style.MoveStop_FootRight) then self.move_stop_foot_right_anim = move_style.MoveStop_FootRight else self.move_stop_foot_right_anim = self.move_stop_foot_left_anim end end return true end function Unit:UpdateMoveAnim(action_id, anim_type, next_pt) if IsRealTimeThread() then --this func does interact rands --it is called in a rt when loading session data due to cascade onsetwhatevers --seems safe to ignore such calls, in fact it also asserts when called like that -> --turn_l and turn_r remain nil when move_anim doesn't return end NetUpdateHash("Unit:UpdateMoveAnim", action_id, anim_type, next_pt) local use_combat_anims self.cur_move_style = false if g_Combat and self:IsAware() then use_combat_anims = true elseif self.species == "Human" then local move_style = self:GetCommandParam("move_style") if not move_style then if self:IsVisiting() then move_style = self:GetDefaultMoveStyle() elseif not move_style and const.MercWalkStyle and self:IsMerc() then move_style = const.MercWalkStyle if type(move_style) ~= "string" or not GetAnimationStyle(self, move_style) then move_style = self:GetDefaultMoveStyle() end end end if move_style and self:UpdateMoveAnimFromStyle(move_style, next_pt) then self.cur_move_style = move_style end end NetUpdateHash("Unit:UpdateMoveAnim01", self.cur_move_style) if not self.cur_move_style or self.carry_flare then self:ChangePathFlags(0, const.pfAnimEnd) -- anim_type can be: Run, Walk, WalkSlow anim_type = anim_type or self:GetCommandParam("move_anim") local move_anim, turn_l, turn_r if self.species ~= "Human" then if anim_type ~= "Run" and use_combat_anims and g_Combat then anim_type = "Run" end move_anim = anim_type == "Run" and "run" or "walk" if IsValidAnim(self, move_anim) then NetUpdateHash("Unit:UpdateMoveAnim0", move_anim) move_anim = self:GetRandomAnim(move_anim) end if self.species == "Crocodile" or self.species == "Hyena" then if anim_type == "Run" then turn_l, turn_r = "run_Turn_L", "run_Turn_R" else turn_l, turn_r = "walk_Turn_L", "walk_Turn_R" end end elseif self.carry_flare then move_anim = "nw_Standing_Patrol_Flare" anim_type = "Walk" elseif use_combat_anims then local prefix = self:GetWeaponAnimPrefix() if action_id == "Charge" and prefix == "mk_" then local weapon, weapon2 = self:GetActiveWeapons() if IsKindOf(weapon, "MacheteWeapon") then move_anim = "mk_Standing_Machete_Run" anim_type = "Run" end end if not move_anim or not IsValidAnim(self, move_anim) then if self.stance == "Standing" then if anim_type == "Run" then move_anim = string.format("%sStanding_CombatRun", prefix) elseif anim_type == "Walk" or anim_type == "WalkSlow" then move_anim = string.format("%sStanding_CombatWalk", prefix) end elseif self.stance == "Crouch" then move_anim = string.format("%sStanding_CombatWalk", prefix) anim_type = "Walk" end end if move_anim and IsValidAnim(self, move_anim) then local cur_anim = self:GetStateText() NetUpdateHash("Unit:UpdateMoveAnim1", cur_anim, move_anim, IsAnimVariant(cur_anim, move_anim)) move_anim = IsAnimVariant(cur_anim, move_anim) and cur_anim or self:GetRandomAnim(move_anim) end end if not move_anim or not IsValidAnim(self, move_anim) then local default_walk_style = (use_combat_anims or IsMerc(self) or self.stance ~= "Standing") and "Run" or "Walk" if not anim_type or (anim_type == "Walk" or anim_type == "WalkSlow") and self.stance ~= "Standing" then anim_type = default_walk_style end move_anim = self:GetActionBaseAnim(anim_type or default_walk_style, self.stance) if not move_anim and anim_type ~= default_walk_style then move_anim = self:GetActionBaseAnim(default_walk_style, "Standing") end if move_anim and (not self.behavior or RandomWalkAnimBehaviors[self.behavior]) then local cur_anim = self:GetStateText() NetUpdateHash("Unit:UpdateMoveAnim1", cur_anim, move_anim, IsAnimVariant(cur_anim, move_anim)) move_anim = IsAnimVariant(cur_anim, move_anim) and cur_anim or self:GetRandomAnim(move_anim) end end self.move_step_fx = self.stance == "Prone" and "StepRunProne" or self.stance == "Crouch" and "StepRunCrouch" or anim_type == "Run" and "StepRun" or "StepWalk" local base_idle = self:GetIdleBaseAnim() self:SetMoveAnim(move_anim or self:GetIdleBaseAnim()) self:SetRotationTime(0) self:SetMoveTurnAnim(turn_l, turn_r) end self:SetWaitAnim(self:GetIdleBaseAnim()) self:UpdateMoveSpeed() self:UpdatePFClass() end function Unit:GetDefaultMoveStyle() if self.carry_flare then return GetRandomAnimationStyle(self, "Flare") end if not self.default_move_style then local style = self:GetRandomMoveStyle() if style then self.default_move_style = style.Name end end return self.default_move_style end function Unit:CalcMoveSpeedModifier() local modifier = 1000 if terrain.GetPassType(self:GetVisualPosXYZ()) == pathfind_water_pass_type_idx then modifier = modifier + 10 * Presets.ConstDef["Action Point Costs"].WaterMoveSpeedModifier.value end if self:HasStatusEffect("Hidden") then modifier = MulDivRound(modifier, 700, 1000) end local command_move_speed_modifier = self:GetCommandParam("move_speed_modifier") if command_move_speed_modifier then modifier = MulDivRound(modifier, command_move_speed_modifier, 1000) end return modifier end function Unit:UpdateMoveSpeed() local modifier = self:CalcMoveSpeedModifier() local speed if not g_Combat and self:IsMerc() then local move_anim = GetStateName(self:GetMoveAnim()) local is_running = string.match(move_anim, ".*Run.*") and true or false if is_running then -- fixed speed for mercs if self.stance == "Standing" then speed = const.UnitMoveSpeed.MercStandingStance elseif self.stance == "Crouch" then speed = const.UnitMoveSpeed.MercCrouchStance elseif self.stance == "Prone" then speed = const.UnitMoveSpeed.MercProneStance end else if self.stance == "Standing" then speed = const.UnitMoveSpeed.MercWalk end end end if speed then local mod = MulDivRound(modifier, self:GetAnimSpeedModifier(), 1000) speed = MulDivRound(speed, mod, 1000) self:SetSpeed(speed) else self:SetMoveSpeed(modifier) end -- debug set speed on zero speed animations if self:GetSpeed() == 0 then self:SetSpeed(self.fallback_walk_speed) end end function Unit:UpdateInWaterFX() local fx_in_water = not self:IsDead() and terrain.GetPassType(self:GetVisualPosXYZ()) == pathfind_water_pass_type_idx if self.fx_in_water ~= fx_in_water then if fx_in_water then PlayFX("UnitInWater", "start", self) else PlayFX("UnitInWater", "end", self) end self.fx_in_water = fx_in_water end end function Unit:StartMoving() self.is_moving = true local team = self.team if not (team and team.neutral and EngineOptions.ObjectDetail == "Low") then PlaceUnitWindModifierTrail(self) end end function Unit:StopMoving() self.is_moving = false RemoveUnitWindModifierTrail(self) end function Unit:GetMovementNoise() local stance = self.species == "Human" and self.stance or "Standing" return Presets.CombatStance.Default[stance].Noise end function Unit:InteractTunnel(tunnel, quick_play) return tunnel:InteractTunnel(self, quick_play) end -- collision avoidance. offset pos2 to the right pass cell corner function GetTunnelExitCollisionAvoidPos(pos1, pos2) local x1, y1, z1 = SnapToVoxel(pos1:xyz()) local x2, y2, z2 = SnapToVoxel(pos2:xyz()) if x1 == x2 and y1 == y2 then return pos2 end local offset = const.SlabSizeX / 4 if x1 == x2 then if y1 < y2 then x2 = x2 - offset --y2 = y2 - offset elseif y1 > y2 then x2 = x2 + offset - 1 --y2 = y2 + offset - 1 end elseif y1 == y2 then if x1 < x2 then --x2 = x2 - offset y2 = y2 + offset - 1 elseif x1 > x2 then --x2 = x2 + offset - 1 y2 = y2 - offset end elseif x1 < x2 then if y1 < y2 then x2 = x2 - offset elseif y1 > y2 then y2 = y2 + offset - 1 end elseif x1 > x2 then if y1 > y2 then x2 = x2 + offset - 1 elseif y1 < y2 then y2 = y2 - offset end end if z2 then z2 = GetVoxelStepZ(x2, y2, z2) end return point(x2, y2, z2) end MapVar("__unit_step_target_dummies", {{phase = 0}}) function Unit:TraverseTunnel(tunnel, pos1, pos2, collision_avoidance, quick_play, use_stop_anim) local tunnel_entrance = tunnel:GetEntrance() local tunnel_exit = tunnel:GetExit() if not pos1 then pos1 = tunnel_entrance end if not pos2 then pos2 = tunnel_exit end -- check for a dead end way local dead_end if not g_Combat and (tunnel.tunnel_type & const.TunnelMaskWalk) == 0 then local side = self.team and self.team.side if side == "player1" or side == "player2" then local tunnel_mask = pathfind[self:GetPfClass() + 1].tunnel_mask local reverse_tunnel = pf.GetTunnel(tunnel_exit, tunnel_entrance, tunnel_mask) if not reverse_tunnel then if IsStuckedMercPos(self, pos2) then dead_end = true if self:IsInterruptable() then self:Interrupt() return end end end end end if collision_avoidance ~= false and self:GetPathFlags(const.pfmCollisionAvoidance) ~= 0 and terrain.IsPassable(pos2) then local pcount = self:GetPathPointCount() local next_pos = pcount > 1 and self:GetPathPoint(pcount - 1) if next_pos and next_pos:IsValid() and (tunnel.tunnel_type & const.TunnelTypeLadder) == 0 and not CanDestlock(pos2, 300) then pos2 = GetTunnelExitCollisionAvoidPos(pos1, pos2) -- offset pos2 to the right pass cell corner pf.SetPathPoint(self, -1, pos2) -- the path execution expects the unit to be at the last position of the path end end local interrupts if not self.combat_path and self.team and self.team.side ~= "neutral" then local target_dummy = __unit_step_target_dummies[1] target_dummy.obj = self target_dummy.anim = self:GetWaitAnim() interrupts = self:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", __unit_step_target_dummies) if interrupts then self:ProvokeOpportunityAttacksWarning("move", interrupts) end end local wasInterruptable = self.interruptable if wasInterruptable then self:EndInterruptableMovement() end if not quick_play and (tunnel.tunnel_type & const.TunnelMaskTraverseWait) ~= 0 then self:TunnelBlock(tunnel_entrance, tunnel_exit) self:TunnelBlock(tunnel_exit, tunnel_entrance) end self.traverse_tunnel = tunnel tunnel:TraverseTunnel(self, pos1, pos2, quick_play, use_stop_anim) self.traverse_tunnel = false if not quick_play and (tunnel.tunnel_type & const.TunnelMaskTraverseWait) ~= 0 then self:TunnelUnblock(tunnel_exit, tunnel_entrance) self:TunnelUnblock(tunnel_entrance, tunnel_exit) end self:SetFootPlant(true) if dead_end then RedeploymentCheckDelayed() end if wasInterruptable then self:BeginInterruptableMovement() end if interrupts then self:ProvokeOpportunityAttacksFromList(interrupts) end end function Unit:GotoStopCheck(stop_anim_tunnel_idx) if self.move_stop_anim_len == 0 then return false end if not stop_anim_tunnel_idx then stop_anim_tunnel_idx = pf.GetPathNextTunnelIdx(self, const.TunnelMaskWalkStopAnim) or 1 end local pcount = self:GetPathPointCount() if pcount <= stop_anim_tunnel_idx + 4 then local pathlen = self:GetPathLen(stop_anim_tunnel_idx) local step_dist = self:GetVisualDist2D(self:GetPosXYZ()) if pathlen - step_dist < self.move_stop_anim_len then local dest = self:GetPathPoint(stop_anim_tunnel_idx) if dest and not dest:IsValid() then stop_anim_tunnel_idx = stop_anim_tunnel_idx - 1 dest = self:GetPathPoint(stop_anim_tunnel_idx) end if not dest or not dest:IsValid() then return false end -- clear path points before dest for i = self:GetPathPointCount(), stop_anim_tunnel_idx + 1, -1 do pf.SetPathPoint(self, i) end local wait_time = pathlen > self.move_stop_anim_len and pf.GetMoveTime(self, pathlen - self.move_stop_anim_len) or 0 if wait_time > 0 then Sleep(wait_time) end self:GotoStop(dest) return true end end return false end function Unit:GotoStop(dest) local l1 = self:TimeToMoment(1, "FootLeft", -1) local l2 = self:TimeToMoment(1, "FootLeft", 1) local r1 = self:TimeToMoment(1, "FootRight", -1) local r2 = self:TimeToMoment(1, "FootRight", 1) local t1, t2, anim, anim1, anim2 if l1 and (not r1 or l1 < r1) then t1, anim1 = l1, self.move_stop_foot_left_anim else t1, anim1 = r1, self.move_stop_foot_right_anim end if l2 and (not r2 or l2 < r2) then t2, anim2 = l2, self.move_stop_foot_left_anim else t2, anim2 = r2, self.move_stop_foot_right_anim end if t1 and t2 then local t = t1 + t2 local perc = t > 0 and t1 * 100 / t or 0 if perc < const.AmbientLife.WalkStopMomentProximity then anim = anim1 else anim = anim2 end elseif t1 then anim = anim1 else anim = anim2 end anim = anim or self.move_stop_foot_left_anim or self.move_stop_foot_right_anim self:SetState(anim) repeat self:SetAnimSpeed(1, self:GetMoveSpeed()) local t = self:TimeToAnimEnd() self:SetPos(dest or self:GetPos(), t) until not WaitWakeup(t) end function Unit:MoveSleep(time) local end_time = now() + time repeat until not WaitWakeup(end_time - now()) or self:GetPathFlags(const.pfDirty) ~= 0 if self.cur_move_style and (self:GetAnimPhase() == 0 or self:IsAnimEnd()) then self:UpdateMoveAnimFromStyle() end end function Unit:GotoTurnOnPlace(next_pos) if not next_pos then return end local move_style = GetAnimationStyle(self, self.cur_move_style) if move_style then if move_style.MoveStart_Left or move_style.MoveStart_Right then return -- move turn animations will be played if necessary end local angle = CalcOrientation(self, next_pos) self:AnimatedRotation(angle) return end if self:IsVisiting() then return -- move style support civilian end if self.stance ~= "Prone" then return end local angle = CalcOrientation(self, next_pos) local angle_diff = AngleDiff(angle, self:GetVisualOrientationAngle()) local min_turn_angle if self:GetState() == self:GetMoveAnim() then min_turn_angle = const.GotoTurnOnPlaceMovingAngle else min_turn_angle = const.GotoTurnOnPlaceAngle end if abs(angle_diff) > min_turn_angle then self:AnimatedRotation(angle) end end DefineConstInt("AmbientLife", "ForbidVisitEnemyDist", 2, "m", "If player around this distance the enemy AL won't use chairs to sit(coming close will also interrupt this behavior normally)") function Unit:IdleForcingDist(other) if self.waiting_attack then return true end local dist = self:GetDist(other) if dist <= const.AmbientLife.ForbidSitChairEnemyDist and IsSittingUnit(self) then return true end if dist <= const.AmbientLife.ForbidWallLeanEnemyDist and IsWallLeaningUnit(self) then return true end if dist <= const.AmbientLife.ForbidVisitEnemyDist and (self.routine == "Ambient" or self.routine == "Patrol") then return true end end function Unit:ShouldBeIdle(other) if self.command == "Die" or self.command == "Dead" then return false end if g_Combat or GameState.sync_loading or self.team and self.team.player_team then return true end if not g_Combat and (self.neutral_retal_attacked) then return true end if not IsValid(other) then local enemies = GetAllEnemyUnits(self) for _, unit in ipairs(enemies) do if self:ShouldBeIdle(unit) then return true end end else if self:IdleForcingDist(other) then return true end end end function Unit:GotoAction() if self.carry_flare then ResetVoxelStealthParamsCache() -- todo: partial invalidate would be cool (prev pos + current pos) end self:InvalidatePindownLinesCache() if not g_Combat and self:HasStatusEffect("Suspicious") then self:SetCommand("Idle") return end if self.goto_stance then self:GotoChangeStance(self.goto_stance) self.goto_stance = false if self:IsInterruptable() then self:BeginInterruptableMovement() end end if self.goto_hide then self.goto_hide = false self:Hide() if self:IsInterruptable() then self:BeginInterruptableMovement() end end if self.team then local enemies = GetAllEnemyUnits(self) if self.team.player_team then for _, unit in ipairs(enemies) do if unit:ShouldBeIdle(self) then unit:SetCommandParamValue("Idle", "idle_forcing_dist", self) unit:SetCommand("Idle") end end end if self.team.player_enemy then for _, unit in ipairs(enemies) do if self:ShouldBeIdle(unit) then self:SetCommandParamValue("Idle", "idle_forcing_dist", unit) self:SetCommand("Idle") end end end for _, enemy in ipairs(enemies) do local attacker, target if self.marked_target_attack_args and self.marked_target_attack_args.target == enemy then attacker, target = self, enemy elseif enemy.marked_target_attack_args and enemy.marked_target_attack_args.target == self then attacker, target = enemy, self end if attacker and target then local action = attacker:GetDefaultAttackAction() local weapon = action:GetAttackWeapons(attacker) if not IsKindOfClasses(weapon, "MeleeWeapon", "UnarmedWeapon") then attacker.marked_target_attack_args = nil elseif attacker:CanAttack(target, weapon, action) and IsMeleeRangeTarget(attacker, nil, nil, target) then local args = attacker.marked_target_attack_args TutorialHintsState.SneakMode = not not args TutorialHintsState.SneakApproach = not not args NetStartCombatAction(action.id, attacker, 0, args) break end end end end end function Unit:WaitResumeOnCommandStart() if not GameTimeAdvanced then return end if IsValid(self.queued_action_visual) then DoneObject(self.queued_action_visual) self.queued_action_visual = false end if not g_Combat and IsActivePaused() and self:HasPreparedAttack() then self:InterruptPreparedAttack() end while not g_Combat and IsActivePaused() do WaitMsg("Resume", 500) end end local pfFinished = const.pfFinished local pfFailed = const.pfFailed local pfDestLocked = const.pfDestLocked local pfTunnel = const.pfTunnel local gofRealTimeAnimMask = const.gofRealTimeAnim | const.gofEditorSelection function Unit:GotoSlab(pos, distance, min_distance, move_anim_type, follow_target, use_stop_anim, interrupted) self:WaitResumeOnCommandStart() assert(self:GetGameFlags(gofRealTimeAnimMask) == 0) Msg("UnitAnyMovementStart", self) if use_stop_anim == nil then use_stop_anim = true end if use_stop_anim ~= false and self.move_stop_anim_len == 0 then use_stop_anim = false end self:SetTargetDummy(false) if self:TimeToPosInterpolationEnd() > 0 then local cur_pos = self:GetVisualPos() if not self:IsValidZ() then cur_pos = cur_pos:SetInvalidZ() end self:SetPos(cur_pos) end local dest, follow_pos if not pos and IsValid(follow_target) then dest = self:GetClosestMeleeRangePos(follow_target) follow_pos = follow_target:GetPos() elseif not pos then local tunnel_param = { unit = self, player_controlled = self.team and self.team:IsPlayerControlled(), } dest = GetCombatPathDestinations(self, nil, nil, nil, tunnel_param, nil, 2 * const.SlabSizeX, false, false, true) for i, packed_pos in ipairs(dest) do dest[i] = point(point_unpack(packed_pos)) end elseif IsPoint(pos) then if self:GetPathFlags(const.pfmVoxelAligned) ~= 0 then dest = self:GetVoxelSnapPos(pos) end elseif self:GetPathFlags(const.pfmVoxelAligned) ~= 0 then for i = 1, #pos do local pt = self:GetVoxelSnapPos(pos[i]) if pt then dest = dest or {} table.insert_unique(dest, pt) end end end dest = dest or pos local status = self:FindPath(dest, distance, min_distance) if self:GetPathPointCount() == 0 then if status == 0 then return true end return end -- cleanup stuff that prevents moving if self:HasStatusEffect("StationedMachineGun") then self:MGPack() elseif self:HasStatusEffect("ManningEmplacement") then self:LeaveEmplacement() elseif self:HasPreparedAttack() then self:InterruptPreparedAttack() end -- the target used for opportunity attacks self:SetTargetDummy(false) self:SetActionInterruptCallback(function(self) if not IsActivePaused() then self:SetCommand("GotoSlab") else self:SetQueuedAction() self:SetCommand("Idle") end end) self.goto_interrupted = interrupted self:PushDestructor(function(self) self:SetActionInterruptCallback() self.move_follow_target = nil self.move_follow_dest = nil self.goto_interrupted = nil end) self.move_follow_target = follow_target self.move_follow_dest = dest self:SetFootPlant(true) self.goto_target = pos local pfStep = self.Step local pfSleep = self.MoveSleep local target, target_time local is_moving = false Msg("UnitGoToStart", self) while true do self:TunnelsUnblock() if follow_pos and IsValid(follow_target) then if IsKindOf(follow_target.traverse_tunnel, "SlabTunnelLadder") then follow_target = false break end if follow_target:GetDist(follow_pos) > 0 then dest = self:GetClosestMeleeRangePos(follow_target) target = false follow_pos = follow_target:GetPos() self.move_follow_dest = dest end end if not target or self:GetPathPointCount() == 0 then status = self:FindPath(dest, distance, min_distance) if self:GetPathPointCount() == 0 then if status == 0 then status = pfFinished end break end local tunnel_start_idx = pf.GetPathNextTunnelIdx(self, const.TunnelMaskTraverseWait) if tunnel_start_idx then local tunnel_entrance = pf.GetPathPoint(self, tunnel_start_idx) local tunnel_exit = pf.GetPathPoint(self, tunnel_start_idx - 2) local last_target = target or self:GetPos() target = nil if last_target == tunnel_entrance or type(last_target) == "table" and table.find(last_target, tunnel_entrance) then if CanUseTunnel(tunnel_entrance, tunnel_exit, self) then self:TunnelBlock(tunnel_entrance, tunnel_exit) self:TunnelBlock(tunnel_exit, tunnel_entrance) local tunnel_start_idx2 = pf.GetPathNextTunnelIdx(self, const.TunnelMaskTraverseWait, tunnel_start_idx - 2) if tunnel_start_idx2 then local tunnel_entrance2 = pf.GetPathPoint(self, tunnel_start_idx2) local tunnel_exit2 = pf.GetPathPoint(self, tunnel_start_idx2 - 2) target = GetAlternateRoutesStartPoints(self, tunnel_entrance2, tunnel_exit2, const.TunnelMaskTraverseWait) else target = dest end end end if not target then target = GetAlternateRoutesStartPoints(self, tunnel_entrance, tunnel_exit, const.TunnelMaskTraverseWait) end elseif target ~= dest then target = dest end target_time = now() if target ~= dest then self:FindPath(target) end local pcount = self:GetPathPointCount() local next_pt = pcount > 1 and pf.GetPathPoint(self, pcount - 1) or nil if next_pt and not next_pt:IsValid() then next_pt = pcount > 2 and pf.GetPathPoint(self, pcount - 2) or nil end local angle = next_pt and CalcOrientation(self.target_dummy or self, next_pt) self:UpdateMoveAnim(nil, move_anim_type, next_pt) local move_anim = GetStateName(self:GetMoveAnim()) if not GameTimeAdvanced then if next_pt then self:Face(next_pt) end if self:GetStateText() ~= move_anim then self:SetState(move_anim, 0, 0) self:RandomizeAnimPhase() end else self:PlayTransitionAnims(move_anim, angle) self:GotoTurnOnPlace(next_pt) -- face next target point end end local target_distance, target_min_distance if target == dest then target_distance = distance target_min_distance = min_distance else -- approaching tunnel. the tunnel can be destlocked, so the unit should destlock and wait end local wait status = pfStep(self, target, target_distance, target_min_distance) if status > 0 then if not is_moving then is_moving = true self:StartMoving() end while status > 0 do if not use_stop_anim or not self:GotoStopCheck() then pfSleep(self, status) end self:GotoAction() if follow_pos and IsValid(follow_target) and not follow_target:IsEqualPos(follow_pos) then if IsKindOf(follow_target.traverse_tunnel, "SlabTunnelLadder") then status = pfFinished dest = self:GetPos() target = dest break end local newdest = self:GetClosestMeleeRangePos(follow_target) if newdest ~= dest then dest = newdest target = newdest end end status = pfStep(self, target, target_distance, target_min_distance) end end if status == pfFinished and target == dest then break elseif status == pfFinished and target_time ~= now() then target = nil elseif status == pfTunnel then if IsActivePaused() then Sleep(1) end self:ClearEnumFlags(const.efResting) -- resting flag block the later ClearPath() local tunnel = pf.GetTunnel(self) if not tunnel then status = pfFailed break end if not self:InteractTunnel(tunnel) then status = pfFailed break end if not IsValid(tunnel) then tunnel = pf.GetTunnel(self) end local tunnel_in_use = IsValid(tunnel) and (tunnel.tunnel_type & const.TunnelMaskTraverseWait) ~= 0 and not CanUseTunnel(tunnel:GetEntrance(), tunnel:GetExit(), self) if IsValid(tunnel) and not tunnel_in_use then if not is_moving then is_moving = true self:StartMoving() end local use_stop_anim = self.move_stop_anim_len > 0 and (pf.GetPathNextTunnelIdx(self, const.TunnelMaskWalkStopAnim) or 1) == self:GetPathPointCount() self:TraverseTunnel(tunnel, nil, nil, true, false, use_stop_anim) self:GotoAction() else target = nil -- the path is invalid, a new one should be cast wait = tunnel_in_use end elseif target ~= dest then wait = true else break end if wait then local anim = self:GetWaitAnim() if self:GetState() ~= anim then self:SetState(anim, const.eKeepComponentTargets) end local target_pos if IsPoint(target) then target_pos = target elseif IsValid(target) then target_pos = target:GetPos() else for i, p in ipairs(target) do if i == 1 or IsCloser(self, p, target_pos) then target_pos = p end end end if target_pos and not self:IsEqualPos2D(target_pos) then self:Face(target_pos) end if is_moving then is_moving = false self:StopMoving() end self:ClearPath() pfSleep(self, 200) self:GotoAction() end end self:PopAndCallDestructor() self.goto_target = false if is_moving then self:StopMoving() end Msg("UnitMovementDone", self) Msg("UnitGoTo", self) ObjModified(self) return status == pfFinished end function Unit:UninterruptableGoto(pos, straight_line) self:WaitResumeOnCommandStart() local wasInterruptable = self.interruptable if wasInterruptable then self:EndInterruptableMovement() end pos = self:GetVoxelSnapPos(pos) or pos assert(not self.goto_target) -- should be a top level Goto self.goto_target = pos self:UpdateMoveAnim() if straight_line then self:Goto(pos, "sl") else self:Goto(pos) end self.goto_target = false Msg("UnitMovementDone", self) if wasInterruptable then self:BeginInterruptableMovement() end end function Unit:Step(...) self:UpdateMoveSpeed() local status = AnimMomentHook.Step(self, ...) if status > 0 then if not self.combat_path and self.team and not self.team.neutral then local target_dummy = __unit_step_target_dummies[1] target_dummy.obj = self target_dummy.anim = self:GetWaitAnim() local interrupts = self:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", __unit_step_target_dummies) self:ProvokeOpportunityAttacksWarning("move", interrupts) self:ProvokeOpportunityAttacks(CombatActions.Move, "move", target_dummy) -- traps end self:UpdateInWaterFX() end return status end function Unit:Goto(...) local pfStep = self.Step self:SetTargetDummy(false) self:UpdateMoveAnim() local status = pfStep(self, ...) if status >= 0 or status == pfTunnel then local topmost_goto, is_moving if not self.goto_target then topmost_goto = true self.goto_target = ... end local pfSleep = self.MoveSleep while true do if status > 0 then if not is_moving then is_moving = true self:StartMoving() end pfSleep(self, status) elseif status == pfTunnel then local tunnel = pf.GetTunnel(self) if not tunnel then status = pfFailed break end if not is_moving then is_moving = true self:StartMoving() end if not self:InteractTunnel(tunnel) then status = pfFailed break end if IsValid(tunnel) then self:TraverseTunnel(tunnel) end else break end status = pfStep(self, ...) end if is_moving then self:StopMoving() end if topmost_goto then self.goto_target = false end CombatPathReset(self) ObjModified(self) end local res = status == pfFinished Msg("UnitGoTo", self) return res end function Unit:IsInterruptable() if self.interruptable then if not self.goto_stance and not self.goto_hide then return true end end if self:IsIdleCommand() or self:HasQueuedAction() then return true end return false end function Unit:IsInterruptableMovement() return self.interruptable and (self.goto_target or self.move_attack_in_progress) end function Unit:InterruptCommand(...) self:Interrupt("SetCommand", ...) end function NetSyncEvents.InterruptCommand(unit, ...) unit:InterruptCommand(...) end function Unit:SetActionInterruptCallback(func) self.action_interrupt_callback = func end function Unit:Interrupt(func, ...) if self:IsInterruptable() then if not func and self.action_interrupt_callback then func = self.action_interrupt_callback self.action_interrupt_callback = false end if not func then return end if type(func) ~= "function" then func = self[func] end assert(func) if func then func(self, ...) end return end self.interrupt_callback = pack_params(func or false, ...) end function Unit:BeginInterruptableMovement() self.interruptable = true local callback = self.interrupt_callback if callback then self.interrupt_callback = false self:Interrupt(unpack_params(callback)) end end function Unit:EndInterruptableMovement() self.interruptable = false end function Unit:IsEnemyPresent() if g_Combat then return true end local dlg = GetInGameInterfaceModeDlg() if dlg and dlg:HasMember("teams") then for i, t in ipairs(dlg.teams) do if not t.player_ally then return true end end end return false end function Unit:GetVoxelSnapPos(pos, angle, stance) if pos then if not pos:IsValid() then return end elseif not self:IsValidPos() then return end if not angle then angle = self:GetOrientationAngle() end local face_posx, face_posy, face_posz = RotateRadius(const.SlabSizeX, angle, pos or self, true) local pos = SnapToPassSlabSegment(pos or self, face_posx, face_posy, face_posz, const.TunnelMaskWalk) if not pos then return end if (stance or self.stance) == "Prone" then angle = FindProneAngle(self, pos, angle) end return pos, angle end function Unit:GetGridCoords() local x, y, z = self:GetPosXYZ() return PosToGridCoords(x, y, z) end function PosToGridCoords(x, y, z) z = z or terrain.GetHeight(x, y) local gx, gy, gz = WorldToVoxel(x, y, z) while true do local wx, wy, wz = VoxelToWorld(gx, gy, gz) if wz < z then gz = gz + 1 else break end end return gx, gy, gz end function Unit:EnterCombat() local 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 self:SetTargetDummyFromPos() end self:UpdateAttachedWeapons() if HasPerk(self, "SharpInstincts") then if self.stance == "Standing" then self:DoChangeStance("Crouch") end self:ApplyTempHitPoints(CharacterEffectDefs.SharpInstincts:ResolveValue("tempHP")) end if self:HasStatusEffect("ManningEmplacement") and self == SelectedObj then self:FlushCombatCache() self:RecalcUIActions(true) ObjModified("combat_bar") end Msg("UnitEnterCombat", self) if wasInterruptable then self:BeginInterruptableMovement() end end function Unit:AutoReload() local weapon = self:GetActiveWeapons("Firearm") local weapons = self:GetEquippedWeapons(self.current_weapon, "Firearm") local needs_reload local i = 1 while not needs_reload and (i <= #weapons) do local w = weapons[i] if w and (w.ammo and w.ammo.Amount or 0) < w.MagazineSize then weapon = w needs_reload = true break end for slot, sub in sorted_pairs(weapons[i].subweapons) do if IsKindOf(sub, "Firearm") then weapons[#weapons + 1] = sub end end i = i + 1 end --if weapon and (weapon.ammo and weapon.ammo.Amount or 0) < weapon.MagazineSize then if needs_reload then Sleep(self:Random(1000)) local _, err = CombatActions.Reload:GetUIState({self}) if err == AttackDisableReasons.NoAmmo then if not weapon.ammo or weapon.ammo.Amount == 0 then PlayVoiceResponse(self, "NoAmmo") else PlayVoiceResponse(self, "AmmoLow") end else RunCombatAction("ReloadMultiSelection", self, 0, {reload_all = true}) end end end function Unit:ExitCombat() if self:IsNPC() and not self.dummy and (not self:IsDead() or self.immortal) then if self.retreating then local markers = MapGetMarkers("Entrance") local nearest, dist for _, marker in ipairs(markers) do local d = self:GetDist(marker) if not nearest or d < dist then nearest, dist = marker, d end end assert(nearest) self:SetCommand("ExitMap", nearest) end end self.ActionPoints = self:GetMaxActionPoints() -- reset AP so the units has all their actions available to initiate combat self.performed_action_this_turn = false if self:IsDowned() then self:SetCommand("DownedRally") elseif not self:IsDead() then self:PushDestructor(function() self:AutoReload() end) self:LeaveEmplacement(false, "exit combat") if self.combat_behavior == "Bandage" then self:EndCombatBandage(nil, "instant") elseif self.behavior == "Bandage" then self:SetBehavior() end if g_Pindown[self] then self:InterruptPreparedAttack() end if self:IsNPC() then if self.spawner then local x, y, z = self:GetGridCoords() local sx, sy, sz = PosToGridCoords(self.spawner:GetPosXYZ()) if x == sx and y == sy and z == sz then local spawner_angle = self.spawner:GetAngle() self:SetAngle(spawner_angle) end end end if self:IsMerc() then local allEnemies = GetAllEnemyUnits(self) local aliveEnemies = 0 for _, enemy in ipairs(allEnemies) do if not enemy:IsDead() and not enemy:IsDefeatedVillain() then aliveEnemies = aliveEnemies + 1 end end if aliveEnemies == 0 then self:DoChangeStance("Standing") end elseif self.species == "Human" and self.stance ~= "Standing" then self:DoChangeStance("Standing") end self:PopAndCallDestructor() if self:IsIdleCommand() then self:SetCommand("Idle") -- force restart to reevaluate the use of combat/civilian anims end self:UpdateAttachedWeapons() elseif self.immortal then self:ReviveOnHealth() self:ChangeStance(false, 0, "Standing") self:SetCommand("Idle") end end function Unit:GetStepActionFX() return self.is_moving and (self.move_step_fx or "StepRun") or "StepWalk" end function Unit:OnAnimMoment(moment, anim) anim = anim or GetStateName(self) local animFxName = FXAnimToAction(anim) PlayFX(animFxName, moment, self, self.anim_moment_fx_target) local anim_moments_hook = self.anim_moments_hook if type(anim_moments_hook) == "table" and anim_moments_hook[moment] then local method = moment_hooks[moment] return self[method](self, anim) end end -- behaviors function Unit:GetCommandParam(name, command) command = command or self.command return self.command_specific_params and self.command_specific_params[command] and self.command_specific_params[command][name] end function Unit:GetCommandParamsTbl(command) command = command or self.command self.command_specific_params = self.command_specific_params or {} self.command_specific_params[command] = self.command_specific_params[command] or {} return self.command_specific_params[command] end function Unit:SetCommandParamValue(command, param, value) local param_tbl = self:GetCommandParamsTbl(command) param_tbl[param] = value end function Unit:SetCommandParams(command, params) command = command or self.command self.command_specific_params = self.command_specific_params or {} if params then self.command_specific_params[command] = params if params.weapon_anim_prefix then self:UpdateAttachedWeapons() end end end if FirstLoad then UnitIdleCommands = { [false] = true, ["Idle"] = true, ["IdleSuspicious"] = true, ["AimIdle"] = true, ["PreparedAttackIdle"] = true, ["PreparedBombardIdle"] = true, ["Dead"] = true, ["VillainDefeat"] = true, ["Hang"] = true, ["Downed"] = true, ["Cower"] = true, ["CombatBandage"] = true, ["ExitMap"] = true, ["OverheardConversation"] = true, ["OverheardConversationHeadTo"] = true, } UnitPreparedAttackBehaviors = { OverwatchAction = true, PinDown = true, } UnitIgnoreEnterCombatCommands = { ["VillainDefeat"] = true, } end function Unit:IsUsingPreparedAttack() return UnitPreparedAttackBehaviors[self.combat_behavior] end function Unit:IsIdleCommand(check_pending) return UnitIdleCommands[self.command or false] and (not check_pending or (not self.pending_aware_state and not HasCombatActionInProgress(self))) end function Unit:HasQueuedAction() local action = CombatActions[self.queued_action_id] return action and (action.ActivePauseBehavior == "queue") and IsActivePaused() end function Unit:IsIdleOrRunningBehavior() if self:IsIdleCommand() then return true end if g_Combat then return self.command == self.combat_behavior end return self.command == self.behavior end function Unit:Idle() self:WaitResumeOnCommandStart() assert(self:IsValidPos()) SetCombatActionState(self, nil) self.being_interacted_with = false if not self.move_attack_in_progress then self.move_attack_target = nil end self:SetQueuedAction() ExplorationClearExclusiveAction(self) if self:IsDead() then if self.behavior == "Despawn" then self:SetCommand("Despawn") elseif self.behavior ~= "Hang" and self.behavior ~= "Dead" then self:SetBehavior("Dead") self:SetCombatBehavior("Dead") end else if self.stance == "Prone" and self:GetValidStance("Prone") ~= "Prone" then self:DoChangeStance("Crouch") end if g_Combat and self:CanCower() and (self.team.side == "neutral" or self:HasStatusEffect("ForceCower")) and not g_Combat:ShouldEndCombat() then self:SetCommand("Cower") end end self:UpdateInWaterFX() if self:IsDead() then if self.behavior == "Hang" then self:SetCommand("Hang") else assert(not self.Squad or IsMerc(self)) self:SetCommand("Dead") end end FallDownCheck(self) if self:HasStatusEffect("Unconscious") then self:SetCommand("Downed") elseif IsSetpieceActor(self) then self:SetCommand("SetpieceIdle", true) elseif self:HasStatusEffect("Suspicious") then if g_Combat then self:RemoveStatusEffect("Suspicious") else return self:SuspiciousRoutine() end elseif self:HasCommandsInQueue() then return elseif g_Combat and self.combat_behavior then self:SetCommand(self.combat_behavior, table.unpack(self.combat_behavior_params or empty_table)) elseif not g_Combat and self.behavior and not self:HasStatusEffect("Suspicious") then local enemy = self:GetCommandParam("idle_forcing_dist") if not IsValid(enemy) or not self:IdleForcingDist(enemy) then self:SetCommandParamValue(self.command, "idle_forcing_dist", nil) self:SetCommand(self.behavior, table.unpack(self.behavior_params or empty_table)) end end -- setup target dummy local anim_style = self:GetIdleStyle() local base_idle = anim_style and anim_style:GetMainAnim() or self:GetIdleBaseAnim() local can_reposition = not (g_Combat and self:IsAware()) -- if large units can change angle (occupied tiles) local pos, orientation_angle if self.return_pos and not self.play_sequential_actions then pos = self.return_pos local voxel = SnapToVoxel(self) if not pos:Equal2D(voxel) then orientation_angle = CalcOrientation(pos, voxel) end else pos = GetPassSlab(self) or self:GetPos() end local dummy_orientation_angle = self:GetPosOrientation(pos, nil, self.stance, true, can_reposition) if not orientation_angle then orientation_angle = self.auto_face and dummy_orientation_angle or self:GetPosOrientation(pos, nil, self.stance, false, can_reposition) end self:SetTargetDummy(pos, dummy_orientation_angle, base_idle, 0) if g_Combat and (not self:IsNPC() or self:IsAware()) then Msg("Idle", self) end if self.aim_action_id and not HasCombatActionInProgress(self) then self:SetCommand("AimIdle") end self:SetWeaponLightFx(false) self:SetIK("AimIK", false) if self.play_sequential_actions then self:SetCommand("SequentialActionsIdle") end -- orient if not GameTimeAdvanced then self:SetOrientationAngle(orientation_angle) else self:EndInterruptableMovement() self:PlayTransitionAnims(base_idle, orientation_angle) self:AnimatedRotation(orientation_angle, base_idle) self:BeginInterruptableMovement() end self:SetCommandParamValue("Idle", "move_anim", "WalkSlow") if self:ShouldBeIdle() then -- one animation cycle -- play current style end animation self.cur_idle_style = anim_style and anim_style.Name or nil if anim_style then local anim = self:GetStateText() if anim_style:HasAnimation(anim) then if self:GetAnimPhase() ~= 0 and not self:IsAnimEnd() then Sleep(self:TimeToAnimEnd()) end elseif anim == anim_style.Start then Sleep(self:TimeToAnimEnd()) elseif (anim_style.Start or "") ~= "" and IsValidAnim(self, anim_style.Start) then self:SetState(anim_style.Start, const.eKeepComponentTargets) Sleep(self:TimeToAnimEnd()) end self:SetState(anim_style:GetRandomAnim(self), const.eKeepComponentTargets) if not GameTimeAdvanced then self:RandomizeAnimPhase() end else if self:GetAnimPhase(1) == 0 or self:IsAnimEnd() or not IsAnimVariant(self:GetStateText(), base_idle) then self:SetRandomAnim(base_idle, const.eKeepComponentTargets, nil, true) end end Sleep(self:TimeToAnimEnd()) else self:IdleRoutine() end end function Unit:IdleSuspicious() if self.stance == "Standing" and self.species == "Human" then local anim if self.gender == "Male" then anim = self:TryGetActionAnim("IdlePassive2", self.stance) else local weapon = self:GetWeaponAnimPrefix() if weapon == "nw" or self.carry_flare then anim = self:TryGetActionAnim("IdlePassive", self.stance) elseif weapon == "mk" then anim = self:TryGetActionAnim("IdlePassive6", self.stance) else anim = self:TryGetActionAnim("IdlePassive2", self.stance) end end if self.carry_flare then anim = string.gsub(anim, "^%a*_", "nw_") end anim = self:ModifyWeaponAnim(anim) self:SetState(anim) Sleep(self:TimeToAnimEnd()) end self:SetCommand("Idle") end function Unit:TakeSlabExploration() local pos, angle = self:GetVoxelSnapPos() if not pos then return end if GameTimeAdvanced then local vx, vy = SnapToVoxel(self:GetVisualPosXYZ()) if not pos:Equal2D(vx, vy) then self:Goto(pos, "sl") pos, angle = self:GetVoxelSnapPos() end end self:SetPos(pos) self:SetOrientationAngle(angle) end function OnMsg.GatherFXMoments(list) table.insert_unique(list, "start") table.insert_unique(list, "end") table.insert(list, "action_start") table.insert(list, "action_end") table.insert(list, "WeaponGripStart") table.insert(list, "WeaponGripEnd") table.insert(list, "OrientationStart") table.insert(list, "OrientationEnd") end function Unit:OnMomentWeaponGripStart() self:SetWeaponGrip(true) end function Unit:OnMomentWeaponGripEnd() self:SetWeaponGrip(false) end function Unit:SequentialActionsStart() self.play_sequential_actions = true end function Unit:SequentialActionsEnd() if not self.play_sequential_actions then return end self.play_sequential_actions = false if self.command == "SequentialActionsIdle" then self:SetCommand("Idle") end end function Unit:SequentialActionsIdle() Halt() end function Unit:ReturnToCover(prefix, update_target_dummy) local pos = self.return_pos if not pos then return false end if not IsCloser(self, pos, const.SlabSizeX/2) and CanOccupy(self, pos) and IsPassSlabStep(self, pos) then local voxel_x, voxel_y = SnapToVoxel(self:GetPosXYZ()) local angle = pos:Equal2D(voxel_x, voxel_y, 0) and self:GetAngle() or CalcOrientation(pos, voxel_x, voxel_y, 0) if update_target_dummy ~= false then self:SetTargetDummyFromPos(pos, angle) end local side = AngleDiff(self:GetVisualOrientationAngle(), angle) < 0 and "Left" or "Right" prefix = prefix or string.match(self:GetStateText(), "^(%a+_).*") or self:GetWeaponAnimPrefix() local anim = string.format("%s%s_Aim_End", prefix, side) self:SetIK("AimIK", false) self:SetFootPlant(true) if IsValidAnim(self, anim) then if self:CanQuickPlayInCombat() then self:SetPos(pos) self:SetOrientationAngle(angle) else anim = self:ModifyWeaponAnim(anim) self:SetPos(pos, self:GetAnimDuration(anim)) self:RotateAnim(angle, anim) end else local msg = string.format('Missing animation "%s" for "%s"', anim, self.unitdatadef_id) StoreErrorSource(self, msg) self:SetPos(pos) self:SetOrientationAngle(angle) end end self.return_pos = false return true end function Unit:MovePlayAnimSpeedUpdate(anim, anim_flags, crossfade, dest) self:SetState(anim, anim_flags or 0, crossfade or -1) repeat self:SetAnimSpeed(1, self:CalcMoveSpeedModifier()) local t = self:TimeToAnimEnd() if dest then self:SetPos(dest, t) end until not WaitWakeup(t) end function Unit:MovePlayAnim(anim, pos1, pos2, anim_flags, crossfade, ground_orient, angle, start_offset, end_offset, sleep_mod, acceleration) if not anim or not self:HasState(anim) then self:SetPos(pos2) self:SetFootPlant(true) return end if not angle then if pos1:Equal2D(pos2) then angle = self:GetAngle() else angle = CalcOrientation(pos1, pos2) end end if not pos1:IsValidZ() then pos1 = pos1:SetTerrainZ() end local pos2_3d = pos2:IsValidZ() and pos2 or pos2:SetTerrainZ() local t = self:GetVisualDist(pos1) == 0 and 100 or 0 self:SetPos(pos1) self:SetFootPlant(false, t) self:SetOrientationAngle(angle, t) -- animation movement can be scaled only between "start" and "end" moments self:SetState(anim, anim_flags or const.eKeepComponentTargets, crossfade or -1) local anim_full_step = GetEntityStepVector(self:GetEntity(), anim) local use_animation_step = pos1 ~= pos2_3d and anim_full_step:Len() > 10*guic local duration = GetAnimDuration(self:GetEntity(), anim) local phase1 = self:GetAnimMoment(anim, "start") or 0 local phase2 = self:GetAnimMoment(anim, "end") if not phase2 then local hit = self:GetAnimMoment(anim, "hit") phase2 = (hit or duration) - 200 end if phase2 < phase1 then phase2 = phase1 end start_offset = start_offset or point30 end_offset = end_offset or point30 if phase1 > 0 then -- [0 - action_start] - adjust the unit at proper action start position local action_phase1 = self:GetAnimMoment(anim, "action_start") if action_phase1 then action_phase1 = Min(action_phase1, phase1) else action_phase1 = phase1 / 2 end if action_phase1 > 0 then local v = use_animation_step and self:GetStepVector(anim, angle, 0, action_phase1) or point30 local dest = pos1 + v + start_offset local t = self:TimeToPhase(1, action_phase1) or 0 self:SetPos(dest, t) Sleep(t) end -- [action_start - start scale] - play the animation as it is local v = use_animation_step and self:GetStepVector(anim, angle, 0, phase1) or point30 local dest = pos1 + v + start_offset local t = self:TimeToPhase(1, phase1) or 0 self:SetPos(dest, t) Sleep(t) end -- [scale] - the unit should reach the proper end position at the proper phase2 (the unit should be in the air) local v = use_animation_step and self:GetStepVector(anim, angle, phase2, duration - phase2) or point30 local phase2_pos = pos2_3d - v + end_offset local t = phase2 > phase1 and self:TimeToPhase(1, phase2) or 0 if t == 0 then self:SetPos(phase2_pos) if ground_orient then self:SetGroundOrientation(angle, t) end else local anim_speed, new_anim_speed if use_animation_step and self:IsCommandThread() then local extra_step_z = pos2_3d:z() - (pos1:z() + anim_full_step:z()) local scale = 1000 if anim_full_step:Len2D() > abs(anim_full_step:z()) then -- horizontal scale local extra_dist_2d = pos1:Dist2D(pos2) - anim_full_step:Len2D() if extra_dist_2d > const.SlabSizeX/4 then local anim_dist2d = abs(self:GetStepVector(anim, 0, phase1, phase2 - phase1):x()) scale = anim_dist2d > 0 and MulDivRound(1000, anim_dist2d + extra_dist_2d, anim_dist2d) or 1000000 scale = Min(scale, 1500) -- fix bugged animations with vely small steps end local fall_time = extra_step_z < 0 and self:GetGravityFallTime(abs(pos2_3d:z() - pos1:z()), -4000, const.Combat.Gravity) or 0 if fall_time > phase2 - phase1 then scale = Max(scale, MulDivRound(1000, fall_time, phase2 - phase1)) end else if extra_step_z < 0 and anim_full_step:z() <= -const.SlabSizeZ/2 then -- vertical scale scale = MulDivRound(1000, pos2_3d:z() - pos1:z(), anim_full_step:z()) end end if scale ~= 1000 then anim_speed = self:GetAnimSpeed(1) new_anim_speed = MulDivRound(anim_speed, 1000, scale) self:SetAnimSpeed(1, new_anim_speed) t = self:TimeToPhase(1, phase2) or 0 end end local acc = acceleration and self:GetAccelerationAndStartSpeed(phase2_pos, 0, t) or 0 self:SetAcceleration(acc) self:SetPos(phase2_pos, t) if ground_orient then self:SetGroundOrientation(angle, t) end if acceleration or anim_speed then self:PushDestructor(function(self) if IsValid(self) then self:SetAcceleration(0) if anim_speed and self:GetAnimSpeed(1) == new_anim_speed then self:SetAnimSpeed(1, anim_speed) end end end) end Sleep(t) if acceleration or anim_speed then self:PopAndCallDestructor() end end -- [end scale - action end] - play the animation as it is local action_phase2 = self:GetAnimMoment(anim, "action_end") if action_phase2 then action_phase2 = Max(action_phase2, phase2) else action_phase2 = phase2 end if action_phase2 > phase2 then local v = self:GetStepVector(anim, angle, action_phase2, duration - action_phase2) local dest = pos2_3d - v + end_offset local t = self:TimeToPhase(1, action_phase2) or 0 self:SetPos(dest, t) Sleep(t) end -- [action end - animation end] - reach pos2 to the animation end t = self:TimeToAnimEnd() if t >= 999999999 then assert(t < 999999999) -- can happen using editor t = 0 end if sleep_mod then t = MulDivRound(t, sleep_mod, 100) end self:SetPos(pos2_3d, t) Sleep(t) self:SetPos(pos2) self:SetFootPlant(true) end function Unit:CanFaceEnemy(enemy) return not (enemy:IsDead() or enemy:IsDowned() or enemy:HasStatusEffect("Hidden")) end function Unit:IsConsideredEnemy(unit) if self:IsOnEnemySide(unit) then return true end for _, groupname in ipairs(self.Groups) do local group_modifiers = gv_AITargetModifiers[groupname] for group, _ in pairs(group_modifiers) do if table.find(unit.Groups, group) then return true end end end end function Unit:GetClosestEnemy(pos) if not IsValid(self) then return end pos = SnapToVoxel(pos or self) local face_targets = {} local threshold = const.SlabSizeX / 2 local closest_enemy, min_dist -- check directly visible enemies local visibility = g_Visibility[self] for _, unit in ipairs(visibility) do if IsValid(unit) and (not min_dist or IsCloser(unit, pos, min_dist)) then local enemy = self:IsConsideredEnemy(unit) if IsValidTarget(unit) and enemy and self:CanFaceEnemy(unit) then table.insert(face_targets, unit) local dist = unit:GetDist(pos) if not closest_enemy or dist < min_dist then closest_enemy = unit min_dist = dist + threshold end end end end if not closest_enemy then return end if #face_targets > 1 then -- prefer targets closer to our current facing local cur_angle = self:GetAngle() local closest_adiff = abs(AngleDiff(CalcOrientation(pos, closest_enemy), cur_angle)) for _, unit in ipairs(face_targets) do if unit ~= closest_enemy and IsCloser(unit, pos, min_dist) then local adiff = abs(AngleDiff(CalcOrientation(pos, unit), cur_angle)) if adiff < closest_adiff then closest_enemy = unit closest_adiff = adiff end end end end return closest_enemy end function Unit:IsUsingCover() local cover = GetHighestCover(self) return cover and (cover == 2 or cover == 1 and self.stance ~= "Standing") end function Unit:GetCoverToClosestEnemy(pos) pos = pos or self:GetVoxelSnapPos() if not pos or not pos:IsValid() then return end local enemies = table.copy(GetEnemies(self)) local closest_enemy, closest_angle, closest_face_target, closest_dist local ow_target = self:GetOverwatchTarget() -- check for script-driven enemy relations for _, groupname in ipairs(self.Groups) do local group_modifiers = gv_AITargetModifiers[groupname] for target_group, mod in pairs(group_modifiers) do for _, obj in ipairs(Groups[target_group]) do if IsKindOf(obj, "Unit") then table.insert_unique(enemies, obj) end end end end local function update_closest(check_pos, angle) for _, enemy in ipairs(enemies) do local dist = IsValid(enemy) and enemy:GetDist(check_pos) if dist and (not closest_dist or dist < closest_dist) then closest_enemy = enemy closest_dist = dist closest_angle = angle closest_face_target = check_pos end end end local covers if self:IsEnemyPresent() then covers = GetCoversAt(pos) end if not next(covers) then if ow_target then return false, ow_target end if #enemies == 0 then -- no covers, no enemies - nothing to face return else -- no covers - face the closest enemy update_closest(pos or self:GetPos()) return false, closest_enemy end end local covers_count = table.count(covers) if covers_count == 1 then -- single cover only - face it no matter the enemies local angle, cover = next(covers) return cover, pos + GetCoverOffset(angle) end -- multiple covers if #enemies == 0 then local angle, cover = next(covers) -- for now choose the 1st cover return cover, pos + GetCoverOffset(angle) end -- multiple covers, enemies around - choose the cover which center has the closest enemy for angle, cover in sorted_pairs(covers) do local cover_center = pos + GetCoverOffset(angle) update_closest(cover_center, angle) end local cover = covers[(closest_angle + 180*60) % (360*60)] return cover, closest_face_target end function Unit:GetPosOrientation(pos, angle, stance, auto_face, can_reposition) if not pos then pos = GetPassSlab(self) or self:GetPos() end if not pos:IsValid() then return 0 end local bandage_target = self:GetBandageTarget() if not angle then -- subsequent calls of self:GetOrientationAngle() can lead to small rotation angle = self:GetVisualOrientationAngle() if self.last_orientation_angle and abs(AngleDiff(angle, self.last_orientation_angle)) < 5*60 then angle = self.last_orientation_angle end end stance = stance or self.stance if self:HasStatusEffect("ManningEmplacement") then auto_face = false elseif IsValid(bandage_target) then auto_face = true end if auto_face == nil then auto_face = self.auto_face end -- face the nearest enemy and orient to high cover if g_Combat and auto_face and self:IsAware() and not self:HasStatusEffect("Exposed") then local to_face = IsValid(bandage_target) and bandage_target or self:GetClosestEnemy(pos) if to_face then angle = CalcOrientation(pos, to_face.return_pos or to_face) end if self.species == "Human" and (stance == "Standing" or stance == "Crouch") and GetHighestCover(pos) == const.CoverHigh then local face_angle if to_face then -- face the cover attack step out position local action = self:GetDefaultAttackAction("ranged") if action and action.AimType ~= "melee" then local lof_args = { action_id = action.id, obj = self, step_pos = pos, stance = "Standing", aimIK = false, prediction = true, } local lof_data = CheckLOF(to_face, lof_args) if lof_data and not IsCloser2D(pos, lof_data.step_pos, const.SlabSizeX/2) then face_angle = CalcOrientation(pos, lof_data.step_pos) end end end if not face_angle then face_angle = GetUnitOrientationToHighCover(pos, angle) end if face_angle then angle = face_angle end end end -- adjust angle of lying units to not collide with other units or environment if self.body_type == "Large animal" then local can_reposition = can_reposition ~= false or not self:IsEqualPos(pos) local snap_angle, fallback = FindLargeUnitAngle(self, pos, angle, can_reposition) angle = snap_angle or fallback or angle elseif self.species == "Human" and stance == "Prone" then angle = FindProneAngle(self, pos, angle) end return angle end function Unit:SetTargetDummyFromPos(pos, angle, can_reposition) pos = pos or GetPassSlab(self) or self:GetPos() if not pos:IsValid() or self:IsDead() then return self:SetTargetDummy(false) end local orientation_angle = self:GetPosOrientation(pos, angle, self.stance, true, can_reposition) local anim_style = GetAnimationStyle(self, self.cur_idle_style) local base_idle = anim_style and anim_style:GetMainAnim() or self:GetIdleBaseAnim() return self:SetTargetDummy(pos, orientation_angle, base_idle, 0) end function Unit:SetTargetDummy(pos, orientation_angle, anim, phase, stance, ground_orient) local dummy = self.target_dummy if dummy and dummy.locked then return end local changed if pos ~= false then pos = pos or GetPassSlab(self) or self:GetPos() --assert(CanOccupy(self, pos)) -- hit when loading savegames with units on impassable anim = anim or self:GetStateText() phase = phase or self:GetAnimPhase() stance = stance or self.stance if not orientation_angle then orientation_angle = self:GetOrientationAngle() if stance == "Prone" then orientation_angle = FindProneAngle(self, nil, orientation_angle, 60*60) end end if ground_orient == nil then ground_orient = select(2, self:GetFootPlantPosProps(stance)) end if not dummy then if self.body_type == "Large animal" then dummy = PlaceObject("TargetDummyLargeAnimal", { obj = self }) else dummy = PlaceObject("TargetDummy", { obj = self }) end self.target_dummy = dummy changed = changed or "unit" end -- pos if changed or not dummy:IsEqualPos(pos) then dummy:SetPos(pos) changed = changed or "pos" end -- animation / phase if changed or dummy:GetStateText() ~= anim then dummy:SetState(anim) dummy:SetAnimSpeed(1, 0) changed = changed or "animation" end if changed or dummy:GetAnimPhase() ~= phase then dummy:SetAnimPhase(1, phase) changed = changed or "phase" end -- orientation local prev_angle, prev_axisx, prev_axisy, prev_axisz if not changed then prev_angle, prev_axisx, prev_axisy, prev_axisz = dummy:GetAngle(), dummy:GetVisualAxisXYZ() end if dummy.stance ~= stance then dummy.stance = stance changed = changed or "stance" end if ground_orient then dummy:ChangePathFlags(const.pfmGroundOrient) dummy:SetGroundOrientation(orientation_angle, 0) else dummy:ChangePathFlags(0, const.pfmGroundOrient) dummy:SetAxisAngle(axis_z, orientation_angle) end if not changed then local new_angle, new_axisx, new_axisy, new_axisz = dummy:GetAngle(), dummy:GetVisualAxisXYZ() if new_angle ~= prev_angle or new_axisx ~= prev_axisx or new_axisy ~= prev_axisy or new_axisz ~= prev_axisz then changed = true end end dummy:ClearEnumFlags(const.efResting) -- notify changed elseif dummy then changed = true DoneObject(dummy) self.target_dummy = false end if changed then Msg("TargetDummiesChanged", self) return true end return false end function Unit:GenerateTargetDummiesFromPath(path) path = path or self.combat_path local dummies = {} local base_idle = self:GetIdleBaseAnim(self.stance) local function AddDummyPos(pos, angle, insert_idx, last_step_pos) if pos ~= last_step_pos then table.insert(dummies, { obj = self, anim = base_idle, phase = 0, pos = pos, angle = angle, stance = self.stance, insert_idx = insert_idx }) end end local p1 = self:GetPos() local angle = self:GetOrientationAngle() local dummy = self.target_dummy if dummy and dummy:GetPos() == p1 then table.insert(dummies, { obj = self, anim = dummy:GetStateText(), phase = dummy:GetAnimPhase(1), pos = dummy:GetPos(), angle = dummy:GetAngle(), insert_idx = #path + 1 }) else AddDummyPos(p1, angle) end for i = #path, 1, -1 do local p0 = p1 p1 = point(point_unpack(path[i])) if p0 ~= p1 then if not p0:Equal2D(p1) then angle = CalcOrientation(p0, p1) end local tunnel = pf.GetTunnel(p0, p1) if not tunnel then ForEachWalkStep(p0, p1, AddDummyPos, angle, i + 1, p1) end AddDummyPos(p1, angle) end end return dummies end function Unit:IsOnEnemySide(other) return self.team and other.team and band(self.team.enemy_mask, other.team.team_mask) ~= 0 end function Unit:IsOnAllySide(other) return self.team and other.team and band(self.team.ally_mask, other.team.team_mask) ~= 0 end function Unit:IsPlayerAlly() return self.team and self.team.player_ally end function Unit:ReportStatusEffectsInLog() --return not (self.team and self.team.side == "neutral") return const.DbgStatusEffects and not (self.team and self.team.side == "neutral") end local visibility_spots = { "Head", "Neck", --head "Shoulderl", "Shoulderr", --torso "Ribsupperl", "Ribsupperr", "Ribslowerl", "Ribslowerr", "Pelvisl", "Pelvisr", "Groin", --groin "Shoulderl", "Shoulderr", --arms "Elbowl", "Elbowr", "Wristl", "Wristr", "Kneel", "Kneer", --legs "Anklel", "Ankler", } local visibility_spot_indices = {} function Unit:GetVisibilitySpotIndices() local entity = self:GetEntity() local current_state = self:GetState() local states = visibility_spot_indices[entity] local indices = states and states[current_state] if not indices then states = states or { } visibility_spot_indices[entity] = states indices = { } local n = 1 for i=1,#visibility_spots do local spot_name = visibility_spots[i] local first, last = GetSpotRange(entity, 0, spot_name) for idx=first,last do indices[n] = idx n = n + 1 end end states[current_state] = indices end return indices or empty_table end function Unit:IsNPC() local unit_data = UnitDataDefs[self.unitdatadef_id] return not unit_data or not unit_data.IsMercenary end function Unit:IsMerc() local unit_data = UnitDataDefs[self.unitdatadef_id] return unit_data and unit_data.IsMercenary end function Unit:IsCivilian() return self.team and self.team.side and self.team.side == "neutral" and self.species == "Human" end function GetMaxSightRadius() return const.Combat.AwareSightRange * const.Combat.SightModMaxValue / 100 * const.SlabSizeX + const.SlabSizeX / 4 end function Unit:GetSightRadius(other, base_sight, step_pos) -- base sight radius, based on awareness (in-combat only) and illumination local modifier = 100 local other_is_unit = other and IsKindOf(other, "Unit") or false local hidden = other_is_unit and other:HasStatusEffect("Hidden") local sight = base_sight or (not hidden and self:IsAware() and const.Combat.AwareSightRange or const.Combat.UnawareSightRange) local night_time = GameState.Night or GameState.Underground if night_time and other and IsIlluminated(other, nil, nil, step_pos) then night_time = false end local force_min_sight = self:CallReactions_Or("OnCheckForceMinSight", self, other, step_pos, night_time) force_min_sight = force_min_sight or (IsKindOf(other, "Unit") and other:CallReactions_Or("OnCheckForceMinSight", self, other, step_pos, night_time)) if force_min_sight then return MulDivRound(sight, const.Combat.SightModMinValue, 100) * const.SlabSizeX, hidden, night_time end modifier = self:CallReactions_Modify("OnCalcSightModifier", modifier, self, other, step_pos, night_time) if IsKindOf(other, "Unit") then modifier = other:CallReactions_Modify("OnCalcSightModifier", modifier, self, other, step_pos, night_time) end if other_is_unit and not other:IsDead() and not other:IsDowned() then if hidden then -- add (clamped) attrib difference as modifier local steath_mod = Max(0, MulDivRound(other.Agility - self.Wisdom, const.Combat.SightModStealthStatDiff, 100)) if other.stance == "Prone" then steath_mod = steath_mod + const.Combat.SightModHiddenProne end modifier = modifier - steath_mod end local armor = other:GetItemInSlot("Torso", "Armor") if armor and armor.Camouflage then modifier = modifier - const.Combat.CamoSightPenalty end end -- environmental factors if other then local env_factors = GetVoxelStealthParams(step_pos or other) or 0 if band(env_factors, const.vsFlagTallGrass) ~= 0 then modifier = modifier + const.EnvEffects.BrushSightMod end end if night_time and other then local darknessMod = const.EnvEffects.DarknessSightMod if self:HasNightVision() then local penaltyReduce = CharacterEffectDefs.NightOps:ResolveValue("night_vision_penalty_reduction") darknessMod = MulDivRound(darknessMod, penaltyReduce, 100) end modifier = modifier + darknessMod end if GameState.Fog then modifier = modifier + const.EnvEffects.FogSightMod end if GameState.DustStorm then modifier = modifier + const.EnvEffects.DustStormSightMod end if GameState.FireStorm then modifier = modifier + const.EnvEffects.FireStormSightMod end if other_is_unit then -- height difference check local ox, oy, oz if step_pos then ox, oy, oz = PosToGridCoords(step_pos:xyz()) else ox, oy, oz = other:GetGridCoords() end local x, y, z = self:GetGridCoords() if oz >= z + const.EnvEffects.SightHeightDiffThreshold then modifier = modifier + const.EnvEffects.SightHeightDiffMod elseif g_Exploration and oz + const.EnvEffects.SightHeightDiffThreshold < z then modifier = modifier + -(const.EnvEffects.SightHeightDiffMod * 2) end end modifier = Clamp(modifier, const.Combat.SightModMinValue, const.Combat.SightModMaxValue) local sightAmount = MulDivRound(sight, modifier, 100) * const.SlabSizeX -- Prevent going in and out of sus state due to Pos/VisualPos differences. if self.command == "IdleSuspicious" then sightAmount = sightAmount + const.SlabSizeX / 4 end return sightAmount, hidden, night_time end function Unit:CanSee(other, overridePos, overrideStance) local sight = self:GetSightRadius(other) local target = other if IsKindOf(other, "Unit") and other.visibility_override then target = stance_pos_pack(other.visibility_override.pos, StancesList[other.stance]) elseif IsPoint(overridePos) then self = stance_pos_pack(overridePos, StancesList[overrideStance or self.stance]) end if CheckLOS(target, self, sight) then return true end return false end function Unit:Face(...) if self.ground_orient then self:SetGroundOrientation(...) else CObject.Face(self, ...) end end function Unit:SetOrientationAngle(angle, ...) if self.ground_orient then self:SetGroundOrientation(angle, ...) self.last_orientation_angle = angle else CObject.SetAngle(self, angle, ...) self.last_orientation_angle = nil end end function Unit:GetOccupiedPos() return self.target_dummy and self.target_dummy:GetPos() end function Unit:GetVisualVoxels(pos, stance, voxels) local x, y, z voxels = voxels or {} if pos then if type(pos) == "number" then x, y, z = point_unpack(pos) elseif IsPoint(pos) and pos:IsValid() then x, y, z = pos:xyz() else return voxels end else if not self:IsValidPos() then return voxels end x, y, z = self:GetPosXYZ() end if not z then z = terrain.GetHeight(x, y) end local snapped_z = select(3, VoxelToWorld(WorldToVoxel(x, y, z))) if z - snapped_z > const.SlabSizeZ / 2 then z = z + const.SlabSizeZ end x, y, z = WorldToVoxel(x, y, z) voxels[1] = point_pack(x, y, z) local head_voxel if self.species == "Human" then if (stance or self.stance) == "Prone" then head_voxel = voxels[1] else head_voxel = point_pack(x, y, z + 1) voxels[#voxels + 1] = head_voxel end elseif self.species == "Crocodile" then local angle = self:GetOrientationAngle() local sina, cosa = sincos(angle) local slabsize = const.SlabSizeX local dx = MulDivRound(slabsize, cosa, 4096) local dy = MulDivRound(slabsize, sina, 4096) if dx > slabsize/2 then dx = 1 elseif dx < -slabsize/2 then dx = -1 end if dy > slabsize/2 then dy = 1 elseif dy < -slabsize/2 then dy = -1 end voxels[#voxels + 1] = point_pack(x + dx, y + dy, z) voxels[#voxels + 1] = point_pack(x - dx, y - dy, z) end return voxels, head_voxel or voxels[1] end function Unit:ChangeStance(action_id, cost_ap, stance, args) self:WaitResumeOnCommandStart() if self.stance == stance or self.species ~= "Human" then self:GainAP(cost_ap) CombatActionInterruped(self) return end local pfclass = CalcPFClass(self.team and self.team.side, stance, self.body_type) local pos = GetPassSlab(self, pfclass) if not pos then self:GainAP(cost_ap) CombatActionInterruped(self) return end self:SetPos(pos) local wasInterruptable = self.interruptable if wasInterruptable then self:EndInterruptableMovement() end PlayFX("ChangeStance", "start", self) self:PushDestructor(function(self) PlayFX("ChangeStance", "end", self) end) local angle = args and args.angle if stance == "Prone" then angle = FindProneAngle(self, nil, angle) end if angle then self:SetOrientationAngle(angle, not GameTimeAdvanced and 0 or 100) end self:DoChangeStance(stance) self:PopAndCallDestructor() -- FX if wasInterruptable then self:BeginInterruptableMovement() end end local AnimationStance = { nw_Standing_MortarIdle = "Crouch", nw_Standing_MortarFire = "Crouch", } function Unit:GetHitStance() return AnimationStance[self:GetStateText()] or self.stance end function Unit:CanStealth(stance) if not self.team or self.team.side == "neutral" or not self:IsValidPos() or self:IsDead() or self:IsDowned() or self.command == "ExitCombat" and not self:HasStatusEffect("Hidden") then return false end stance = stance or self.stance local is_stealthy_stance if self.species == "Human" then is_stealthy_stance = stance ~= "Standing" if HasPerk(self, "FleetingShadow") then is_stealthy_stance = true end elseif self.species == "Crocodile" then is_stealthy_stance = true end if not is_stealthy_stance then return false end -- in combat allow Spotted units to remain Hidden for the duration of the effect local effects = self.StatusEffects local visual_contact = self.enemy_visual_contact if g_Combat and effects.Spotted then visual_contact = false elseif not self:HasStatusEffect("Hidden") then local enemies = GetAllEnemyUnits(self) for _, enemy in ipairs(enemies) do visual_contact = visual_contact or HasVisibilityTo(enemy, self) end end if visual_contact then return false end if effects.BandagingDowned or effects.Revealed or effects.StationedMachineGun or effects.ManningEmplacement then return false end return true end function Unit:GetStanceToStealth(stance) stance = stance or self.stance if self.species == "Human" and stance == "Standing" and not HasPerk(self, "FleetingShadow") then return "Crouch" end return stance end function Unit:Hide() local stance = self:GetStanceToStealth() if not self:CanStealth(stance) then return end local wasInterruptable if stance ~= self.stance then wasInterruptable = self.interruptable if wasInterruptable then self:EndInterruptableMovement() end self:DoChangeStance(stance) end self:AddStatusEffect("Hidden") self:UpdateMoveAnim() PlayVoiceResponse(self, "BecomeHidden") if wasInterruptable then self:BeginInterruptableMovement() end end function Unit:Unhide() self.goto_hide = false self.goto_stance = false --self.marked_target_attack_args = false self:RemoveStatusEffect("Hidden") self:UpdateMoveAnim() if self:IsInterruptable() then self:BeginInterruptableMovement() end end function Unit:CanTakeCover() return self.species == "Human" and self.stance ~= "Prone" and (GetHighestCover(self.return_pos or self) or 0) > 0 end function Unit:TakeCover() if not self:CanTakeCover() then return end self:InterruptPreparedAttack() self:AddStatusEffect("Protected") UpdateTakeCoverAction() self:DoChangeStance("Crouch") ObjModified(self) end function OnMsg.UnitAnyMovementStart(unit) unit:RemoveStatusEffect("Protected") UpdateTakeCoverAction() end function OnMsg.UnitStanceChanged(unit) if unit.stance ~= "Crouch" then unit:RemoveStatusEffect("Protected") end end local sneak_ui_update_thread = false function OnMsg.UnitStealthChanged(obj) if obj == SelectedObj or table.find(Selection or empty_table, obj) then sneak_ui_update_thread = sneak_ui_update_thread or CreateGameTimeThread(function() while obj.command == "ExitCombat" do Sleep(100) end ObjModified("combat_bar") sneak_ui_update_thread = false end) end end function Unit:UpdateHidden() if self:HasStatusEffect("Hidden") then if not self:CanStealth() then self:RemoveStatusEffect("Hidden") self:UpdateFXClass() end elseif self.species ~= "Human" then if self:CanStealth() then self:AddStatusEffect("Hidden") PlayVoiceResponse(self, "BecomeHidden") self:UpdateFXClass() end end end -- used to control the fx actor class of the unit based on his Hidden status and self.visible function Unit:UpdateFXClass() if not self.visible then self.fx_actor_class = "Hidden" elseif IsMerc(self) then self.fx_actor_class = "ImportantUnit" elseif self.species ~= "Human" then self.fx_actor_class = self.species elseif self:IsAmbientUnit() then if self.gender == "Male" then self.fx_actor_class = "AmbientMale" elseif self.gender == "Female" then self.fx_actor_class = "AmbientFemale" else self.fx_actor_class = "AmbientUnit" end else if self.gender == "Male" then self.fx_actor_class = "Male" elseif self.gender == "Female" then self.fx_actor_class = "Female" else self.fx_actor_class = "Unit" end end end function OnMsg.GetCustomFXInheritActorRules(rules) -- unit rules[#rules + 1] = "Male" rules[#rules + 1] = "Unit" rules[#rules + 1] = "Female" rules[#rules + 1] = "Unit" rules[#rules + 1] = "ImportantUnit" rules[#rules + 1] = "Unit" rules[#rules + 1] = "AmbientUnit" rules[#rules + 1] = "Unit" -- gender rules[#rules + 1] = "AmbientMale" rules[#rules + 1] = "Male" rules[#rules + 1] = "AmbientMale" rules[#rules + 1] = "AmbientUnit" rules[#rules + 1] = "AmbientFemale" rules[#rules + 1] = "Female" rules[#rules + 1] = "AmbientFemale" rules[#rules + 1] = "AmbientUnit" end local function UpdateHiddenUnits() for _, team in ipairs(g_Teams) do if team.side ~= "neutral" then for _, unit in ipairs(team.units) do unit:UpdateHidden() end end end end function OnMsg.UnitMovementDone(obj) if GameState.sync_loading then return end obj:RemoveStatusEffect("Focused") UpdateHiddenUnits() NetUpdateHash("UnitMovement", obj, obj:GetPosXYZ()) end OnMsg.SyncLoadingDone = UpdateHiddenUnits OnMsg.ExplorationStart = UpdateHiddenUnits function Unit:GotoChangeStance(stance) if not stance or self.stance == stance then return end if not self:CanSwitchStance(stance) then return end local prev_stance = self.stance self.stance = stance ObjModified(self) self:UpdateMoveAnim() self:ChangePathFlags(const.pfDirty) self:UpdateHidden() ObjModified(self) if stance == "Prone" or prev_stance == "Prone" then local base_idle = self:GetIdleBaseAnim() PlayTransitionAnims(self, base_idle) end Msg("UnitStanceChanged", self) end function Unit:DoChangeStance(stance) assert(self.species == "Human" or stance == "") self.stance = HasPerk(self, "ZombiePerk") and "Standing" or stance self.aim_results = false self.aim_attack_args = false ObjModified(self) self:SetFootPlant(true) self:SetTargetDummyFromPos() self:UpdateMoveAnim() if GameTimeAdvanced then local base_idle = self:GetIdleBaseAnim() local angle = (self.target_dummy or self):GetOrientationAngle() PlayTransitionAnims(self, base_idle, angle) if not g_Combat and self.command ~= "ExitCombat" and self.command ~= "TakeCover" and self.command ~= "FallDown" then self:GotoSlab(self:GetPos()) end end self:UpdateHidden() ObjModified(self) Msg("UnitStanceChanged", self) end if FirstLoad then g_StanceToStanceAP = {} end local function UpdateStanceToStanceAPCache() g_StanceToStanceAP = {} ForEachPresetInGroup("StanceToStanceAP", "Default", function(def) local t = g_StanceToStanceAP[def.start_stance] if not t then t = {} g_StanceToStanceAP[def.start_stance] = t end t[def.end_stance] = def.ap_cost end) end OnMsg.DataLoaded = UpdateStanceToStanceAPCache OnMsg.PresetSave = UpdateStanceToStanceAPCache function GetStanceToStanceAP(start_stance, end_stance) if start_stance == end_stance then return 0 end return (g_StanceToStanceAP[start_stance] or empty_table)[end_stance] end function Unit:GetStanceToStanceAP(stance, ownStanceOverride) local currentStance = ownStanceOverride or self.stance if stance == currentStance then return -1 end if HasPerk(self, "HitTheDeck") and stance == "Prone" then return 0 end return GetStanceToStanceAP(currentStance, stance) or 0 end function Unit:GetArchetype() local arch = Archetypes[self.script_archetype] if arch then return arch end return Archetypes[self.current_archetype] or Archetypes.Soldier end function Unit:GetCurrentArchetype() return Archetypes[self.current_archetype] or Archetypes.Soldier end -- equipped items wihtout weapons, like Grenade, Medicine... function Unit:GetEquippedQuickItems(class, slot_name) local items = {} self:ForEachItemInSlot(slot_name,class,function(item, s, l,t, items) if not item:IsWeapon() then items[#items+1] = item end end, items) return items end function Unit:GetActiveWeapons(class, strict_order) if class == "UnarmedWeapon" then self.unarmed_weapon = self.unarmed_weapon or g_UnarmedWeapon return self.unarmed_weapon, nil, { self.unarmed_weapon } end if self:GetStatusEffect("ManningEmplacement") then local handle = self:GetEffectValue("hmg_emplacement") local obj = HandleToObject[handle] if obj and obj.weapon and (not class or IsKindOf(obj.weapon, class)) then obj.weapon.emplacement_weapon = true return obj.weapon, nil, { obj.weapon } end end local slot if IsSetpiecePlaying() and IsSetpieceActor(self) then slot = "SetpieceWeapon" else slot = self.current_weapon end self.combat_cache = self.combat_cache or {} local key = string.format("%s_%s%s", slot, class or "all", strict_order and "-strict" or "") local weapons = self.combat_cache[key] if not weapons then weapons = {} local firearms = {} self.combat_cache[key] = weapons local equipped = self:GetEquippedWeapons(slot) if slot == "SetpieceWeapon" and (#(equipped or empty_table) == 0) then equipped = self:GetEquippedWeapons(self.current_weapon) end for _, o in ipairs(equipped) do local match = not class or (class ~= "Firearm") or not IsKindOfClasses(o, "HeavyWeapon", "FlareGun") match = match and (not class or IsKindOf(o, class)) if match then table.insert(weapons, o) end if IsKindOf(o, "FirearmBase") then table.insert(firearms, o) end end -- second pass to add subweapons at the end of the list for _, item in ipairs(firearms) do for slot, weapon in sorted_pairs(item.subweapons) do local match = not class or (class ~= "Firearm") or not IsKindOfClasses(weapon, "HeavyWeapon", "FlareGun") match = match and (not class or IsKindOf(weapon, class)) if match then table.insert(weapons, weapon) end end end end -- If weapon1 is exhausted and weapon 2 isnt then weapon2 is the main weapon if not strict_order then local weapon1Exhausted = not self:CanUseWeapon(weapons[1]) local weapon2Exhausted = not self:CanUseWeapon(weapons[2]) local weapon2IsntSubWeapon = weapons[1] and weapons[2] and not weapons[2].parent_weapon if weapons[1] and weapons[2] and weapon1Exhausted and not weapon2Exhausted and weapon2IsntSubWeapon then weapons[1], weapons[2] = weapons[2], weapons[1] end end return weapons[1], weapons[2], weapons end function UnitProperties:GetWeaponByDefIdOrDefault(class, def_id, packed_pos, item_id) if packed_pos then local weapon = self:GetItemAtPackedPos(packed_pos) return weapon else local weapons = self:GetEquippedWeapons(self.current_weapon) local alt_slot = self.current_weapon == "Handheld A" and "Handheld B" or "Handheld A" table.iappend(weapons, self:GetEquippedWeapons(alt_slot)) table.iappend(weapons, self:GetEquippedWeapons("Inventory")) local n = #weapons for i = 1, n do local weapon = weapons[i] if IsKindOf(weapon, "FirearmBase") then for _, sub in sorted_pairs(weapon.subweapons) do weapons[#weapons + 1] = sub end end end local matched if def_id then matched = table.ifilter(weapons, function(idx, item) return IsKindOf(item, def_id) end) end if #(matched or empty_table) == 0 then matched = weapons end if item_id then for _, weapon in ipairs(matched) do if weapon.id == item_id then return weapon end end end return matched[1] end end function Unit:OutOfAmmo(weapon, amount) weapon = weapon or self:GetActiveWeapons() return weapon and weapon:HasMember("ammo") and (not weapon.ammo or weapon.ammo.Amount < (amount or 1)) end function Unit:GetSector() return gv_Sectors[gv_CurrentSectorId] end function Unit:IsWeaponJammed(weapon) weapon = weapon or self:GetActiveWeapons() return IsKindOf(weapon, "Firearm") and weapon.jammed end function Unit:CanUseWeapon(weapon, num_shots) if not weapon then return false, AttackDisableReasons.NoWeapon elseif weapon.Condition <= 0 then return false, AttackDisableReasons.WeaponBroken end if IsKindOf(weapon, "Firearm") then if weapon.jammed then return false, AttackDisableReasons.WeaponJammed elseif not weapon.ammo or weapon.ammo.Amount < (num_shots or 1) then return false, AttackDisableReasons.OutOfAmmo end end return true end AttackDisableReasons = { NoAP = T(526265371339, "Insufficient AP"), NoWeapon = T(379423324402, "No active weapon"), WeaponJammed = T(522229430242, "The weapon is jammed"), WeaponBroken = T(187856566334, "The weapon is broken"), OutOfAmmo = T(694516627561, "Out of ammo"), InsufficientAmmo = T(48284763278, "Not enough ammo"), InvalidTarget = T(332327094836, "Invalid target"), InvalidSelfTarget = T(858041242091, "Cannot target self"), NoTeamSight = T(308212362965, "Out of team sight"), NoTeamSightLivewire = T(316258123370, "Out of sight (Livewire)"), NoTarget = T(282471750002, "No target"), NoBandageTarget = T(409300745676, "No target. Mercs with lowered max HP are healed in the Sat View."), OutOfRange = T(460146513440, "Out Of Range"), ExtremeRange = T(990882650338, "Extreme range"), CantReach = T(533577783995, "Can't reach"), NoLoS = T(871138850086, "No line of sight"), InsufficientMeds = T(589775173410, "Not enough Meds"), FullHP = T(270490338639, "At full health"), NoLockpick = T(179588362502, "Can't pick a lock without a lockpick"), NoCrowbar = T(871669664824, "You need a crowbar to attempt to break this"), NoCutters = T(457726671611, "You need a wire cutter."), OnlyStanding = T(589960103330, "Only available in Standing stance"), Cooldown = T(591766545566, "This action can be used once per turn"), BandagingDowned = T(216477918933, "Currently treating a downed ally"), NoAmmo = T(958927508781, "Out of ammo for this weapon"), FullClip = T(342527613232, "This weapon is already fully loaded"), FullClipHaveOther = T(193803534966, "This weapon is already fully loaded. You can change the ammo type from the inventory."), SignatureRecharge = T(422255119652, "Can only be used once per conflict"), SignatureRechargeOnKill = T(895217484195, "Recharges on kill with another attack."), Water = T(389919921473, "Not allowed in water"), Stairs = T(377843918366, "Not allowed on stairs"), Impassable = T(150460717914, "Impassable"), Occupied = T(173250243531, "Occupied"), Indoors = T(927774491188, "Cannot use indoors"), InEnemySight = T(749326574456, "You cannot sneak while in enemy sight."), Revealed = T(393250883796, "You revealed yourself to the enemies and cannot sneak this turn."), CannotSneak = T(637272145762, "You cannot sneak at this time."), WrongWeapon = T(734977105577, "Wrong active weapon."), RangedWeapon = T(464635588615, "Requires a Firearm."), MacheteWeapon = T(899395755721, "Requires a Machete."), KnifeWeapon = T(270404087675, "Requires a Knife."), CombatOnly = T(607543203128, "Must be used during combat"), RequiresMachineGun = T(293921437394, "Requires a Machine Gun"), RequiresUnarmed = T(252038182681, "Must be Unarmed"), NotInCover = T(553368221470, "Only available in cover spots"), AlreadyActive = T(492570194020, "Already active"), NotInMeleeRange = T(863084049025, "Approach to attack"), NotInBandageRange = T(576945352567, "Approach to bandage"), NotSneaking = T(465045630742, "Must be sneaking"), MinDist = T(753745088840, "Too close"), NoLine = T(127026985840, "Straight path required"), TooFar = T(137552442717, "Too Far"), UsingMachineGun = T(504207281345, "Currently operating a machine gun"), NoFireArc = T(698332993342, "No fire arc"), } function GetUnitNoApReason(unit) if unit:GetBandageTarget() then return AttackDisableReasons.BandagingDowned end return AttackDisableReasons.NoAP end function Unit:SetEffectValue(id, value) self.effect_values = self.effect_values or {} self.effect_values[id] = value or nil end function Unit:GetEffectValue(id) return self.effect_values and self.effect_values[id] end function Unit:GetEffectExpirationTurn(id, key) local store_key = string.format("%s:%s", id, key) return self.effect_values and self.effect_values[store_key] or -1 end function Unit:SetEffectExpirationTurn(id, key, turn) local store_key = string.format("%s:%s", id, key) self.effect_values = self.effect_values or {} self.effect_values[store_key] = Max(turn, self.effect_values[store_key] or -1) end function Unit:CanAttack(target, weapon, action, aim, goto_pos, skip_ap_check, is_free_aim) if GetInGameInterfaceMode() == "IModeDeployment" then return false end if action.ActionType ~= "Melee Attack" and action.ActionType ~= "Ranged Attack" then return false end local args if action then if action.ActionType == "Melee Attack" and target == self then return false, AttackDisableReasons.InvalidSelfTarget end if (action.ActionType == "Melee Attack" and action.AimType ~= "melee-charge") and IsValid(target) then if IsKindOf(target.traverse_tunnel, "SlabTunnelLadder") then return false, AttackDisableReasons.InvalidTarget end if not IsMeleeRangeTarget(self, goto_pos, nil, target) then local attack_pos = self:GetClosestMeleeRangePos(target) if not attack_pos or (IsKindOf(target, "Unit") and not IsMeleeRangeTarget(self, attack_pos, nil, target)) then return false, AttackDisableReasons.CantReach end local reason = g_Combat and AttackDisableReasons.CantReach or AttackDisableReasons.TooFar --[[if attack_pos then local cpath = GetCombatPath(self) local ap = cpath and cpath:GetAP(attack_pos) if ap and action:GetAPCost(self, args) <= self.ActionPoints - ap then reason = AttackDisableReasons.NotInMeleeRange end end return false, reason--]] if attack_pos then --local cpath = GetCombatPath(self) --local ap = cpath and cpath:GetAP(attack_pos) --if not ap or action:GetAPCost(self, args) > self.ActionPoints - ap then args = args or { target = target, goto_pos = attack_pos, aim = aim, ap_cost_breakdown = {} } local cost = action:GetAPCost(self, args) if cost < 0 or cost > self.ActionPoints then return false, reason end else return false, reason end end end if action.ActionType == "Ranged Attack" and target == self then -- no return false, AttackDisableReasons.InvalidTarget end if action.id == "UnarmedAttack" or action.id == "ExplodingPalm" or (action.id == "Brutalize" and not weapon) or action.id == "MarkTarget" then weapon = self:GetActiveWeapons("UnarmedWeapon") end if action.group == "SignatureAbilities" then local recharge = self:GetSignatureRecharge(action.id) if recharge then if recharge.on_kill then return false, AttackDisableReasons.SignatureRechargeOnKill end return false, AttackDisableReasons.SignatureRecharge end end if g_Combat and self:GetEffectExpirationTurn(action.id, "cooldown") >= g_Combat.current_turn then return false, AttackDisableReasons.Cooldown elseif not skip_ap_check and (g_Combat or g_StartingCombat or g_TestingSaveLoadSystem) then args = args or { target = target, goto_pos = goto_pos, aim = aim, ap_cost_breakdown = {} } local cost = action:GetAPCost(self, args) if not self:HasAP(cost, action.id, args) then return false, GetUnitNoApReason(self) end end if action.id == "OnMyTarget" then return true elseif action.id == "MGBurstFire" then if target and not CombatActionTargetFilters.MGBurstFire(target, {self}) then return false, AttackDisableReasons.OutOfRange end elseif action.id == "Bombard" or action.id == "FireFlare" then if self.indoors then return false, AttackDisableReasons.Indoors end elseif action.id == "PinDown" then if not CombatActionTargetFilters.Pindown(target, self, weapon) then return false, AttackDisableReasons.InvalidTarget end end end if not weapon then return false, AttackDisableReasons.NoWeapon elseif (weapon.Condition or 100) <= 0 then return false, AttackDisableReasons.WeaponBroken end if IsKindOf(weapon, "Grenade") or (IsKindOf(weapon, "HeavyWeapon") and weapon.trajectory_type == "parabola") then if action and target then local range = action:GetMaxAimRange(self, weapon) if goto_pos then if goto_pos:Dist(target) > range * const.SlabSizeX then return false, AttackDisableReasons.OutOfRange end else if self:GetDist(target) > range * const.SlabSizeX then return false, AttackDisableReasons.OutOfRange end end args = args or { target = target, goto_pos = goto_pos, aim = aim, ap_cost_breakdown = {} } local results = action:GetActionResults(self, args) if not results.trajectory or #results.trajectory == 0 then return false, AttackDisableReasons.NoFireArc end end return true end local fireArm = IsKindOf(weapon, "Firearm") if fireArm and weapon.jammed then return false, AttackDisableReasons.WeaponJammed end local ammo_amount = fireArm and weapon:GetAutofireShots(action) or 1 local min_ammo_amount = action:ResolveValue("min_shots") if fireArm and not ammo_amount then local params = weapon:GetAreaAttackParams(action.id, self) ammo_amount = params and params.used_ammo end ammo_amount = Min(ammo_amount or 1, min_ammo_amount or ammo_amount or 1) if action.ActionType ~= "Other" and self:OutOfAmmo(weapon, ammo_amount) then -- Non-attack actions dont require ammo return false, AttackDisableReasons.OutOfAmmo end if IsKindOf(weapon, "MeleeWeapon") and action.ActionType == "Ranged Attack" and target then local range = action:GetMaxAimRange(self, weapon) local attack_pos = goto_pos or self:GetPos() local target_pos = IsPoint(target) and target or target:GetPos() if attack_pos:Dist(target_pos) > range * const.SlabSizeX then return false, AttackDisableReasons.OutOfRange end end -- If you give me a target I will check it no matter whether you require one. local optionalTargetAndNoTarget = not action.RequireTargets and not target local invalidObjectTarget = not action.RequireTargets and not IsValid(target) and not IsPoint(target) local pointTarget = not action.RequireTargets and IsPoint(target) if optionalTargetAndNoTarget or invalidObjectTarget or pointTarget then return true end local targetIsUnit = IsKindOf(target, "Unit") local targetIsTrap = IsKindOf(target, "Trap") local freeAimMeleeTarget = IsValid(target) and is_free_aim and action.ActionType == "Melee Attack" if not target and action.RequireTargets then return false, AttackDisableReasons.NoTarget end if not targetIsUnit and action.id == "Brutalize" then return false, AttackDisableReasons.InvalidTarget end if not targetIsUnit and not targetIsTrap and not freeAimMeleeTarget then return false, AttackDisableReasons.InvalidTarget end -- all attacks should be able to target all units except dead/defeated if targetIsUnit and (target:IsDead() or target:IsDefeatedVillain()) then return false, AttackDisableReasons.InvalidTarget end return true, false end function Unit:GetMoveModifier(stance, action_id) stance = stance or self.stance action_id = action_id or "Move" local modValue = 0 if self:HasStatusEffect("Hidden") then modValue = modValue + Hidden:ResolveValue("ap_cost_modifier") end if GameState.DustStorm then modValue = modValue + const.EnvEffects.DustStormMoveCostMod end modValue = self:CallReactions_Modify("OnCalcMoveModifier", modValue, CombatActions[action_id]) return modValue end function Unit:GetUIScaledAP() return self:GetUIActionPoints() / const.Scale.AP end function Unit:GetUIScaledAPMax() local max = Max(self:GetMaxActionPoints(), self:GetUIActionPoints()) return max / const.Scale.AP end function Unit:GatherCTHModifications(id, value, data) if not id then return end data.meta_text = data.meta_text or {} data.mod_mul = 100 data.mod_add = 0 data.base_chance = value data.enabled = true Msg("GatherCTHModifications", self, id, data.action.id, data.target, data.weapon1, data.weapon2, data) self:CallReactions("OnModifyCTHModifier", id, self, data.target, data.action, data.weapon1, data.weapon2, data) if IsKindOf(data.target, "Unit") then data.target:CallReactions("OnModifyCTHModifier", id, self, data.target, data.action, data.weapon1, data.weapon2, data) end value = data.enabled and MulDivRound(value + data.mod_add, data.mod_mul, 100) or 0 return value end function Unit:CalcChanceToHit(target, action, args, chance_only) -- Argument validation and fallbacks if not (IsPoint(target) or IsValid(target) and IsKindOf(target, "CombatObject")) then return 0 end local weapon1, weapon2 = action:GetAttackWeapons(self) local weapon = args and args.weapon or weapon1 if not weapon or IsKindOf(weapon, "Medicine") then return 0 end local modifiers = not chance_only and {} if CheatEnabled("AlwaysHit") then if modifiers then modifiers[#modifiers + 1] = { name = T(521586645369, "Cheat: Always Hit"), value = 100, id = "cheat" } end return 100, 100, modifiers elseif CheatEnabled("AlwaysMiss") then if modifiers then modifiers[#modifiers + 1] = { name = T(455715392693, "Cheat: Always Miss"), value = 0, id = "cheat" } end return 0, 0, modifiers end local target_spot_group = args and args.target_spot_group or nil if type(target_spot_group) == "table" then target_spot_group = target_spot_group.id end target_spot_group = target_spot_group or g_DefaultShotBodyPart if type(target_spot_group) == "string" then target_spot_group = Presets.TargetBodyPart.Default[target_spot_group] end local aim = args and args.aim or 0 local opportunity_attack = args and args.opportunity_attack local attacker_pos = args and (args.step_pos or args.goto_pos) or self:GetPos() local target_pos = args and args.target_pos or IsPoint(target) and target or target:GetPos() local base = 0 -- Base CTH local skill = self[weapon.base_skill] if action.id == "SteroidPunch" then skill = self["Strength"] end base = base + skill if args and not args.prediction then local effects = {} for i, effect in ipairs(self.StatusEffects) do effects[i] = effect.class end effects = table.concat(effects, ",") local target_effects = "-" if IsKindOf(target, "Unit") then target_effects = {} for i, effect in ipairs(target.StatusEffects) do target_effects[i] = effect.class end target_effects = table.concat(target_effects, ",") end NetUpdateHash("CalcChanceToHit_Base", self, target, action.id, weapon.class, weapon.id, base, effects, target_effects, weapon1 and weapon1.class, weapon1 and weapon1.id, weapon1 and weapon1.Condition, weapon1 and weapon1.MaxCondition, weapon2 and weapon2.class, weapon2 and weapon2.id, weapon2 and weapon2.Condition, weapon2 and weapon2.MaxCondition ) end if modifiers then self.combat_cache = self.combat_cache or {} local key = "base_cth_" .. weapon.base_skill local skillmod = self.combat_cache[key] if not skillmod then local prop_meta = self:GetPropertyMetadata(weapon.base_skill) if prop_meta then skillmod = { name = prop_meta.name, value = skill } else assert(false, "weapon base skill '" .. weapon.base_skill .. "' property metadata not found!") skillmod = { name = T(462143455900, "Marksmanship"), value = skill } end self.combat_cache[key] = skillmod end table.insert(modifiers, skillmod) end local mod_data = { attacker = self, target = target, target_spot_group = target_spot_group, action = action, weapon1 = weapon1, weapon2 = weapon2, aim = aim, opportunity_attack = opportunity_attack, attacker_pos = attacker_pos, target_pos = target_pos, min = 0, max = 100, } -- Evaluate all modifiers ForEachPreset("ChanceToHitModifier", function(mod) if mod.RequireTarget and not IsValidTarget(target) then return end local req_action = mod.RequireActionType if req_action == "Any Attack" then if action.ActionType == "Other" then return end elseif req_action == "Any Melee Attack" then if action.ActionType ~= "Melee Attack" then return end elseif req_action == "Any Ranged Attack" then if action.ActionType ~= "Ranged Attack" then return end elseif req_action ~= action.id then return end local lof = false -- Currently unused by any modifier local apply, value, nameOverride, metaText, idOverride = mod:CalcValue(self, target, target_spot_group, action, weapon, weapon2, lof, aim, opportunity_attack, attacker_pos, target_pos) if args and not args.prediction then NetUpdateHash("CalcChanceToHit_Modifier", mod.id, apply, value) end if not apply then return end -- automated GatherCTHModifications provide a standard mechanism for replacing display name & adding meta text (only for the applicable mods) mod_data.display_name = nameOverride or mod.display_name mod_data.meta_text = (IsT(metaText) and {metaText} or metaText) or nil value = self:GatherCTHModifications(mod.id, value, mod_data) if args and not args.prediction then NetUpdateHash("CalcChanceToHit_Modifier_Mods", mod.id, value) end local nameOverride = mod_data.display_name local metaText = #mod_data.meta_text > 0 and mod_data.meta_text base = base + value if mod_data.enabled and modifiers then table.insert(modifiers, { name = nameOverride or mod.display_name, value = value, id = idOverride or mod.id, metaText = metaText }) end end) -- cycle status effects, running GatherCTHModifications() for every one of them, using the effect class/id as mod id -- this way status effects can implement their own cth modifiers via the same mechanism for _, effect in ipairs(self.StatusEffects) do mod_data.display_name = effect.DisplayName mod_data.meta_text = nil local value = self:GatherCTHModifications(effect.class, 0, mod_data) if args and not args.prediction then NetUpdateHash("CalcChanceToHit_Effect_Mods", effect.class, value) end if value and value ~= 0 then base = base + value if mod_data.enabled and modifiers then table.insert(modifiers, { name = mod_data.display_name, value = value, id = effect.id, metaText = mod_data.meta_text }) end end end -- process weaponcomponenteffects mod_data.weapon1 = nil mod_data.weapon2 = nil local weapons = {weapon1, weapon2} for _, weapon in ipairs(weapons) do if IsKindOf(weapon, "Firearm") then for slot_id, component_id in sorted_pairs(weapon.components) do local def = WeaponComponents[component_id] local effects = def and def.ModificationEffects or empty_table if next(effects) ~= nil then mod_data.weapon1 = weapon mod_data.display_name = def.DisplayName mod_data.meta_text = nil local value = self:GatherCTHModifications(component_id, 0, mod_data) if args and not args.prediction then NetUpdateHash("CalcChanceToHit_Component_Mods", weapon.id, component_id, value) end if value and value ~= 0 then base = base + value if mod_data.enabled and modifiers then table.insert(modifiers, { name = mod_data.display_name, value = value, id = component_id, metaText = mod_data.meta_text }) end end end end end end mod_data.modifiers = modifiers self:CallReactions("OnCalcChanceToHit", self, action, target, weapon1, weapon2, mod_data) if IsKindOf(target, "Unit") then target:CallReactions("OnCalcChanceToHit", self, action, target, weapon1, weapon2, mod_data) end base = Max(0, mod_data.enabled and MulDivRound(base + mod_data.mod_add, mod_data.mod_mul, 100) or 0) local target_pos = IsPoint(target) and target or target:GetPos() local knife_throw = IsKindOf(weapon, "MeleeWeapon") and (action.ActionType == "Ranged Attack") local penalty = weapon:GetAccuracy(attacker_pos:Dist(target_pos), self, action, knife_throw) - 100 local final = Clamp(base + penalty, 0, 100) final = Clamp(final, mod_data.min, mod_data.max) if args and not args.prediction then NetUpdateHash("CalcChanceToHit_Final", final) end if chance_only then return final end if penalty ~= 0 then if action.ActionType == "Melee Attack" then modifiers[#modifiers + 1] = { name = T(660754354729, "Weapon Accuracy"), value = penalty, id = "Accuracy" } elseif penalty <= -100 then modifiers[#modifiers + 1] = { name = T(162704513413, "Out of Range"), value = penalty, id = "Range" } else modifiers[#modifiers + 1] = { name = T(301586030557, "Range"), value = penalty, id = "Range" } end end return final, base, modifiers, penalty end -- unused function Unit:GetBestChanceToHit(target, action, args, lof) local best_idx, best_hit_chance local list = attack_data.lof for i, hit_data in ipairs(list) do if i == 1 or hit_data.ally_hits_count <= list[best_idx].ally_hits_count then args.target_spot_group = hit_data.target_spot_group local hit_chance = self:CalcChanceToHit(target, action, args, "chance_only") if i == 1 or hit_data.ally_hits_count < list[best_idx].ally_hits_count or hit_chance > best_hit_chance then best_idx, best_hit_chance = i, hit_chance end end end return best_hit_chance, best_idx end function Unit:IsPointBlankRange(target) if not IsValid(target) then return false end return IsCloser(target, self, const.Weapons.PointBlankRange * const.SlabSizeX + 1) end function Unit:IsArmorPiercedBy(weapon, aim, target_spot_group, action) -- Can Crit local pierced = true if target_spot_group == "Head" then local helm = self:GetItemInSlot("Head") if helm and IsKindOf(helm, "IvanUshanka") then return false end end if action and action.id == "KalynaPerk" then return true, "ignored" end if action and action.ActionType == "Melee Attack" then return true, "ignored" end self:ForEachItem("Armor", function(item, slot) if slot ~= "Inventory" and item.Condition > 0 and weapon.PenetrationClass < item.PenetrationClass and (item.ProtectedBodyParts or empty_table)[target_spot_group] then pierced = false return "break" end end) return pierced end function Unit:CalcCritChance(weapon, target, action, args, attack_pos) if not IsKindOfClasses(weapon, "Firearm", "MeleeWeapon") then return 0 end local target_spot_group = args and args.target_spot_group or g_DefaultShotBodyPart local aim = args and args.aim or 0 if IsKindOf(target, "Unit") and not target:IsArmorPiercedBy(weapon, aim, target_spot_group, action) then return 0 end local critChance = self:GetBaseCrit(weapon) + (args and args.stealth_bonus_crit_chance or 0) local data = { crit_chance = critChance, action_id = action and action.id, weapon = weapon, aim = aim, opportunity_attack = args and args.opportunity_attack, opportunity_attack_type = args and args.opportunity_attack_type, crit_per_aim = const.Combat.AimCritBonus, stealth_attack = args and args.stealth_attack, target_spot_group = target_spot_group, guaranteed_crit = false, guaranteed_noncrit = false, } Msg("GatherCritChanceModifications", self, target, action and action.id, weapon, data) self:CallReactions("OnCalcCritChance", self, target, action, weapon, data) if IsKindOf(target, "Unit") then target:CallReactions("OnCalcCritChance", self, target, action, weapon, data) end if data.guaranteed_noncrit or data.opportunity_attack then return 0 end if data.guaranteed_crit then return 100 end local critChance = data.crit_chance + aim * data.crit_per_aim return Clamp(critChance, 0, 100) end function TFormat.AimAPCost() local igi = GetInGameInterfaceModeDlg() if not igi then return -1 end local attacker = igi.attacker local action = igi.action if not action then return -1 end local weapon = action:GetAttackWeapons(attacker) local crosshair = igi.crosshair local aimLevel = crosshair and crosshair.aim or 0 local actionCost, aimCost = attacker:GetAttackAPCost(action, weapon, nil, aimLevel + 1, action.ActionPointDelta) return aimCost / const.Scale.AP end function Unit:GetAttackAPCost(action, weapon, action_ap_cost, aim, delta) if not weapon then return 0 end local min, max = self:GetBaseAimLevelRange(action, weapon) aim = Clamp(aim or 0, min, max) - min -- only charge for aiming above min level delta = delta or 0 local aimCost = const.Scale.AP local rain_penalty = GameState.RainHeavy and not self.indoors if rain_penalty then aimCost = MulDivRound(aimCost, 100 + const.EnvEffects.RainAimingMultiplier, 100) end local ap = action_ap_cost or weapon.AttackAP or weapon.ShootAP or 0 ap = ap + delta ap = self:CallReactions_Modify("OnCalcAPCost", ap, action, weapon, aim) if IsKindOf(weapon, "HeavyWeapon") then elseif IsKindOf(weapon, "Firearm") or IsKindOf(weapon, "Grenade") or IsKindOf(weapon, "MeleeWeapon") then ap = ap + aim * aimCost else ap = -1 end -- legal cheat: during heavy rain last possible aim costs 1 AP regardless of the penalty local remainingAP = (self:GetUIActionPoints() / 1000) * 1000 if rain_penalty and ap > remainingAP and aim > 0 then local diff = abs(remainingAP - ap) if diff < aimCost and diff >= const.Scale.AP then ap = remainingAP aimCost = 1000 end end return ap, aimCost end function Unit:ResolveAttackParams(action_id, target, lof_params) local action = action_id and CombatActions[action_id] if action and action.AimType == "melee" then local attack_data = { obj = self, step_pos = self:GetOccupiedPos() or GetPassSlab(self) or self:GetPos(), target = target } return attack_data end lof_params = lof_params or {} lof_params.obj = self if not lof_params.action_id then lof_params.action_id = action_id end if lof_params.weapon == nil then lof_params.weapon = action and action:GetAttackWeapons(self) or false end if not lof_params.step_pos then lof_params.step_pos = self:GetOccupiedPos() end if lof_params.can_use_covers == nil then lof_params.can_use_covers = true end lof_params.prediction = true local attack_data = GetLoFData(self, target, lof_params) if not attack_data then attack_data = { obj = self, step_pos = self:GetOccupiedPos() or GetPassSlab(self) or self:GetPos(), target = target, stuck = true, } end return attack_data end function Unit:PrepareToAttack(attack_args, attack_results) if not self.visible then local targetIsUnit = attack_args.target and IsKindOf(attack_args.target, "Unit") and attack_args.target if targetIsUnit and targetIsUnit.visible then local floor = GetStepFloor(targetIsUnit) SnapCameraToObj(targetIsUnit, "force", floor) else return end end local showMiddle local dontMoveCamera, ccAttacker = StopCinematicCombatCamera() local updateLastUnitShoot = false if dontMoveCamera then updateLastUnitShoot = ccAttacker end local targetPos = not IsPoint(attack_args.target) and attack_args.target:GetVisualPos() or attack_args.target local notInGivenCommand = self.command ~= "OverwatchAction" and self.command ~= "MGSetup" and self.command ~= "MGTarget" local attackerPos = self:GetVisualPos() local isRetaliation = attack_args.opportunity_attack_type and attack_args.opportunity_attack_type == "Retaliation" local isAIControlled = not ActionCameraPlaying and not self:IsMerc() and g_AIExecutionController and (not self.opportunity_attack or #g_CombatCamAttackStack == 0) local mercPlayingAsAI = self:IsMerc() and g_AIExecutionController and g_AIExecutionController.units_playing and g_AIExecutionController.units_playing[self] local isAIControlledMerc = not ActionCameraPlaying and (isRetaliation or attack_args.gruntyPerk or mercPlayingAsAI) local cameraPosChanged --Show attacker local movedToShowAttacker if isAIControlled or isAIControlledMerc then --handle camera if g_LastUnitToShoot ~= self and not dontMoveCamera then local midPoint = (attackerPos + targetPos) / 2 local floor = GetStepFloor(self) local target_floor = GetStepFloor(attack_args.target) showMiddle = floor == target_floor and notInGivenCommand and DoPointsFitScreen({ attackerPos, targetPos }, midPoint, 10) local posToShow = showMiddle and midPoint or attackerPos local cameraIsNear = DoPointsFitScreen({posToShow}, nil, const.Camera.BufferSizeNoCameraMov) if cameraIsNear and showMiddle then cameraIsNear = DoPointsFitScreen({ attackerPos, targetPos }, nil, 10) end if not cameraIsNear then movedToShowAttacker = true SnapCameraToObj(posToShow, "force", GetStepFloor(showMiddle and targetPos or attackerPos)) if not self:CanQuickPlayInCombat() then Sleep(1000) end elseif not IsVisibleFromCamera(self) or GetStepFloor(self) > cameraTac.GetFloor() then --no movement of camera should still take into account the floor cameraTac.SetFloor(GetStepFloor(self), hr.CameraTacInterpolatedMovementTime * 10, hr.CameraTacInterpolatedVerticalMovementTime * 10) end updateLastUnitShoot = self end elseif not ActionCameraPlaying and self.opportunity_attack and not isRetaliation then movedToShowAttacker = not DoPointsFitScreen({ targetPos }, nil, const.Camera.BufferSizeNoCameraMov) CombatCam_ShowAttackNew(self, attack_args.target, nil, attack_results, dontMoveCamera) end --handle badges if not g_AITurnContours[self.handle] and (isAIControlled or isAIControlledMerc or (self.opportunity_attack and not isRetaliation)) 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 self:AimTarget(attack_args, attack_results, true) --delay after aim if not self:CanQuickPlayInCombat() and movedToShowAttacker and (g_AIExecutionController or isRetaliation or attack_args.gruntyPerk or self.opportunity_attack) then local delay local consecutiveDelay = not dontMoveCamera and g_LastUnitToShoot == self if dontMoveCamera then delay = const.Combat.ShootDelayAfterAimCinematic elseif consecutiveDelay then delay = const.Combat.ConsecutiveShootDelayAfterAim else delay = const.Combat.ShootDelayAfterAim end Sleep(delay) end self:SetTargetDummy(nil, nil, attack_args.anim, 0, attack_args.stance) --Show target if not showMiddle then cameraPosChanged = not DoPointsFitScreen({targetPos}, nil, const.Camera.BufferSizeNoCameraMov) end if isAIControlled and notInGivenCommand and g_LastUnitToShoot ~= self or isAIControlledMerc then local interrupts = self:CheckProvokeOpportunityAttacks(attack_args.action_id and CombatActions[attack_args.action_id], "attack interrupt", {self.target_dummy or self}) local targetNotVisible = showMiddle and IsKindOf(attack_args.target, "Unit") and not IsVisibleFromCamera(attack_args.target) CombatCam_ShowAttackNew(self, attack_args.target, interrupts, attack_results, dontMoveCamera or showMiddle, targetNotVisible) elseif self:IsMerc() and not ActionCameraPlaying and not g_AIExecutionController then --edge case where merc/player shoots but he is in overwatch so the overwatch will be shown first and then his attack local interrupts = self:CheckProvokeOpportunityAttacks(attack_args.action_id and CombatActions[attack_args.action_id], "attack interrupt", {self.target_dummy or self}) if interrupts then CombatCam_ShowAttackNew(self, attack_args.target, interrupts, attack_results) else local cameraIsNear = DoPointsFitScreen({targetPos}, nil, const.Camera.BufferSizeNoCameraMov) if attack_results.explosion and (not attack_args.action_id or attack_args.action_id ~= "Bombard") and not cameraIsNear then SnapCameraToObj(targetPos, nil, GetStepFloor(targetPos), 500) Sleep(500) end end end --delay before shooting if not self:CanQuickPlayInCombat() then if g_AIExecutionController or isRetaliation or self.opportunity_attack then local delay local consecutiveDelay = not dontMoveCamera and g_LastUnitToShoot == self if dontMoveCamera then delay = const.Combat.ShootDelayCinematic elseif not cameraPosChanged then delay = const.Combat.ShootDelayTargetOnScreen elseif consecutiveDelay then delay = const.Combat.ConsecutiveShootDelay else delay = const.Combat.ShootDelay end Sleep(delay) elseif self.command ~= "PrepareBombard" and self.command ~= "OverwatchAction" then if cameraPosChanged then Sleep(const.Combat.ShootDelayNonAI) else Sleep(const.Combat.ShootDelayTargetOnScreen) end end end if updateLastUnitShoot then g_LastUnitToShoot = updateLastUnitShoot end end function Unit:CalcStealthKillChance(weapon, target, target_spot_group, aim) if not IsValidTarget(target) or not IsKindOf(target, "Unit") or not weapon then return 0 end local chance = Max(0, self.Dexterity - target.Wisdom) local min_chance = 1 if target_spot_group == "Head" or target_spot_group == "Neck" then chance = chance + const.Combat.HeadshotStealthKillChanceMod end chance = self:CallReactions_Modify("OnCalcStealthKillChance", chance, self, target, weapon, target_spot_group, aim) chance = target:CallReactions_Modify("OnCalcStealthKillChance", chance, self, target, weapon, target_spot_group, aim) min_chance = self:CallReactions_Modify("OnCalcStealthKillMinChance", min_chance, self, target, weapon, target_spot_group, aim) min_chance = target:CallReactions_Modify("OnCalcStealthKillMinChance", min_chance, self, target, weapon, target_spot_group, aim) local stealthKillBonus = GetComponentEffectValue(weapon, "StealthKillBonusPerAim", "stealth_kill_bonus") if stealthKillBonus then chance = chance + (aim or 0) * (stealthKillBonus or 0) end if target:IsAware() then chance = MulDivRound(chance, 33, 100) elseif target:HasStatusEffect("Surprised") or target:HasStatusEffect("Suspicious") then chance = MulDivRound(chance, Max(0, 100 + CharacterEffectDefs.Surprised:ResolveValue("stealthkill_modifier")), 100) end if CheatEnabled("SkillCheck") then chance = 100 end local weapon_pen_class = weapon:HasMember("PenetrationClass") and weapon.PenetrationClass or 1 local armor_class = 0 target:ForEachItem("Armor", function(item, slot) if slot ~= "Inventory" and item.ProtectedBodyParts and item.ProtectedBodyParts[target_spot_group] then armor_class = Max(armor_class, item.PenetrationClass) end end) if weapon_pen_class < armor_class and chance > 0 then chance = chance / 2 end chance = Clamp(chance, min_chance, 100) return chance end function Unit:PrepareAttackArgs(action_id, args) action_id = action_id or self:GetDefaultAttackAction("ranged") args = args or empty_table local action = CombatActions[action_id] local weapon = args.weapon or action and action:GetAttackWeapons(self) local target = args.target local prediction = args.prediction or args.prediction == nil local aim_type = action and action.AimType local thermal_aim = IsKindOf(weapon, "Firearm") and IsFullyAimedAttack(args) and weapon:HasComponent("IgnoreGrazingHitsWhenFullyAimed") local attack_args = table.copy(args) attack_args.action_id = action_id attack_args.obj = self attack_args.weapon = weapon attack_args.target_pos = attack_args.target_pos or IsPoint(target) and target attack_args.step_pos = attack_args.step_pos or self.return_pos or self:GetOccupiedPos() or GetPassSlab(self) or self:GetPos() attack_args.ignore_smoke = thermal_aim if attack_args.fire_relative_point_attack == nil then attack_args.fire_relative_point_attack = self.WeaponType == "Shotgun" end attack_args.prediction = prediction if aim_type ~= "melee" then attack_args.prediction = true local attack_data if IsPoint(target) and attack_args.target_height_range then if not target:IsValidZ() then target = target:SetTerrainZ() end local min_dist for h = 0, attack_args.target_height_range, guim/2 do local pt = target:SetZ(target:z() + h) local data = GetLoFData(self, pt, attack_args) if data then local dist for _, lof_data in ipairs(data.lof) do for _, hit in ipairs(lof_data.hits) do local d = target:Dist(hit.pos) if not dist or dist > d then dist = d end end end if dist and (not attack_data or min_dist > dist) then attack_data, min_dist = data, dist end end end else attack_data = GetLoFData(self, target, attack_args) end if attack_data then for k, v in pairs(attack_data) do attack_args[k] = v end else attack_args.stuck = true end attack_args.prediction = prediction end attack_args.num_shots = attack_args.num_shots or 1 -- number of direct shots (with simulated projectiles attack_args.aoe_action_id = attack_args.aoe_action_id or false -- defines cone area for collateral/aoe damage (as used in Firearm:GetAreaAttackParams) attack_args.aoe_fx_action = attack_args.aoe_fx_action or false -- fx action to play for the aoe part of the attack, if any attack_args.aoe_damage_type = attack_args.aoe_damage_type or "default" -- "default", "fixed" or "percent", defines how the aoe damage is calculated attack_args.aoe_damage_value = attack_args.aoe_damage_value or false -- when "fixed" or "percent" type is used attack_args.applied_status = attack_args.applied_status or false -- status effect on hit attack_args.damage_bonus = attack_args.damage_bonus or false -- bonus damage applied by the attack (0/false = no change, 100 = x2, -100 = x0) attack_args.consumed_ammo = attack_args.consumed_ammo or false -- defaults to num_shots/used_ammo (from aoe params) attack_args.aoe_damage_bonus = attack_args.aoe_damage_bonus or false -- same as above for collateral/aoe damage attack_args.cth_loss_per_shot = attack_args.cth_loss_per_shot or false -- for attacks with num_shots > 1, defines the accuracy of the follow-up shots based on the attack roll attack_args.fx_action = attack_args.fx_action or false -- fx action to play in the hit moment(s) of the attack anim (defaults to WeaponFire) attack_args.single_fx = attack_args.single_fx or false -- if set to true, attacks will not play their fx_action more than once per attack if weapon and aim_type == "cone" then local aoe_params = weapon:GetAreaAttackParams(action_id, self, attack_args.target_pos, attack_args.step_pos) for k, v in pairs(aoe_params) do attack_args[k] = v end end -- Stealth kill local is_stealth = attack_args.stealth_attack or self:HasStatusEffect("Hidden") local lethal_weapon = (attack_args.target_spot_group == "Neck") and IsKindOf(weapon, "MeleeWeapon") and (weapon.NeckAttackType == "lethal") if action and (is_stealth or lethal_weapon) then local stealth_targeted = is_stealth and action.StealthAttack and IsKindOf(target, "Unit") and IsValidTarget(target) local stealth_aoe, chance if stealth_targeted or stealth_aoe then local crosshair = GetInGameInterfaceModeDlg().crosshair local aim = args.aim or (crosshair and crosshair.aim) or 0 chance = self:CalcStealthKillChance(weapon, target, attack_args.target_spot_group, aim) attack_args.stealth_attack = true end if lethal_weapon then local lethal_chance = 5 + Max(0, (self.Strength - target.Health) / 2) chance = Max(chance or 0, lethal_chance) end if stealth_targeted or lethal_weapon then if target:IsNPC() and not target.villain then attack_args.stealth_kill_chance = chance attack_args.stealth_bonus_crit_chance = 0 else attack_args.stealth_kill_chance = 0 attack_args.stealth_bonus_crit_chance = chance end end end return attack_args end function Unit:StartFireAnim(shot, attack_args, aim_pos, shotAnimDelay) if self:CanAimIK(attack_args.weapon) then if not aim_pos then aim_pos = shot and (shot.lof_pos2 or shot.target_pos) or attack_args.target_pos end if aim_pos then self:SetIK("AimIK", aim_pos) -- the unit should be in aim or fire state if shotAnimDelay then Sleep(shotAnimDelay) shotAnimDelay = 0 else Sleep(200) end end end if (shotAnimDelay or 0) > 0 then Sleep(shotAnimDelay) end local hit_moment = shot and shot.hit_moment or "hit" -- todo: make sure DualShot passes the correct hit moments local anim = self:ModifyWeaponAnim(attack_args.anim) self:SetState(anim, const.eKeepComponentTargets, 0) local time_to_hit = self:TimeToMoment(1, hit_moment) if time_to_hit then Sleep(time_to_hit) end end function Unit:GetAttackRolls(num_shots, multishot, atk_value, crit_value) local attack_roll, crit_roll if not multishot or ((num_shots or 1) <= 1) then attack_roll = atk_value or (1 + self:Random(100)) crit_roll = crit_value or (1 + self:Random(100)) else attack_roll, crit_roll = {}, {} for i = 1, num_shots do attack_roll[i] = atk_value or (1 + self:Random(100)) crit_roll[i] = crit_value or (1 + self:Random(100)) end end return attack_roll, crit_roll end function FXAnimToAction(anim) return "Anim:" .. anim end function FXActionToAnim(action) return remove_prefix(action, "Anim:") or action end function Unit:GetLogName() return self:GetDisplayName() end function Unit:SetAttackReason(reason, opportunity_attack) self.attack_reason = reason self.opportunity_attack = opportunity_attack end function Unit:GetAttackReasonText() return self.attack_reason and T{295729060245, ": ", name = self.attack_reason} or "" end function Unit:GetBaseDamage(weapon, target, breakdown) if self.infinite_dmg then return 10000 end weapon = weapon or self:GetActiveWeapons() local mod = 100 local base_damage = 0 if IsKindOf(weapon, "Firearm") then base_damage = weapon.Damage if IsValidTarget(target) and self:GetDist(target) <= weapon.WeaponRange * const.SlabSizeX / 2 then local damageIncrease = GetComponentEffectValue(weapon, "HalfRangeDmgIncrease", "base_dmg_bonus") if damageIncrease then base_damage = base_damage + damageIncrease end end elseif IsKindOf(weapon, "Grenade") then if IsKindOf(weapon, "ThrowableTrapItem") then base_damage = weapon:GetBaseDamage() else base_damage = weapon.BaseDamage end mod = 100 + GetGrenadeDamageBonus(self) elseif IsKindOfClasses(weapon, "MeleeWeapon", "Ordnance") then base_damage = weapon.BaseDamage end local data = { base_damage = base_damage, modifier = mod, breakdown = breakdown or {} } Msg("CalcBaseDamage", self, weapon, target, data) self:CallReactions("OnCalcBaseDamage", weapon, target, data) return MulDivRound(data.base_damage, data.modifier, 100) end function Unit:GodMode(mode, state) mode = mode or "god_mode" if state == nil then state = true end self[mode] = state if mode == "god_mode" then self.infinite_ap = state self.infinite_ammo = state self.infinite_dmg = state self.infinite_condition = state self.invulnerable = state end if self.infinite_ap then self:GainAP(self:GetMaxActionPoints()) end end local l_all_equiped_weapons_reloaded function Unit:ReloadAllEquipedWeapons(ammo_type) l_all_equiped_weapons_reloaded = false self:ForEachItemInSlot("Handheld A", "Firearm", function(gun, slot, left, top, self, ammo_type) if ammo_type or not gun.ammo or gun.ammo.Amount < gun.MagazineSize then if self:ReloadWeapon(gun, ammo_type) then l_all_equiped_weapons_reloaded = true end end end, self, ammo_type) self:ForEachItemInSlot("Handheld B", "Firearm", function(gun, slot, left, top, self, ammo_type) if ammo_type or not gun.ammo or gun.ammo.Amount < gun.MagazineSize then if self:ReloadWeapon(gun, ammo_type) then l_all_equiped_weapons_reloaded = true end end end, self, ammo_type) return l_all_equiped_weapons_reloaded end function Unit:GetCoverPercentage(attackerPos, target_pos, stance) if g_Combat and self.combat_path then return false, false, 0 -- unit is moving in combat and does not benefit from cover end return GetCoverPercentage(target_pos or self:GetPos(), attackerPos, stance or self.stance) end function Unit:SetVisible(visible, force) visible = visible or (not force and IsSetpiecePlaying() and g_SetpieceFullVisibility) --NetUpdateHash("Unit:SetVisible", self, visible, force, IsSetpiecePlaying(), g_SetpieceFullVisibility) if visible then if g_Exploration and not self:IsDead() then if not self.visible then self:SetOpacity(100, 500) end else self:SetOpacity(100) end local hidden_parts = self.Headshot and self.species == "Human" and HeadshotHideParts self:SetEnumFlags(const.efVisible) for name, body_part in pairs(self.parts) do if hidden_parts and table.find(hidden_parts, name) then body_part:ClearEnumFlags(const.efVisible) else body_part:SetEnumFlags(const.efVisible) end end if self.prepared_attack_obj then self.prepared_attack_obj:SetEnumFlags(const.efVisible) end else if g_Exploration and not self:IsDead() then if self.visible then self:SetOpacity(0, 2000) end else self:ClearEnumFlags(const.efVisible) for _, body_part in pairs(self.parts) do body_part:ClearEnumFlags(const.efVisible) end end if self.prepared_attack_obj then self.prepared_attack_obj:ClearEnumFlags(const.efVisible) end end if self.visible ~= visible then if self.melee_threat_contour then self.melee_threat_contour:SetVisible(visible) end if self.ui_badge then local badgeVisible = visible and not self:IsDead() self.ui_badge:SetVisible(badgeVisible, "unit") end self:SetSoundMute(not visible) self:ForEachAttach("GrenadeVisual", function(obj) obj:SetSoundMute(not visible) end) self.visible = visible ObjModified(self) if self.carry_flare then self:UpdateOutfit() end self:UpdateFXClass() end end function Unit:GetVisibleEnemies() local visible = {} if self.team then -- Add all enemies visible by my team. local team_visible = g_Visibility[self.team] or empty_table for _, u in ipairs(team_visible) do if IsValidTarget(u) and self:IsOnEnemySide(u) then table.insert_unique(visible, u) end end end return visible end function Unit:OnGearChanged(isLoad) self.using_cumbersome = false NetUpdateHash("CumbersomeReset", self) self:ForEachItem(false, function(item, slot) if slot ~= "Inventory" and item:IsCumbersome() then self.using_cumbersome = true NetUpdateHash("CumbersomeSet", self) end item:ApplyModifiersList(item.applied_modifiers) end) Msg("UnitAPChanged", self) ObjModified(self) ObjModified(self.Inventory) end function Unit:CanUseIroncladPerk() local canUse = HasPerk(self, "Ironclad") if canUse then local weapons = self:GetHandheldItems() for _, weapon in ipairs(weapons) do if weapon:IsCumbersome() then canUse = false break end end end return canUse end local _is_attack_available_units = {} function Unit:GetDefaultAttackAction(force_ranged, force_ungrouped, weapon, sync, ignore_stealth, args, ui) local weapon2 if not weapon then weapon, weapon2 = self:GetActiveWeapons() end local id, action local weaponAttacks = IsKindOf(weapon, "Firearm") and not IsKindOfClasses(weapon, "HeavyWeapon", "FlareGun") and weapon.AvailableAttacks or empty_table local weapon2Attacks = IsKindOf(weapon2, "Firearm") and not IsKindOfClasses(weapon2, "HeavyWeapon", "FlareGun") and weapon2.AvailableAttacks or empty_table _is_attack_available_units[1] = self -- Check if dual shot. if #weaponAttacks > 0 and #weapon2Attacks > 0 then if table.find(weaponAttacks, "DualShot") and table.find(weapon2Attacks, "DualShot") then action = CombatActions.AttackDual if action:GetUIState(_is_attack_available_units, args) == "enabled" then id = action.id end end end if force_ranged and IsKindOf(weapon, "MeleeWeapon") and weapon.CanThrow then id = "KnifeThrow" end if IsKindOf(weapon, "FlareGun") then weapon = nil if weapon2 and not IsKindOf(weapon2, "FlareGun") then weapon = weapon2 weapon2 = nil end end if not id then id = weapon and weapon:GetBaseAttack(self, force_ranged) or "UnarmedAttack" end action = CombatActions[id] if force_ungrouped and action:GetUIState(_is_attack_available_units, args) == "enabled" then if sync then NetUpdateHash("GetDefaultAttackAction", self, id) end return action end local firingMode = action and action.FiringModeMember if firingMode and CombatActions[firingMode]:GetUIState({self}, args) == "enabled" then if not force_ungrouped then if sync then NetUpdateHash("GetDefaultAttackAction", self, firingMode) end return CombatActions[firingMode] end local action_id = self:ResolveDefaultFiringModeAction(CombatActions[firingMode], ui, sync) if action_id then return CombatActions[action_id] end end if sync then NetUpdateHash("GetDefaultAttackAction", self, action.id) end return action end local _resolve_default_firing_mode_actions = {} function Unit:ResolveDefaultFiringModeAction(firingMode, ui, sync) local actions = _resolve_default_firing_mode_actions table.iclear(actions) local firing_id = firingMode.id local weapon = firingMode:GetAttackWeapons(self) if IsKindOf(weapon, "Firearm") then for _, id in ipairs(weapon.AvailableAttacks) do if CombatActions[id].FiringModeMember == firing_id then actions[#actions + 1] = CombatActions[id] end end else for id, action in pairs(CombatActions) do if action.FiringModeMember == firing_id then actions[#actions + 1] = action end end end -- special casea if firing_id == "AttackDual" then table.insert_unique(actions, CombatActions.LeftHandShot) table.insert_unique(actions, CombatActions.RightHandShot) elseif firing_id == "Attack" and weapon:HasComponent("EnableFullAuto") then table.insert_unique(actions, CombatActions.AutoFire) end table.sort(actions, function(a, b) return a.SortKey < b.SortKey end) if self:HasStatusEffect("Hidden") then if table.find(actions, "id", "SingleShot") then return "SingleShot", actions end end _is_attack_available_units[1] = self if ui and self.lastFiringMode and table.find(actions, "id", self.lastFiringMode) then local action = CombatActions[self.lastFiringMode] if action:GetUIState(_is_attack_available_units) == "enabled" then if self:HasAP(action:GetAPCost(self), action.id) then return action.id, actions end end end for _, action in ipairs(actions) do if action:GetUIState(_is_attack_available_units) == "enabled" then if not ui or self:HasAP(action:GetAPCost(self), action.id) then return action.id, actions end end end return actions[1] and actions[1].id, actions end local function ResetIdleLookAt(unit) if not g_Combat then return end for _, team in ipairs(g_Teams) do if team.side ~= "neutral" then for _, u in ipairs(team.units) do if u.command == "Idle" and u.target_dummy and u:IsAware() then local pos = GetPassSlab(u.target_dummy) or u.target_dummy:GetPos() if u:SetTargetDummyFromPos(pos, nil, false) then u:SetCommand("Idle") end end end end end end function UpdateIndoors(unit) if GameState.Underground then unit.indoors = true return end local volume = EnumVolumes(unit, "smallest") --unit.indoors = volume and not volume.dont_use_interior_lighting unit.indoors = volume and volume:HasRoof(true) end OnMsg.UnitMovementDone = ResetIdleLookAt OnMsg.UnitDied = ResetIdleLookAt OnMsg.VisibilityUpdate = ResetIdleLookAt OnMsg.CoversChanged = ResetIdleLookAt OnMsg.CombatObjectDied = ResetIdleLookAt function OnMsg.EnterSector() for _, unit in ipairs(g_Units) do UpdateIndoors(unit) if HasPerk(unit, "ZombiePerk") then unit.stance = "Standing" end end end function OnMsg.UnitDied(dead_unit) -- bandage target death handler dead_unit:PlaceBlood() if not g_Combat then return end for _, unit in ipairs(g_Units) do if unit.combat_behavior == "CombatBandage" then local target = unit.combat_behavior_params[1] if target == dead_unit then unit:EndCombatBandage() end end end end local function UnitsUpdateCovers(bbox) MapForEach(bbox or "map", "Unit", function(u) if u:IsDead() or not u:IsAware() then return end if u:HasStatusEffect("Protected") and not GetCoversAt(u) then u:RemoveStatusEffect("Protected") end if u.command == "Idle" then u:SetCommand("Idle") end end) end OnMsg.CoversChanged = UnitsUpdateCovers function OnMsg.CombatObjectDied(obj, bbox) UnitsUpdateCovers(bbox) end function Unit:SetHighlightColorModifier(visible) if not IsValid(self) then return "break" end if not visible then self.interactable_highlight_ctr = SpawnUnitContour(false, false, self.interactable_highlight_ctr) end local dead = self:IsDead() if not self:IsNPC() and not dead then return "break" end if dead then return Interactable.SetHighlightColorModifier(self, visible) end if visible == not not self.interactable_highlight_ctr then return "break" end self.interactable_highlight_ctr = SpawnUnitContour(self, "Interact", self.interactable_highlight_ctr) return "break" end if Platform.developer then function TestSpawnNPC(class, pos) local session_id = GenerateUniqueUnitDataId("TestNPC", gv_CurrentSectorId or "A1", class) return SpawnUnit(class, session_id, pos or GetCursorPos()) end end TFormat.ap = function(context_obj, value) return T{747393774818, " AP", num = value/const.Scale.AP} end TFormat.apn = function(context_obj, value) return T{867764319678, "", num = type(value) == "number" and value/const.Scale.AP or value or ""} end function Unit:UpdatePFClass() if self.pfclass_overwritten then return end local side = self.team and self.team.side if (side == "player1" or side == "player2") and (self:HasStatusEffect("Panicked") or self:HasStatusEffect("Berserk")) then side = "enemy1" -- player units controlled by AI use the same pathfinding rules as the AI units end local pfclass = CalcPFClass(side, self.stance, self.body_type) self:SetPfClass(pfclass) end function Unit:OverwritePFClass(pfclass) if pfclass then self.pfclass_overwritten = pfclass self:SetPfClass(pfclass) elseif self.pfclass_overwritten then self.pfclass_overwritten = false self:UpdatePFClass() end end SuppressTeamUpdate = false function Unit:SetTeam(team) local old_team = self.team local aware = self:IsAware() self.team = team self:UpdatePFClass() if old_team and team ~= old_team and old_team.side == "neutral" and not team.player_team then self:AddStatusEffect("Unaware") end if old_team and team ~= old_team and IsValidTarget(self) then local next_unit if table.find(Selection or empty_table, self) then SelectionRemove(self) if #Selection == 0 then next_unit = true end end if SelectedObj == self or next_unit then local igi = GetInGameInterfaceModeDlg() if igi then igi:NextUnit() end end if aware and team.side ~= "player1" and team.side ~= "player2" and team.side ~= "neutral" and team.side ~= old_team.side then if g_Combat then self:AddStatusEffect("Surprised") else self:AddStatusEffect("Suspicious") end if old_team and not GameState.sync_loading and not GameState.loading_savegame then for _, unit in ipairs(old_team.units) do PushUnitAlert("discovered", unit) end AlertPendingUnits() end end end Msg("UnitSideChanged", self, team) if not SuppressTeamUpdate then Msg("TeamsUpdated") end end function Unit:SetSide(side) if not g_Teams or #g_Teams == 0 then SetupDummyTeams() end local new_team = table.find_value(g_Teams, "side", side) SendUnitToTeam(self, new_team) self.CurrentSide = side if side ~= "neutral" then -- remove forced weapon anim prefix for command, params in pairs(self.command_specific_params) do params.weapon_anim_prefix = nil end end self:SyncWithSession("map") end function Unit:GetCombatMoveCost(pos) if point_pack(SnapToVoxel(pos:xyz())) == point_pack(SnapToVoxel(self:GetPosXYZ())) then return 0 -- the unit is in the same slab with the destination end local combatPath = GetCombatPath(self) local ap = combatPath:GetAP(pos) return ap end function Unit:GetClosestMeleeRangePos(target, target_pos, stance, interaction) local closest_pos if g_Combat then local combatPath = GetCombatPath(self, stance) closest_pos = combatPath:GetClosestMeleeRangePos(target, target_pos, true, interaction) else if target.behavior == "Visit" and IsKindOf(target.last_visit, "AL_SitChair") then return target.last_visit:GetPos() end local positions = GetMeleeRangePositions(self, target, target_pos, true) if positions then for i, packed_pos in ipairs(positions) do positions[i] = point(point_unpack(packed_pos)) end local has_path, pf_closest_pos = pf.HasPosPath(self, positions) if has_path and table.find(positions, pf_closest_pos) then if interaction or not IsKindOf(target, "Unit") or IsMeleeRangeTarget(self, pf_closest_pos, stance, target, target_pos) then closest_pos = pf_closest_pos end end end end return closest_pos end function Unit:CalcAttackCostRange(action, target, item_id) if action.group == "FiringModeMetaAction" then local _, firingModeActions = GetUnitDefaultFiringModeActionFromMetaAction(self, action) local max, min = 0, 1000 * const.Scale.AP for i, fm in ipairs(firingModeActions) do local mode_min, mode_max = self:CalcAttackCostRange(fm, target) max = Max(max, mode_max) min = Min(min, mode_min) end return min, max end local min_aim, max_aim = self:GetBaseAimLevelRange(action) local args = {target = target, item_id = item_id} local min, max, display_cost for aim = min_aim, max_aim do args.aim = aim local ap = action:GetAPCost(self, args) if ap > 0 then min = Min(min, ap) max = Max(max, ap) end end return min, max end -- Returns the maximum aim level the unit can use to execute the provided action function Unit:GetBaseAimLevelRange(action, target) if not action.IsAimableAttack then return 0, 0 end local actionWep = action:GetAttackWeapons(self) local min, max = 0, 0 if IsKindOfClasses(actionWep, "Firearm", "MeleeWeapon") then max = actionWep.MaxAimActions end if max > 0 then max = self:CallReactions_Modify("OnCalcMaxAimActions", max, self, target, action, actionWep) if IsKindOf(target, "Unit") then max = target:CallReactions_Modify("OnCalcMaxAimActions", max, self, target, action, actionWep) end min = self:CallReactions_Modify("OnCalcMinAimActions", min, self, target, action, actionWep) if IsKindOf(target, "Unit") then min = target:CallReactions_Modify("OnCalcMinAimActions", min, self, target, action, actionWep) end max = Max(max, min) end return min, max end function Unit:GetAimLevelRange(action, target, goto_pos, is_free_aim) local minAim, maxCurrent, maxTotal minAim, maxTotal = self:GetBaseAimLevelRange(action, target) for i = 1, maxTotal do if action:GetUIState({self}, { target = target, aim = i, goto_pos = goto_pos, free_aim = is_free_aim }) ~= "enabled" then maxCurrent = i - 1 break end end return minAim, (maxCurrent or maxTotal), maxTotal end function Unit:JoinSquadAs(merc_id, squad) local unit = SpawnUnit(merc_id, merc_id, self:GetPos(), self:GetAngle()) unit:ApplyAppearance(self.Appearance) unit:SetState(self:GetState(), 0, 0) unit:SetAnimPhase(1, self:GetAnimPhase()) unit.stance = self.stance unit.current_weapon = self.current_weapon local weapon1, weapon2 = self:GetActiveWeapons(false, "strict") if IsKindOf(weapon1, "Firearm") then unit:Attach(weapon1:CreateVisualObj(unit), unit:GetSpotBeginIndex("Weaponr")) end if IsKindOf(weapon2, "Firearm") then unit:Attach(weapon2:CreateVisualObj(unit), unit:GetSpotBeginIndex("Weaponl")) end self.villain = false self.HitPoints = 0 self:ClearHierarchyEnumFlags(const.efVisible) local tidx = g_Teams and table.find(g_Teams, "side", squad.Side) if tidx then table.insert(g_Teams[tidx].units, unit) unit:SetTeam(g_Teams[tidx]) ObjModified(unit.team) end AddToGlobalUnits(unit) -- Check if trying to join a full squad. local unitCount, unitCountWithJoining = GetSquadUnitCountWithJoining(squad.UniqueId) if unitCountWithJoining >= const.Satellite.MercSquadMaxPeople then local oldSector, oldVisualPos = squad.CurrentSector, squad.VisualPos local name = SquadName:GetNewSquadName("player1") local squadParams = {Side = "player1", CurrentSector = oldSector, VisualPos = oldVisualPos, Name = name} local squad_id = CreateNewSatelliteSquad(squadParams, {merc_id}) squad = gv_Squads[squad_id] Msg("UnitJoinedPlayerSquad",squad_id) else AddUnitsToSquad(squad, {merc_id}, nil, InteractionRand(nil, "Satellite")) end -- Mark unit as spawned, so satellite doesnt respawn. local newUd = gv_UnitData[unit.session_id] assert(newUd) newUd.already_spawned_on_map = true unit.already_spawned_on_map = true -- Remove old UD from its squad. local ud = gv_UnitData[self.session_id] if ud then RemoveUnitFromSquad(ud, "despawn") end if g_Combat then Msg("UnitEnterCombat", unit) end Msg("TeamsUpdated") DoneObject(self) Msg("UnitJoinedAsMerc", unit) end function OnMsg.UnitJoinedPlayerSquad() if g_Combat then ObjModified(g_Combat) else ForceUpdateCommonUnitControlUI() end end function GetBehaviorGroups() local marker_groups = {} for id, group in sorted_pairs(Groups) do for _, o in ipairs(group) do if IsKindOf(o, "WaypointMarker") or (IsKindOf(o, "GridMarker") and (o.Type == "Position" or o.Type=="Entrance") )then marker_groups[#marker_groups + 1] = id break end end end return marker_groups end MapVar("gv_UnitGroups",false) MapVar("gv_NPCGroups",false) MapVar("gv_TargetUnitGroups",false) local groups_separator = "-----------------" function GetUnitSpawnMarkerGroups() local marker_groups = {} MapForEach("map", "UnitMarker", function(marker, marker_groups) table.iappend(marker_groups, marker.Groups or empty_table) end, marker_groups) MapForEachMarker("Defender", false, function(marker, marker_groups) table.iappend(marker_groups, marker.Groups or empty_table) end, marker_groups) MapForEachMarker("DefenderPriority", false, function(marker, marker_groups) table.iappend(marker_groups, marker.Groups or empty_table) end, marker_groups) table.sort(marker_groups) -- remove duplicates for i = #marker_groups, 2, -1 do if marker_groups[i] == marker_groups[i - 1] then table.remove(marker_groups, i) end end return marker_groups end function GetUnitGroups() if not gv_UnitGroups then RecalcGroups() end return gv_UnitGroups end function GetTargetUnitCombo() if not gv_TargetUnitGroups and not IsChangingMap() then RecalcGroups() end return gv_TargetUnitGroups end local custom_unit_groups = {"EnemySquad", "Villains"} g_AnyUnitGroups = { ["any"] = true, ["any merc"] = true, ["current unit"] = true, ["player mercs on map"] = true } function RecalcGroups() if GetMap() == "" then return end local groups = GetUnitSpawnMarkerGroups() groups[#groups+1] = groups_separator local mercs = {} for k,v in pairs(UnitDataDefs) do if IsMerc(v) then mercs[#mercs+1] = k end end table.sort(mercs) table.iappend(groups, mercs) groups[#groups+1] = groups_separator local non_mercs = {} for k,v in pairs(UnitDataDefs) do if not IsMerc(v) then non_mercs[#non_mercs+1] = k end end table.sort(non_mercs) table.iappend(groups, non_mercs) table.iappend(groups, custom_unit_groups) gv_UnitGroups = groups gv_TargetUnitGroups = table.keys2(g_AnyUnitGroups, "sorted") table.iappend(gv_TargetUnitGroups, groups) end function TFormat.GetNumAliveUnitsInGroup(context, groupName) return GetNumAliveUnitsInGroup(groupName) end function GetNumAliveUnitsInGroup(group) local num = 0 for _, obj in ipairs(Groups and Groups[group]) do if IsKindOf(obj, "Unit") and not obj:IsDead() then num = num + 1 end end return num end OnMsg.NewMapLoaded = RecalcGroups OnMsg.GameExitEditor = RecalcGroups function OnMsg.UnitDied() ObjModified(gv_Quests) end GameVar("DeadGroupsInSectors", {}) function UpdateDeadGroups(groups) local deadGroups = DeadGroupsInSectors[gv_CurrentSectorId] or {} for _, group in ipairs(groups) do local allDead = GetNumAliveUnitsInGroup(group) == 0 if allDead then deadGroups[group] = "all" else deadGroups[group] = "any" end end DeadGroupsInSectors[gv_CurrentSectorId] = deadGroups end function OnMsg.UnitDieStart(unit) UpdateDeadGroups(unit.Groups) end function OnMsg.VillainDefeated(unit) UpdateDeadGroups(unit.Groups) end function OnMsg.GatherFXActions(list) table.insert(list, "StepRun") table.insert(list, "StepWalk") table.insert(list, "StepRunCrouch") table.insert(list, "StepRunProne") table.insert(list, "Interact") for i,combat_action in ipairs(Presets.CombatAction.Interactions) do table.insert(list, combat_action.id) end end function OnMsg.GatherFXActors(list) table.insert(list, "Unit") end function Unit:AutoRemoveCombatEffects() local effect_ids = table.map(self.StatusEffects or empty_table, "class") for _, id in ipairs(effect_ids) do local def = CharacterEffectDefs[id] if def and def.RemoveOnEndCombat then self:RemoveStatusEffect(id, "all") end end end local ExitCombatUninterruptable = { ["Visit"] = true, ["EnterMap"] = true, ["Cower"] = true, } function OnMsg.CombatEnd(combat) MapForEach("map", "Unit", function(unit) unit:AutoRemoveCombatEffects() if not unit:IsDead() and g_Overwatch[unit] and (not g_Overwatch[unit].permanent or unit.team.control ~= "UI") then unit:InterruptPreparedAttack() unit:RemovePreparedAttackVisuals() end if not unit:IsDead() or unit.immortal then local overwatch = g_Overwatch[unit] if not ExitCombatUninterruptable[unit.command] then if overwatch and overwatch.permanent then -- only check for reload unit:UpdateNumOverwatchAttacks() CreateGameTimeThread(Unit.AutoReload, unit) else unit:InterruptCommand("ExitCombat") end end end end) end function Unit:IsAdjacentTo(other, check_pos) local x, y, z if check_pos then x, y, z = PosToGridCoords(check_pos:xyz()) else x, y, z = self:GetGridCoords() end local ox, oy, oz = other:GetGridCoords() return abs(x - ox) <= 1 and abs(y - oy) <= 1 and abs(z - oz) <= 1 end function Unit:CanSurround(other, check_pos) -- side if not self:IsOnEnemySide(other) or self:IsDead() or self:IsDowned() then return false end -- status effects if self:HasStatusEffect("Suppressed") then return false end -- Not valid gameplay wise, but happens in some rare cases and fires asserts down the line. local pos = check_pos or self:GetPos() if other:GetPos() == pos then return false end -- visibility if check_pos then -- checking from another position, use CheckLOS if not CheckLOS(other, self, self:GetSightRadius()) then return false end else -- checking from current position, can use precomputed visibility if not HasVisibilityTo(self, other) then return false end end -- weapon range local adjacent = self:IsAdjacentTo(other, check_pos) local in_range = false local w1, w2, weapons = self:GetActiveWeapons() for _, weapon in ipairs(weapons) do if IsKindOf(weapon, "Firearm") or (IsKindOf(weapon, "MeleeWeapon") and weapon.CanThrow) then -- heavy weapons are Firearms and go here too in_range = in_range or other:GetDist(pos) <= weapon.WeaponRange * const.SlabSizeX elseif IsKindOf(weapon, "MeleeWeapon") then in_range = in_range or adjacent end end return in_range end function Unit:IsSurrounded(unitReplace) if not g_Visibility or not g_Combat or self:IsDead() then return end local pos = unitReplace and unitReplace[self] or self:GetPos() local enemy_pos = {} local angle = 120*60 local cosa = MulDivRound(cos(angle), guim*guim, 4096) for _, team in ipairs(g_Teams) do if team.side ~= "neutral" then for _, u in ipairs(team.units) do if u:CanSurround(self, unitReplace and unitReplace[u]) then enemy_pos[#enemy_pos + 1] = unitReplace and unitReplace[u] or u:GetPos() end end end end if #enemy_pos < 2 then return end local pts = ConvexHull2D(enemy_pos) for i = 1, #pts - 1 do local v1 = pts[i]:Equal2D(pos) and point30 or SetLen(pts[i] - pos, guim) for j = i + 1, #pts do local v2 = pts[j]:Equal2D(pos) and point30 or SetLen(pts[j] - pos, guim) local dp = Dot2D(v1, v2) if dp < cosa then return true end end end end function InterpolateCoverEffect(coverage, full_value, exposed_value) local threshold = 40 if coverage >= 80 then return full_value elseif coverage < threshold then return exposed_value end return exposed_value + MulDivRound(full_value - exposed_value, coverage - threshold, threshold) end function Unit:ApplyHitDamageReduction(hit, weapon, hit_body_part, ignore_cover, ignore_armor, record_breakdown) local damage = hit.damage or 0 hit.damage = damage local weapon_pen_class = weapon:HasMember("PenetrationClass") and weapon.PenetrationClass or 1 self:ForEachItem("Armor", function(item, slot, left, top, hit, ignore_armor, record_breakdown, weapon_pen_class) if hit.damage > 0 and slot ~= "Inventory" and item.ProtectedBodyParts and item.ProtectedBodyParts[hit_body_part] then local dr, degrade, pierced if not ignore_armor and item.Condition > 0 then dr = item.DamageReduction degrade = item.Degradation if weapon_pen_class < item.PenetrationClass then dr = dr + item.AdditionalReduction degrade = MulDivRound(degrade, const.Combat.ArmorDegradePercent, 100) else pierced = true end else pierced = true end -- scale DR down on poor condition dr = MulDivRound(dr or 0, Min(100, 50 + item.Condition), 100) local scaled = hit.damage * (100 - dr) local result = scaled / 100 if pierced and scaled % 100 > 0 then -- round the resulting damage up when pierced only result = result + 1 end if record_breakdown then if pierced then record_breakdown[#record_breakdown + 1] = { name = T{191288543859, " (Pierced)", item}, value = -dr } else record_breakdown[#record_breakdown + 1] = { name = T{516752639882, "", item}, value = -dr } end end hit.damage = Min(hit.damage, result) if not hit.armor_decay then hit.armor_decay = {} end if not hit.armor_pen then hit.armor_pen = {} end hit.armor_decay[item] = Min(item.Condition, degrade or 0) if pierced then hit.armor_pen[item] = true end end end, hit, ignore_armor, record_breakdown, weapon_pen_class) local armor_prevented = damage - hit.damage -- HoldPosition perk damage reduction if HasPerk(self, "HoldPosition") and (g_Overwatch[self] or g_Pindown[self]) then local statPercent = CharacterEffectDefs.HoldPosition:ResolveValue("percentHealth") local percent_reduction = MulDivRound(self.Health, statPercent, 100) if record_breakdown then record_breakdown[#record_breakdown + 1] = { name = CharacterEffectDefs.HoldPosition.DisplayName, value = -percent_reduction } end hit.damage = Max(0, MulDivRound(hit.damage, 100 - percent_reduction, 100)) end local armor = next(hit.armor_decay) hit.armor = armor and armor.DisplayName hit.armor_prevented = armor_prevented end function Unit:IsArmored(target_spot_group) if self:IsDead() then return false end local armorFound = false self:ForEachItem("Armor", function(item, slot) if slot ~= "Inventory" and (not target_spot_group or item.ProtectedBodyParts and item.ProtectedBodyParts[target_spot_group]) then armorFound = item return "break" end end) local iconName = false if armorFound and armorFound.PenetrationClass > 1 then local classId = PenetrationClassIds[armorFound.PenetrationClass] iconName = classId:lower() .. "_armor" end return armorFound, iconName, "UI/Hud/" end function Unit:ApplyDamageAndEffects(attacker, damage, hit, armor_decay) if self:IsDead() or not IsValid(self) then return end if damage and damage > 0 or hit.setpiece then self:TakeDamage(damage or 0, attacker, hit) end local invulnerable = self:IsInvulnerable() if not invulnerable then local effects = hit.effects if type(effects) == "string" and effects ~= "" then self:AddStatusEffect(effects) else for _, effect in ipairs(effects) do if effect and effect ~= "" then self:AddStatusEffect(effect) end end end end -- blood/soot stains local was_wounded = self:HasStatusEffect("Wounded") if hit.direct_shot then -- bullet-simulated firearm attacks local spot, params = CalcStainParamsFromShot(self, attacker, hit) if spot then assert(not params or type(params) == "table") self:AddStain("Blood", spot, params) end elseif not was_wounded then -- pick a spot and save it as effect value for the Wounded status to use if it gets applied local spot if hit.melee_attack then spot = GetRandomStainSpot(hit.spot_group) else spot = GetRandomStainSpot() end if spot then self:SetEffectValue("wounded_stain_spot", spot) end end -- cleanup wounded_stain_spot self:SetEffectValue("wounded_stain_spot", nil) -- add soot from explosions (if there's no blood) if hit.explosion and not self:HasStainType("Blood") then local spot = GetRandomStainSpot() self:AddStain("Soot", spot) end if not invulnerable then local change = false for item, degrade in pairs(armor_decay) do item.Condition = self:ItemModifyCondition(item, - degrade) if IsKindOf(item, "TransmutedItemProperties") and item.RevertCondition=="damage" then item.RevertConditionCounter = item.RevertConditionCounter-1 if item.RevertConditionCounter== 0 then local slot_name = self:GetItemSlot(item) local new, prev = item:MakeTransmutation("revert") armor_decay[new] = degrade armor_decay[item] = false self:RemoveItem(slot_name, item) self:AddItem(slot_name, new) DoneObject(prev) change = true end end end if change then self:UpdateOutfit() end end end function Unit:SwapActiveWeapon(action_id, cost_ap) local igi = GetInGameInterfaceModeDlg() if IsKindOf(igi, "IModeCombatBase") and igi.attacker == self then InvokeShortcutAction(igi, "ExitAttackMode", igi) end if self.current_weapon == "Handheld A" then self.current_weapon = "Handheld B" else self.current_weapon = "Handheld A" end self:OnSetActiveWeapon(action_id, cost_ap) end function Unit:OnSetActiveWeapon(action_id, cost_ap) if not self.current_weapon then return end if HasPerk(self, "Scoundrel") and g_Combat then self:ActivatePerk("Scoundrel") PlayVoiceResponse(self, "Scoundrel") end if GameTimeAdvanced then if g_Overwatch[self] and not self:FindWeaponInSlotById(self.current_weapon, g_Overwatch[self].weapon_id) or g_Pindown[self] and not self:FindWeaponInSlotById(self.current_weapon, g_Pindown[self].weapon_id) or self.prepared_bombard_zone and not self:FindWeaponInSlotById(self.current_weapon, self.prepared_bombard_zone.weapon_id) then self:InterruptPreparedAttack() end end self:UpdateOutfit(self.Appearance) self:RecalcUIActions() --self.lastFiringMode = false self:SetAimTarget(false, false) if not cost_ap or cost_ap == 0 then -- if the action was free we still want the UI to reflect possible differences Msg("UnitAPChanged", self, action_id) end Msg("UnitSwappedWeapon", self) ObjModified(self) end function Unit:StartAI(debug_data, forced_behavior) if not IsValid(self) or self:IsDead() or self.ai_context or self:HasStatusEffect("Unconscious") then return end AIReloadWeapons(self) local proto_context = {} self:SelectArchetype(proto_context) --local context = AICreateContext(self, proto_context) --local archetype = context.archetype local archetype = self:GetArchetype() local scores, available = {}, {} local total = 0 AIUpdateBiases() for i, behavior in ipairs(archetype.Behaviors) do local weight_mod, disable, priority if behavior:MatchUnit(self) then weight_mod, disable, priority = AIGetBias(behavior.BiasId, self) priority = priority or behavior.Priority else weight_mod, disable, priority = 0, true, false end if debug_data then debug_data.behaviors = debug_data.behaviors or {} debug_data.behaviors[i] = { name = behavior:GetEditorView(), priority = priority, disable = disable, behavior = behavior, index = i, } end if not disable then local score = MulDivRound(behavior:Score(self, proto_context, debug_data), weight_mod, 100) if debug_data then debug_data.behaviors[i].score = score end if score > 0 then if priority and not forced_behavior then forced_behavior = behavior break end scores[#scores + 1] = score available[#available + 1] = behavior total = total + score end end end if total == 0 and not forced_behavior then printf("unit of %s archetype failed to select a behavior!", archetype.id) return end local roll = InteractionRand(total, "AIBehavior", self) local selected if not forced_behavior then for i, behavior in ipairs(available) do local score = scores[i] if roll <= score then selected = behavior break end roll = roll - score end end if self.ai_context then self.ai_context.behavior = forced_behavior or selected or available[#available] else proto_context.behavior = forced_behavior or selected or available[#available] AICreateContext(self, proto_context) end if self.ai_context.behavior then self.ai_context.behavior:OnStart(self) end return true end function UpdateSurrounded() for _, unit in ipairs(g_Units) do if unit:IsSurrounded() then unit:AddStatusEffect("Flanked") else unit:RemoveStatusEffect("Flanked") end end end function RollSkillCheck(unit, skill, modifier, add) assert(IsKindOf(unit, "UnitPropertiesStats")) modifier = modifier or 100 add = add or 0 local roll = 1 + unit:Random(100) --adjust roll based on diff local adjustRoll = GameDifficulties[Game.game_difficulty]:ResolveValue("rollSkillCheckBonus") or 0 roll = roll + adjustRoll roll = Min(roll, 95) local value = MulDivRound(unit[skill], modifier, 100) + add local pass = roll < value or CheatEnabled("SkillCheck") --CombatLog("debug", local t_res = pass and Untranslated("Pass") or Untranslated("Fail") local meta = unit:GetPropertyMetadata(skill) local t_skill = meta.name if modifier ~= 100 then if add > 0 then t_skill = T{816405633181, " +", n1 = modifier, n2 = add, skill = meta.name} elseif add < 0 then t_skill = T{656059859333, " ", n1 = modifier, n2 = add, skill = meta.name} else t_skill = T{570928040607, " ", number = modifier, skill = meta.name} end elseif add > 0 then t_skill = T{481345361355, "+", number = add, skill = meta.name} elseif add < 0 then t_skill = T{945399039468, "", number = add, skill = meta.name} end CombatLog("debug", T{Untranslated(" Skill check () /: "), name = unit:GetLogName(), skill = t_skill, roll = roll, target = value, result = t_res, }) return pass end function SkillCheck(unit, skill, threshold,dont_report_fails) if not unit or not IsKindOf(unit, "UnitPropertiesStats") then return "error" end local stat = unit[skill] if not stat then return "error" end if threshold <= stat or CheatEnabled("SkillCheck") then CombatLog("debug", "(success) " .. unit.session_id .. " " .. skill.. " check (" .. stat.. " / " ..threshold .. ")") PlayFX("SkillCheck", "success", unit, skill) return "success", stat - threshold, stat end if not dont_report_fails then PlayFX("SkillCheck", "fail", unit, skill) CombatLog("debug", "(fail) " .. unit.session_id .." " .. skill.. " check (" .. stat.. " / " ..threshold .. ")") end return "fail", threshold - stat, stat end function SpawnUnit(class, session_id, pos, angle, groups, spawner, entrance) session_id = session_id or class NetUpdateHash("SpawnUnit", class, session_id, pos) local unit_data = CreateUnitData(class, session_id, InteractionRand(nil, "Satellite")) local unit_group = UnitDataDefs[unit_data.class].group local unit = Unit:new{ unitdatadef_id = unit_data.class, group = unit_group, session_id = session_id, spawner = spawner, entrance_marker = entrance, } AddToGlobalUnits(unit) for _, group in ipairs(groups) do unit:AddToGroup(group) end if angle then unit:SetAngle(angle) end if pos then unit:SetPos(pos) end if unit:IsNPC() and not unit.dummy then if IsKindOf(spawner, "UnitMarker") then for _, effect in ipairs(spawner.status_effects) do if CharacterEffectDefs[effect] then unit:AddStatusEffect(effect) end end end if IsKindOf(spawner, "GridMarker") and spawner.Suspicious or IsKindOf(entrance, "GridMarker") and entrance.Suspicious then unit:AddStatusEffect("HighAlert") if not spawner or spawner.Side ~= "neutral" then unit:AddStatusEffect("Unaware") end else local data = gv_UnitData[session_id] local squad_idx = data and data.Squad and table.find(g_SquadsArray, "UniqueId", data.Squad) local squad = squad_idx and g_SquadsArray[squad_idx] if squad and squad.militia then unit:AddStatusEffect("HighAlert") elseif not spawner or spawner.Side ~= "neutral" then unit:AddStatusEffect("Unaware") end end end unit:SetTargetDummyFromPos() return unit end function ValidateUnitGroupForEffectExec(group, effect, trigger_obj) local units = Groups[group] if Platform.developer and GameState.entered_sector and not units then local trigger_obj_idx = 1 local errs = {} local sector_ids, effect_classes = {}, {} local function addErr(effect, parents, obj) if effect:HasMember("Group") and effect.Group == group then if obj == trigger_obj then trigger_obj_idx = #errs + 1 end if IsKindOf(obj, "QuestsDef") then errs[#errs + 1] = {obj, string.format("Effect %s with invalid group %s in quest %s", effect.class, group, obj.id)} elseif IsKindOf(obj, "SatelliteSector") then sector_ids[#sector_ids + 1] = obj.Id effect_classes[#effect_classes + 1] = effect.class elseif IsKindOf(obj, "GridMarker") then errs[#errs + 1] = {obj, string.format("Effect %s with invalid group %s in marker", effect.class, group)} end end end -- check quest effects ForEachPresetInCampaign("QuestsDef", function(quest) if quest.TCEs and next(quest.TCEs) then for _, tce in ipairs(quest.TCEs) do tce:ForEachSubObject("Effect", addErr, quest) end end end) -- check sector effects local campaign_preset = Game.Campaign and CampaignPresets[Game.Campaign] for _, sector in ipairs(campaign_preset and campaign_preset.Sectors) do for _, event in ipairs(sector.Events) do sector:ForEachSubObject("Effect", addErr, sector) end end if next(sector_ids) then errs[#errs + 1] = {campaign_preset, string.format("Effects - %s - with invalid group %s in sectors: %s", table.concat(effect_classes, ", "), group, table.concat(sector_ids, ", "))} end --check grid marker effects MapForEachMarker("GridMarker", nil, function(marker) marker:ForEachSubObject("Effect", addErr, marker) end) errs[1], errs[trigger_obj_idx] = errs[trigger_obj_idx], errs[1] for _, err in ipairs(errs) do StoreErrorSource(err[1], err[2]) end end return units or empty_table end OnMsg.VisibilityUpdate = UpdateSurrounded OnMsg.CombatApplyVisibility = UpdateSurrounded OnMsg.CombatStart = UpdateSurrounded OnMsg.CombatEnd = UpdateSurrounded function OnMsg.CombatStart(dynamic_data) local transfer_keys = { "hmg_emplacement", "spent_ap", "PrisonDoor", "CellLeaveBanters" } for _, unit in ipairs(g_Units) do if unit.team.side == "neutral" and not unit.behavior then unit:SetBehavior("GoBackAfterCombat", {unit:GetPos()}) end if not dynamic_data then local values = {} for i, key in ipairs(transfer_keys) do values[i] = unit:GetEffectValue(key) end unit.effect_values = nil -- reset effect data on combat start for i, key in ipairs(transfer_keys) do if values[i] ~= nil then unit:SetEffectValue(key, values[i]) end end if unit.carry_flare then unit:RoamDropFlare() end end unit.marked_target_attack_args = nil -- invalid in combat unit.neutral_retal_attacked = nil end end DefineClass.DummyUnit = { __parents = { "AppearanceObject" }, flags = { gofPermanent = true, gofUnitLighting = false }, properties = { {category = "Dummy Unit", id = "UnitLighting", name = "Unit Lighting", editor = "bool", default = false, }, {category = "Dummy Unit", id = "FreezePhase", name = "Freeze Phase", editor = "number", default = false, slider = true, min = 0, max = function(obj) return GetAnimDuration(obj:GetEntity(), obj.anim) - 1 end, help = "The unit will be freezed at this frame.", }, }, entity = "Male", Appearance = "Raider_01", } function DummyUnit:GameInit() self:UpdateFreezePhase() end function DummyUnit:OnEditorSetProperty(prop_id, old_value, ged) if prop_id == "FreezePhase" or prop_id == "anim" then self:UpdateFreezePhase() end end function DummyUnit:UpdateFreezePhase() local phase = self:GetProperty("FreezePhase") if phase then self:SetAnimPose(self.anim, phase) self:SetAnimSpeedModifier(0) else self:SetAnimSpeedModifier(1000) end end -- NOTE: Level Designers are setting the anim through StateText which gets overridden by 'anim' property function DummyUnit:SetStateText(state) AppearanceObject.SetStateText(self, state) self:SetProperty("anim", state) end function DummyUnit:SetUnitLighting(value) if value then self:SetHierarchyGameFlags(const.gofUnitLighting) else self:ClearHierarchyGameFlags(const.gofUnitLighting) end self:DestroyRenderObj() end function DummyUnit:GetUnitLighting(value) return self:GetGameFlags(const.gofUnitLighting) ~= 0 end function ErnyTown_HangUnit(group_id) local group = Groups[group_id] local units = {} for _, o in ipairs(group) do if IsKindOf(o, "Unit") then table.insert(units, o) end end if #units ~= 1 then StoreErrorSource(point30, "There should be exactly one unit of the group " .. group_id) return end local unit = units[1] unit:SetCommand("Hang") unit:SetGroups(false) end function OnMsg.ValidateMap() if Game and not g_IdleAnimActionStances then FillIdleAnimActionsAndStances() end end function OnMsg.ChangeMapDone(map) if IsModEditorMap(map) and not g_IdleAnimActionStances then FillIdleAnimActionsAndStances() end end if FirstLoad then g_IdleAnimActionStances = false end local function GetAllEntitiesValidAnimations() local dummy = PlaceObject("Unit", { NetUpdateHash = empty_func, IsSyncObject = function(self) return false end, }) dummy:ClearGameFlags(const.gofSyncObject) dummy:SetCommand(false) dummy:SetPos(point30) local anims = {} for id, app in sorted_pairs(AppearancePresets) do if app.Body then dummy:ApplyAppearance(AppearancePresets[id]) if IsValidEntity(dummy:GetEntity()) then dummy:SetState("idle") local dummy_anims = ValidAnimationsCombo(dummy) for _, a in ipairs(dummy_anims) do anims[a] = true end end end end DoneObject(dummy) return anims end function FillIdleAnimActionsAndStances() g_IdleAnimActionStances = { no_weapon = {g_StanceActionDefault, [g_StanceActionDefault] = {g_StanceActionDefault}}, weapon = {g_StanceActionDefault, [g_StanceActionDefault] = {g_StanceActionDefault}} } local anims = GetAllEntitiesValidAnimations() for a, _ in sorted_pairs(anims) do local prefix, stance, action = string.match(a, "(.*)_(.*)_(.*)") if prefix and stance and action then local key = prefix == "civ" and "no_weapon" or "weapon" table.insert_unique(g_IdleAnimActionStances[key], stance) g_IdleAnimActionStances[key][stance] = g_IdleAnimActionStances[key][stance] or {g_StanceActionDefault} table.insert_unique(g_IdleAnimActionStances[key][stance], action) end end end function GetIdleAnimStances(use_weapons) assert(g_IdleAnimActionStances) return g_IdleAnimActionStances[use_weapons and "weapon" or "no_weapon"] end function GetIdleAnimStanceActions(use_weapons, stance) assert(g_IdleAnimActionStances) return g_IdleAnimActionStances[use_weapons and "weapon" or "no_weapon"][stance] end function Unit:ResolveUIAction(idx) local id = self.ui_actions and self.ui_actions[idx] return id and self.ui_actions[id] and CombatActions[id] end function Unit:IsAware(check_pending) if self.team and self.team.side == "neutral" or self.command == "Die" or self:IsDead() then return false end local status_effects = self.StatusEffects if check_pending then if self.pending_aware_state == "aware" or self.pending_aware_state == "surprised" or status_effects["Surprised"] then return true end end return not status_effects["Unaware"] and not status_effects["Suspicious"] and not status_effects["Surprised"] end function Unit:IsSuspicious() if self:IsDead() or self.command == "Die" then return false end return self:HasStatusEffect("Suspicious") end function Unit:AddToInventory(item_id, amount, callback) if not item_id then return 0 end amount = amount or 1 local unit_amount = 0 self:ForEachItemInSlot("Inventory", "InventoryStack", function(curitm, slot_name, item_left, item_top) if curitm and item_id and curitm.class == item_id and curitm.Amount < curitm.MaxStacks then local to_add = Min(curitm.MaxStacks - curitm.Amount, amount) curitm.Amount =curitm.Amount + to_add Msg("InventoryAddItem", self, curitm, to_add) amount = amount - to_add unit_amount = unit_amount + to_add if amount <= 0 then if callback then callback(self, curitm, unit_amount) end return "break" end end end) local itm while amount > 0 do local item = PlaceInventoryItem(item_id) local is_stack = IsKindOf(item, "InventoryStack") if self:AddItem("Inventory", item) then local to_add = 1 if is_stack then to_add = Min(item.MaxStacks, amount) item.Amount = to_add end unit_amount = unit_amount + to_add amount = amount - to_add unit_amount = unit_amount + to_add Msg("InventoryAddItem", self, item, to_add) itm = item else DoneObject(item) break end end if callback and itm and unit_amount>0 then callback(self, itm, unit_amount) end ObjModified(self) return amount end -- Drop a container with a new item created from item_id. function Unit:DropItemContainer(item_id, amount, callback) if amount <= 0 then return end local item = PlaceInventoryItem(item_id) local is_stack = IsKindOf(item, "InventoryStack") if is_stack then item.Amount = amount end local container = GetDropContainer(self, false,item ) if container then local pos, res = container:AddItem("Inventory", item) if pos and callback then callback(self, item, amount) end end end -- Add already generated items (from loot table) into a container and drop it. Stack them if possible. function Unit:DropItemsInContainer(items, callback) local container = GetDropContainer(self) if not container then return end for i = #items, 1, -1 do local item = items[i] if not container:CanAddItem("Inventory", item) then container = GetDropContainer(self, false, item) end local pos, reason = container:AddItem("Inventory", item) if pos then if callback then callback(self, item, IsKindOf("InventoryStack") and item.Amount or 1) end table.remove(items, i) end end end function Unit:SetHighlightReason(reason, enable) self.highlight_reasons = self.highlight_reasons or {} self.highlight_reasons[reason] = enable self:UpdateHighlightMarking() if self.session_id then ObjModified(self.session_id .. "_combat_badge") end if reason == "deploy_predict" and self.ui_badge then self.ui_badge:SetVisible(not enable, "deploy_predict") end end function Unit:UpdateHighlightMarking() if WaitRecalcVisibility then return end local marking = false -- if the unit was set invisible by visibility logic we dont want to mark it, as it would show it if not self.visible or IsSetpiecePlaying() or CheatEnabled("IWUIHidden") then marking = -1 end -- during combat, only show marking of playing if not marking then local pov_team = GetPoVTeam() local enemyTurn = g_Combat and g_Teams[g_CurrentTeam] ~= pov_team local playing = IsMerc(self) or (g_AIExecutionController and table.find(g_AIExecutionController.currently_playing, self)) -- if merc skip this check so we show hard to see mercs if enemyTurn and not playing then marking = -1 end end if not marking then local reasons = self.highlight_reasons if reasons["dark voxel"] then marking = 3 elseif reasons["area target"] then marking = 3 elseif reasons["melee"] then marking = 3 elseif reasons["melee-target"] then marking = 3 elseif reasons["bandage-target"] then marking = 0 elseif reasons["concealed"] then if HasThermalVision(Selection) then marking = 10 else marking = 9 end elseif reasons["obscured"] then marking = 7 elseif reasons["visibility"] then local pov_team = GetPoVTeam() if pov_team:IsEnemySide(self.team) then local enemyTurn = g_Combat and g_Teams[g_CurrentTeam] ~= pov_team local playing = g_AIExecutionController and table.find(g_AIExecutionController.currently_playing, self) local seen = enemyTurn or playing if not seen then for _, unit in ipairs(Selection) do if HasVisibilityTo(unit, self) then seen = true break end end end marking = seen and 3 or 1 elseif reasons["faded"] or g_Combat and not g_Combat:ShouldEndCombat() then -- ally and neutral only get marking in conflict unless they're walking in the air if pov_team:IsAllySide(self.team) then marking = 0 else -- neutral marking = 2 end end elseif reasons["deploy_predict"] then marking = 5 elseif reasons["darkness"] then marking = 8 elseif reasons["can_be_interacted"] then marking = 11 end end marking = marking or -1 self:SetObjectMarking(marking) if marking < 0 then self:ClearHierarchyGameFlags(const.gofObjectMarking) else self:SetHierarchyGameFlags(const.gofObjectMarking) end end const.utWellRested = -1 const.utNormal = 0 const.utTired = 1 const.utExhausted = 2 const.utUnconscious = 3 UnitTirednessEffect = { [const.utWellRested] = "WellRested", [const.utNormal] = "Default", [const.utTired] = "Tired", [const.utExhausted] = "Exhausted", [const.utUnconscious] = "Unconscious", } function TFormat.tiredness(context_obj, value) local effect = UnitTirednessEffect[value] if effect and g_Classes[effect] then return g_Classes[effect].DisplayName elseif effect == "Default" then return T(714191851131, "Normal") end return "" end function UnitTirednessComboItems() local items = {} for k, v in sorted_pairs(UnitTirednessEffect) do items[#items + 1] = { name = v, value = k } end return items end function OnMsg.UnitRelationsUpdated() for _, unit in ipairs(g_Units) do unit:UpdateMeleeTrainingVisual() end end -- sort units map by pos function SortUnitsMap(units_map) if not units_map or not next(units_map) then return units_map end local first_key = next(units_map) if next(units_map, first_key) == nil then return {{id = first_key.session_id, unit = first_key, data = units_map[first_key]}} end local positions = {} for unit, data in pairs(units_map) do if IsValid(unit) then positions[#positions + 1] = {id = unit.session_id, unit = unit, data = data} end end table.sortby(positions, "id") return positions end function Unit:GetBodyParts(attack_weapon) local list = Presets.TargetBodyPart.Default if self.species == "Human" then local head = list.Head if IsKindOf(attack_weapon, "MeleeWeapon") then head = list.Neck end return { head, list.Arms, list.Torso, list.Groin, list.Legs } end return { list.Head, list.Torso, list.Legs } end function Unit:ShowMishapNotification(action) if self.team.player_team then local text = action:GetAttackWeapons(self).DisplayName HideTacticalNotification("playerAttack") ShowTacticalNotification("playerAttack", false, T{989807512852, " Mishap", attack = text}) else local text = GetTacticalNotificationText("enemyAttack") or action:GetAttackWeapons(self).DisplayName HideTacticalNotification("enemyAttack") ShowTacticalNotification("enemyAttack", false, T{989807512852, " Mishap", attack = text}) end CreateFloatingText(self, T(371973388445, "Mishap!"), "FloatingTextMiss") end function Unit:GetValidStance(target_stance, pos) if target_stance == "Prone" then local side = self.team and self.team.side or "player1" local pfclass = CalcPFClass(side, "Prone", self.body_type) if not GetPassSlab(pos or self, pfclass) then return "Crouch" end end return target_stance end function Unit:CanSwitchStance(toDoStance, args) --no stance switch available for machineguns if self:IsStanceChangeLocked() then return false end --no stance switch for prone - addition checks local action if toDoStance == "Standing" then action = "StanceStanding" elseif toDoStance == "Crouch" then action = "StanceCrouch" elseif toDoStance == "Prone" then local unitOrGotoPos = args and args.goto_pos or self local in_water = terrain.IsWater(unitOrGotoPos) -- disable when all the units are in water if in_water then return false, AttackDisableReasons.Water end local valid_stance = self:GetValidStance("Prone", unitOrGotoPos) if valid_stance ~= "Prone" then return false, AttackDisableReasons.Stairs end action = "StanceProne" end --no stance switch avaible for lack of ap local cost = CombatActions[action]:GetAPCost(self, args) if cost < 0 then return false, "hidden" end if not self:UIHasAP(cost, action) then return false, GetUnitNoApReason(self) end return true end function Unit:IsStanceChangeLocked() if IsKindOf(self:GetActiveWeapons(), "MachineGun") and (self.behavior == "OverwatchAction" or self.combat_behavior == "OverwatchAction")then return true end if self:GetBandageTarget() then -- bandaging other unit return true end return false end MapVar("g_AttackRevealQueue", false) function Unit:AttackReveal(action, attack_args, results) local attacker = self local target = attack_args.target local killed = results.killed_units or empty_table if not g_Combat then g_AttackRevealQueue = {self} end if IsKindOf(target, "Unit") and not target:IsDead() and not table.find(killed, target) then if g_Combat then self:RevealTo(target) else g_AttackRevealQueue[#g_AttackRevealQueue + 1] = target end end for _, hit in ipairs(results) do local unit = IsKindOf(hit.obj, "Unit") and not hit.obj:IsIncapacitated() and hit.obj if unit and unit.team ~= self.team and not unit:IsDead() and not table.find(killed, unit) then if g_Combat then self:RevealTo(unit) else g_AttackRevealQueue[#g_AttackRevealQueue + 1] = unit end end end end function Unit:OnEnemySighted(other) --printf("%s has seen %s", unit.unitdatadef_id, other.unitdatadef_id) if HasPerk(self, "AlwaysReady") and not self:HasStatusEffect("Hidden") and g_Combat and not g_Combat:ShouldEndCombat() and g_Teams[g_CurrentTeam] ~= self.team and not self:HasPreparedAttack() and not self:IsThreatened(nil, "overwatch") then self:TryActivateAlwaysReady(other) end end function Unit:GetMoveAPCost(dest) if not dest or not (g_Combat or g_StartingCombat) then return 0 end local move_cost = 0 local path = GetCombatPath(self) move_cost = path and path:GetAP(dest) if not move_cost then return -1 end return Max(0, move_cost - self.free_move_ap) end function Unit:HasNightVision() if HasPerk(self, "NightOps") then return true end local helm = self:GetItemInSlot("Head") return IsKindOf(helm, "NightVisionGoggles") and helm.Condition > 0 end function NetSyncEvents.SetAutoFace(obj, auto_face) if not obj or obj.auto_face == auto_face then return end obj.auto_face = auto_face -- reorientation if auto_face and obj.command == "Idle" and obj.stance ~= "Prone" and not obj.interrupt_callback and not IsValidThread(obj.reorientation_thread) then obj.reorientation_thread = CreateGameTimeThread(function(obj) Sleep(obj:Random(500)) if obj.auto_face and obj.command == "Idle" and (obj.stance == "Standing" or obj.stance == "Crouch") and not obj.interrupt_callback then obj:InterruptCommand("Idle") end obj.reorientation_thread = false end, obj) end end function OnMsg.SelectedObjChange() local obj = SelectedObj if not obj then return end if not obj.auto_face then return end if not (g_Combat and g_Teams and g_Teams[g_CurrentTeam] == obj.team) then return end if not obj:IsLocalPlayerControlled() then return end NetSyncEvent("SetAutoFace", obj, false) end function OnMsg.SelectionRemoved(obj) if not (g_Combat and g_Teams and g_Teams[g_CurrentTeam] == obj.team) then return end if not obj:IsLocalPlayerControlled() then return end if obj.stance == "Prone" then return end NetSyncEvent("SetAutoFace", obj, true) end function OnMsg:TurnEnded(team) for i, unit in ipairs(g_Teams[g_CurrentTeam].units) do unit.auto_face = true end end -- TargetDummy DefineClass.TargetDummy = { __parents = { "Movable", "SyncObject", "AppearanceObject" }, flags = { efUnit = true, efVisible = false, efSelectable = false, efWalkable = false, efCollision = false, efPathExecObstacle = false, efResting = false, efApplyToGrids = false, efShadow = false, efSunShadow = false, gofOnRoof = false, }, __toluacode = empty_func, obj = false, stance = false, locked = false, } DefineClass.TargetDummyLargeAnimal = { __parents = { "TargetDummy" }, } function TargetDummy:Init() local obj = self.obj if obj then self:ApplyAppearance(obj.Appearance) pf.SetGroundOrientOffsets(self, table.unpack(obj:GetGroundOrientOffsets())) end self:SetDestlockRadius(obj and obj:GetDestlockRadius() or 0) self:SetCollisionRadius(0) self:SetAnimSpeed(1, 0) Msg("NewTargetDummy", self) -- for debug visualization end -- disable setting efPathExecObstacle TargetDummy.InitPathfinder = empty_func TargetDummy.EnterPathfinder = empty_func TargetDummy.EnterPathfinder = empty_func function SavegameSectorDataFixups.ClearTargetDummies(sector_data) local spawn_data = sector_data.spawn while true do local idx = table.find(spawn_data, "TargetDummy") if not idx then break end table.remove(spawn_data, idx + 1) -- handle table.remove(spawn_data, idx) -- TargetDummy end end function IsLastUnitInTeam(units) local lastStanding = false for _, unit in ipairs(units) do if not unit:IsDead() and not lastStanding then lastStanding = unit elseif not unit:IsDead() and lastStanding then return false end end return lastStanding end function SavegameSessionDataFixups.ExpFixup(data, metadata, lua_ver) local ud = data.gvars.gv_UnitData for _, data in pairs(ud) do if not data.Experience then local minXP = GetXPTable(data.StartingLevel) data.Experience = minXP end data:RemoveModifier("ExperienceBonus", "Experience") end end UndefineClass('AdditionalGroup') DefineClass.AdditionalGroup = { __parents = { "PropertyObject" }, properties = { { id = "Weight", name = "Weight", editor = "number", min = 0, max = 100, default = 100, help = "Integer numbers.(0:never picked / 100:always picked)"}, { id = "Exclusive", name = "Mutually Exclusive", editor = "bool", default = false, help = "If marked as exclusive, only one will be chosen from all marked as exclusive. NB: If only one is marked as exclusive and weight > 0, it will be ALWAYS picked." }, { id = "Name", name = "Name", editor = "text", default = "", help = "The name of the group." }, }, } function AdditionalGroup:EditorView() return string.format("AdditionalGroup %s", self.Name and self.Name ~= "" and ("- " .. self.Name) or "") end function Unit:IsConcealedFrom(observer) return GameState.Fog and not self.indoors and not IsCloser(self, observer, const.EnvEffects.FogUnkownFoeDistance) end function Unit:IsObscuredFrom(observer) return GameState.DustStorm and not self.indoors and not IsCloser(self, observer, const.EnvEffects.DustStormUnkownFoeDistance) end function HasThermalVision(units) for _, unit in ipairs(units) do local _, _, weapons = unit:GetActiveWeapons() for _, weapon in ipairs(weapons) do if weapon:HasComponent("IgnoreConcealAndObscure") then return true end end end end function Unit:UIObscured() local side = self.CurrentSide or (self.team and self.team.side) if not side or side == "player1" or side == "player2" or side == "ally" then return false end if not GameState.DustStorm then return false end local units = Selection or empty_table if #units == 0 then local team = GetPoVTeam() units = team and team.units end local obscured = true for _, unit in ipairs(units) do obscured = obscured and CheckSightCondition(unit, self, const.usObscured) end return obscured end function Unit:UIConcealed(skip_check) local side = self.CurrentSide or (self.team and self.team.side) if not side or side == "player1" or side == "player2" or side == "ally" then return false end if not GameState.Fog then return false end if not skip_check and HasThermalVision(Selection) then return false end local concealed = true local units = Selection or empty_table if #units == 0 then local team = GetPoVTeam() units = team and team.units end for _, unit in ipairs(units) do concealed = concealed and CheckSightCondition(unit, self, const.usConcealed) end return concealed end function Unit:GetDisplayName() if self:UIObscured() or self:UIConcealed() then return T(393866533740, "???") end return UnitProperties.GetDisplayName(self) end function Unit:HasVisibleEffects() if self.team.neutral then return false end return StatusEffectObject.HasVisibleEffects(self) end function Unit:FastForwardCommand() if self.command == "ExitMap" and not self:HasStatusEffect("DontFastForwardExit") then self:Despawn() end end function Unit:SetCommandIfNotDead(...) if self:IsDead() or self.command == "Die" then return end self:SetCommand(...) end DefineClass.World_HangingSkeleton_Base = { __parents = { "CombatObject", "DecorStateFXAutoAttachObject" }, flags = { efApplyToGrids = false }, }