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 },
}