myspace / Lua /Tactical /CombatAI.lua
sirnii's picture
Upload 1816 files
b6a38d7 verified
raw
history blame
90.1 kB
const.AIDecisionThreshold = 80 -- targets/locations up to this percent of max scored target/location can be selected
const.AIPointBlankTargetMod = 50 -- targets in point-blank range get +50% score
const.AIFallbackWeight_OpenDoor = 100
const.AIFallbackWeight_ClosedDoor = 40
const.AIFallbackWeight_Window = 70
const.AIAvoidFireWeigth = -200
const.AIAvoidGasWeigth = -200
const.AIAvoidBombardEdge = 100 -- % of score retained at the border of the zone
const.AIAvoidBombardCenter = 30 -- % of score retained at the center of the zone
const.AIFriendlyFire_MaxRange = 10 * const.SlabSizeX -- max range to ally for it to be considered in danger
const.AIFriendlyFire_LOFWidth = 100*guic -- max distance from an ally to the line between position and target considered in danger
const.AIFriendlyFire_LOFConeNear = 100*guic -- same as above for cone attacks (near side of the cone, positioned at attacker)
const.AIFriendlyFire_LOFConeFar = 300*guic -- same as above for cone attacks (far side of the cone, positioned at AIFriendlyFire_MaxRange)
const.AIFriendlyFire_ScoreMod = 50 -- % of damage score evaluation remanining when an ally is in danger
const.AIShootAboveCTH = 0
local function CanReload(unit, weapon)
if not IsKindOf(weapon, "Firearm") then
return false
end
if (weapon.ammo and weapon.ammo.Amount or 0) >= weapon.MagazineSize then
return false
end
if not unit:HasAP(CombatActions["Reload"]:GetAPCost(unit)) then
return false
end
local ammo_type
if (weapon.ammo and weapon.ammo.Amount or 0) > 0 then
ammo_type = weapon.ammo.class
end
local ammo = unit:GetAvailableAmmos(weapon, ammo_type)
if not ammo or not ammo[1] then
return false
end
return true
end
function WaitIdle(unit)
while IsValidTarget(unit) and not unit:IsIdleCommand() do
WaitMsg("Idle", 200)
end
end
local remove_action_cam_actions = { Move = true, MeleeAttack = true, ThrowGrenadeA = true, ThrowGrenadeB = true , ThrowGrenadeC = true, ThrowGrenadeD = true}
function AIStartCombatAction(action_id, unit, ap, args, ...)
if not ap then
ap = CombatActions[action_id]:GetAPCost(unit, args, ...)
end
if not ap or ap < 0 or not unit:HasAP(ap, action_id) then
return false
end
if ActionCameraPlaying then
local waited
if CurrentActionCamera.wait_signal then
waited = true
WaitMsg("ActionCameraWaitSignalEnd", 2000)
end
if remove_action_cam_actions[action_id] and g_Combat and g_Combat:IsVisibleByPoVTeam(unit) and not args.reposition then
if not waited then
Sleep(500)
end
RemoveActionCamera()
end
end
if args and type(args) == "table" then
if args.target then
--HandleCameraTargetFixed(unit, args.target)
ShowBadgeOfAttacker(unit, true)
end
if args.voiceResponse then
PlayVoiceResponseGroup(unit, args.voiceResponse)
elseif unit.ai_context and unit.ai_context.movement_action then
local vr = unit.ai_context.movement_action:GetVoiceResponse()
if vr then
PlayVoiceResponseGroup(unit, vr)
end
end
end
local willBeTracked, visibleMovement
if action_id == "Move" then
willBeTracked, visibleMovement = AddToCameraTrackingBehavior(unit, args)
args.willBeTracked = willBeTracked
args.visibleMovement = visibleMovement
end
StartCombatAction(action_id, unit, ap, args, ...)
return true
end
function AIPlayCombatAction(action_id, unit, ap, args)
--[[if args and IsKindOf(args.target, "Unit") then
printf("%s (%d): %s vs %s", _InternalTranslate(unit.Name or ""), unit.handle, action_id, _InternalTranslate(args.target.Name or ""))
end--]]
if not AIStartCombatAction(action_id, unit, ap, args) then
return false
end
WaitCombatActionsPostAction(unit)
ClearAITurnContours()
StopCinematicCombatCamera()
return true
end
function AIStartChangeStance(unit, stance, target_pos)
if unit.stance == stance then
return true
end
local angle
if target_pos and target_pos:IsValid() then
angle = CalcOrientation(unit, target_pos)
end
local args = { angle = angle }
local result
if stance == "Standing" then
result = AIStartCombatAction("StanceStanding", unit, nil, args)
elseif stance == "Crouch" then
result = AIStartCombatAction("StanceCrouch", unit, nil, args)
elseif stance == "Prone" then
result = AIStartCombatAction("StanceProne", unit, nil, args)
end
return result or false
end
function AIPlayChangeStance(unit, stance, target_pos)
if not AIStartChangeStance(unit, stance, target_pos) then
return false
end
WaitCombatActionsPostAction(unit)
return true
end
MapVar("g_AIDestIndoorsCache", {})
MapVar("g_AISignatureActionModifiers", {})
function AIUpdateContext(context, unit)
unit = unit or context.unit
context.unit_pos = GetPassSlab(unit) or context.unit_pos
context.unit_stance_pos = GetPackedPosAndStance(unit) or context.unit_stance_pos
context.unit_grid_voxel = point_pack(unit:GetGridCoords())
end
function AIGetIntendedTarget(unit, context)
context = context or unit.ai_context or empty_table
local dest = context.ai_destination or GetPackedPosAndStance(unit)
return (context.dest_target or empty_table)[dest]
end
function AILockTarget(unit, context)
context = context or unit.ai_context
local target = AIGetIntendedTarget(unit, context)
if target then
context.target_locked = target
end
end
function AIGetAttackTargetingOptions(unit, context, target, action, targeting)
local body_parts
targeting = targeting or context.archetype.BaseAttackTargeting
if IsKindOf(target, "Unit") and targeting then
action = action or context.default_attack
local args = { target = target, aim = 0 }
local parts = target:GetBodyParts(context.weapon)
local valid, fallback
for _, part in ipairs(parts) do
args.target_spot_group = part.id
local results = action:GetActionResults(unit, args)
body_parts = body_parts or {}
results.chance_to_hit = results.chance_to_hit or 0
table.insert(body_parts, {id = part.id, chance = results.chance_to_hit})
if results.chance_to_hit > 0 then
fallback = fallback or {id = part.id, chance = results.chance_to_hit}
if targeting[part.id] then
valid = true
end
end
end
if not valid then
table.insert(body_parts, fallback)
end
end
return body_parts
end
function AIPlayAttacks(unit, context, dbg_action, force_or_skip_action)
-- filter enemies because they might have been killed by a teammate
if g_AIExecutionController then
g_AIExecutionController:Log("Unit %s (%d) start attack sequence", unit.unitdatadef_id, unit.handle)
end
local enemies = context.enemies
for i = #enemies, 1, -1 do
if not IsValidTarget(enemies[i]) then
table.remove(enemies, i)
end
end
local remaining_free_ap = unit.free_move_ap
unit:RemoveStatusEffect("FreeMove") -- lose any remaining free movement points, we're going to use actions now
AIUpdateContext(context, unit)
if g_AIExecutionController then
g_AIExecutionController:Log(" Num enemies: %d", #enemies)
g_AIExecutionController:Log(" Action Points: %d", unit.ActionPoints)
end
local dest = not force_or_skip_action and context.ai_destination or GetPackedPosAndStance(unit)
-- recalc target to make sure we're firing at a valid target, but prefer the already picked target if there's one
--table.insert(g_AIDamageScoreLog, string.format("[%s] AIPlayAttacks (%s)", _InternalTranslate(unit.Name or ""), context.archetype.id))
context.dest_ap[dest] = context.dest_ap[dest] or unit.ActionPoints
AIPrecalcDamageScore(context, {dest}, context.target_locked or (context.dest_target or empty_table)[dest])
-- archetype signature actions
local signature_action
if dbg_action then
context.action_states = context.action_states or {}
context.action_states[dbg_action] = {}
dbg_action:PrecalcAction(context, context.action_states[dbg_action])
if dbg_action:IsAvailable(context, context.action_states[dbg_action]) then
signature_action = dbg_action
elseif force_or_skip_action then
table.insert(failed_actions, dbg_action.BiasId or dbg_action.class)
return
end
end
if not context.reposition and not unit:HasStatusEffect("Numbness") then
signature_action = signature_action or AIChooseSignatureAction(context)
end
local default_attack = context.default_attack
local default_attack_vr = "AIAttack"
if default_attack and default_attack.FiringModeMember and default_attack.FiringModeMember == "AttackShotgun" then
default_attack_vr = "AIDoubleBarrel"
end
local voice_response = signature_action and (signature_action:GetVoiceResponse() or "") or default_attack_vr
if voice_response == "" then
voice_response = nil
end
if signature_action then
if g_AIExecutionController then
g_AIExecutionController:Log(" Signature Action: %s", signature_action:GetEditorView())
end
signature_action:OnActivate(unit)
--printf("[signature] %s (%d)", _InternalTranslate(unit.Name or ""), unit.handle)
if voice_response then
context.action_states[signature_action].args = context.action_states[signature_action].args or {}
context.action_states[signature_action].args.voiceResponse = voice_response
end
local status = signature_action:Execute(context, context.action_states[signature_action])
context.ap_after_signature = unit.ActionPoints
if status then -- support signature actions that want to restart or stop ai turn execution
return status
end
AIReloadWeapons(unit)
context.max_attacks = context.max_attacks - 1
else
if g_AIExecutionController then
g_AIExecutionController:Log(" No Signature Action chosen")
end
end
local target = (context.dest_target or empty_table)[dest]
if signature_action and (not IsValidTarget(target) or (IsKindOf(target, "Unit") and target:IsIncapacitated())) then
--table.insert(g_AIDamageScoreLog, string.format("[%s] TargetChange (%s)", _InternalTranslate(unit.Name or ""), context.archetype.TargetChangePolicy))
if context.archetype.TargetChangePolicy == "restart" then
return "restart"
end
context.dest_ap[dest] = unit.ActionPoints
context.target_locked = nil
AIPrecalcDamageScore(context, {dest})
target = context.dest_target[dest]
end
if IsValidTarget(target) then
if g_AIExecutionController then
g_AIExecutionController:Log(" Target: %s", IsKindOf(target, "Unit") and target.unitdatadef_id or target.class)
end
-- revert to basic attacks
local attacks, aim = AICalcAttacksAndAim(context, unit.ActionPoints)
if context.default_attack.id == "Bombard" and AICheckIndoors(dest) then
attacks = 0
end
local args = { target = target, voiceResponse = voice_response }
if attacks > 1 then
unit:SequentialActionsStart()
end
if g_AIExecutionController then
g_AIExecutionController:Log(" Executing %d attacks...", attacks)
end
local body_parts = AIGetAttackTargetingOptions(unit, context, target)
for i = 1, attacks do
args.aim = aim[i]
args.target_spot_group = nil
if body_parts and #body_parts > 0 then
local pick = table.weighted_rand(body_parts, "chance", InteractionRand(1000000, "Combat"))
if pick then
args.target_spot_group = pick.id
end
end
Sleep(0)
local result = AIPlayCombatAction(context.default_attack.id, unit, nil, args)
context.max_attack = context.max_attacks - 1
if g_AIExecutionController then
g_AIExecutionController:Log(" Attack %d result: %s", i, tostring(result))
end
if IsSetpiecePlaying() then
unit:SequentialActionsEnd()
return
end
AIReloadWeapons(unit)
if not result or i == attacks or not IsValidTarget(unit) or context.max_attacks <= 0 then
break
end
while IsKindOf(target, "Unit") and target:IsGettingDowned() do
WaitMsg("UnitDowned", 20)
end
if not IsValidTarget(target) or (IsKindOf(target, "Unit") and target:IsIncapacitated()) then
--table.insert(g_AIDamageScoreLog, string.format("[%s] TargetChange (%s)", _InternalTranslate(unit.Name or ""), context.archetype.TargetChangePolicy))
if context.archetype.TargetChangePolicy == "restart" then
unit:SequentialActionsEnd()
return "restart"
end
-- look for another target
context.dest_ap[dest] = unit.ActionPoints
context.target_locked = nil
AIPrecalcDamageScore(context, {dest})
target = context.dest_target[dest]
if not IsValidTarget(target) then
break
end
end
Sleep(0)
end
unit:SequentialActionsEnd()
elseif unit:HasStatusEffect("StationedMachineGun") and CombatActions.MGPack:GetUIState({unit}) == "enabled" then
unit:SequentialActionsEnd()
AIPlayCombatAction("MGPack", unit)
return "restart"
else
if g_AIExecutionController then
g_AIExecutionController:Log(" No target")
end
end
unit:SequentialActionsEnd()
while not unit:IsIdleCommand() do
WaitMsg("Idle", 50)
end
if unit.ActionPoints + remaining_free_ap == context.start_ap and not unit:HasStatusEffect("ManningEmplacement") then
-- no action was taken, use a fallback one
-- if all fails, move toward optimal loc
if context.closest_dest then
unit:GainAP(remaining_free_ap)
local dest = context.closest_dest
local x, y, z, stance_idx = stance_pos_unpack(dest)
local move_stance_idx = context.dest_combat_path[dest]
local cpath = context.combat_paths[move_stance_idx]
local pt = SnapToPassSlab(x, y, z)
local path = pt and cpath and cpath:GetCombatPathFromPos(pt)
if path then
local goto_stance = StancesList[move_stance_idx]
if goto_stance ~= unit.stance then
AIPlayChangeStance(unit, goto_stance, point(point_unpack(path[2])))
end
local goto_ap = unit.ActionPoints -- context.dest_ap[dest] --cpath.paths_ap[point_pack(x, y, z)] or 0
context.ai_destination = path[1]
AIPlayCombatAction("Move", unit, goto_ap, { goto_pos = point(point_unpack(path[1])), fallbackMove = true, goto_stance = stance_idx })
end
end
if unit:GetDist(context.unit_pos) < const.SlabSizeX / 2 then
local revert = true
if context.archetype.FallbackAction == "overwatch" then
-- try to place overwatch
revert = not AIPlaceFallbackOverwatch(unit, context)
end
if revert then
-- we're stuck somewhere and unable to move or act, revert back to being Unaware (only if no sight of any enemies)
local sight = false
for _, enemy in ipairs(context.enemies) do
sight = sight or HasVisibilityTo(unit, enemy)
end
if not sight then
table.insert(g_UnawareQueue, unit)
end
end
end
end
end
function AIPlaceFallbackOverwatch(unit, context)
if not IsKindOf(context.weapon, "Firearm") then
return false
end
if context.weapon.PreparedAttackType ~= "Overwatch" and context.weapon.PreparedAttackType ~= "Both" then
return false
end
local target_pt
local room = EnumVolumes(unit, "smallest")
if room then
-- indoors - overwatch against an open door/window or a closed one if none of them are opened
local targets = {}
room:ForEachSpawnedDoor(function(obj)
local w = (obj.pass_through_state == "open" or obj.pass_through_state == "broken") and const.AIFallbackWeight_OpenDoor or const.AIFallbackWeight_ClosedDoor
targets[#targets + 1] = { obj = obj, weight = w }
end)
room:ForEachSpawnedWindow(function(obj)
targets[#targets + 1] = { obj = obj, weight = const.AIFallbackWeight_Window }
end)
if #targets > 0 then
local target = table.weighted_rand(targets, "weight", InteractionRand(1000000, "AIDecision"))
target_pt = target.obj:GetPos()
end
elseif context.unit.last_known_enemy_pos then
target_pt = context.unit.last_known_enemy_pos
else
-- check for aware teammates that we can see
local sp = GetPackedPosAndStance(unit)
local targets = {}
for _, ally in ipairs(context.allies) do
if ally ~= context.unit and context.unit:GetDist(ally) < 12 * guim and stance_pos_visibility(sp, context.ally_pack_pos_stance[ally]) then
-- try to find a point that we can (probably) see in front of our ally
local v = Rotate(point(guim, 0, 0), ally:GetAngle())
for i = 6, 1, -1 do
local tpt = SnapToPassSlab(ally:GetPos() + SetLen(v, i*guim))
if tpt then
local x, y, z = tpt:xyz()
local tsp = stance_pos_pack(x, y, z, StancesList.Standing)
if stance_pos_visibility(sp, tsp) then
targets[#targets + 1] = tpt
break
end
end
end
end
end
if #targets == 0 then
-- target in direction of alive enemy
local revealed, all = {}, {}
for _, enemy in ipairs(context.enemies) do
if IsValidTarget(enemy) then
all[#all + 1] = enemy
if not enemy:HasStatusEffect("Hidden") then
revealed[#revealed + 1] = enemy
end
end
end
local target_units = #revealed > 0 and revealed or all
for _, enemy in ipairs(target_units) do
targets[#targets + 1] = enemy:GetPos() + Rotate(point(InteractionRand(4*guim), 0, 0, InteractionRand(360*60)))
end
end
if #targets > 0 then
target_pt = table.interaction_rand(targets, "AIDecision")
end
end
if target_pt then
local args, has_ap = AIGetAttackArgs(context, CombatActions.Overwatch, nil, "None")
if args and has_ap then
args.target_pos = target_pt
args.target = target_pt
if AIPlayCombatAction("Overwatch", context.unit, nil, args) then
PlayVoiceResponse(context.unit, "AIOverwatch")
return true
end
end
end
return false
end
function AIExecuteUnitBehavior(unit, force_or_skip_action)
if not g_Combat or not IsValid(unit) or unit:IsDead() then
return
end
if unit.ai_context.behavior then
local status = unit.ai_context.behavior:Play(unit)
if g_AIExecutionController then
g_AIExecutionController:Log(" Behavior %s for unit %s (%d) returned '%s'", unit.ai_context.behavior:GetEditorView(), unit.unitdatadef_id, unit.handle, tostring(status))
end
if status then -- support behaviors that want to restart or stop the unit's ai
return status
end
end
-- recheck unit, they could be killed or despawned during Play
if IsValid(unit) and not unit:IsDead() then
-- use the rest of the ap (if any) in signature actions and basic attacks
return AIPlayAttacks(unit, unit.ai_context, unit.ai_context.forced_signature_action, force_or_skip_action) or AITakeCover(unit)
end
end
function AITakeCover(unit, context)
local context = unit.ai_context
if unit:HasPreparedAttack() or not context or ((context.ap_after_signature or 0) <= 0) then
return
end
local cover_high, cover_low = GetCoverTypes(unit)
if not cover_high and not cover_low then
return
end
if unit.species == "Human" and unit.stance ~= "Prone" then
local context = unit.ai_context
local chance = context and context.behavior and context.behavior.TakeCoverChance or 0
if chance > 0 and (chance >= 100 or unit:Random(100) < chance) then
local dest = GetPackedPosAndStance(unit)
local enemy_visible = context.enemy_visible
local enemy_pos = context.enemy_pack_pos_stance
for _, enemy in ipairs(context.enemies) do
if (enemy_visible[enemy] and GetCoverFrom(dest, enemy_pos[enemy]) or 0) > 0 then
AIPlayCombatAction("TakeCover", unit, 0)
return
end
end
end
end
if cover_low then
AIPlayCombatAction("StanceCrouch", unit, 0)
end
end
function AIApplyActionModifiers(signature_action, unit)
for _, mod in ipairs(signature_action.WeightModifications) do
local id = mod.ActionId
if id then
local act_mods = g_AISignatureActionModifiers[id] or {}
g_AISignatureActionModifiers[id] = act_mods
local list
if mod.ApplyTo == "Self" then
list = act_mods[unit] or {}
act_mods[unit] = list
else
list = act_mods[unit.team] or {}
act_mods[unit.team] = {}
end
list[#list + 1] = { end_turn = g_Combat.current_turn + mod.Period, value = mod.Value }
end
end
end
function AIGetActionWeight(action, unit, action_state)
local w = action.Weight
local id = action.ActionId
if id and id ~= "" then
local mods = g_AISignatureActionModifiers[id] or empty_table
if mods[unit] then w = w + mods[unit].total end
if mods[unit.team] then w = w + mods[unit.team].total end
end
local score = action_state and action_state.score or 100
return MulDivRound(w, score, 100)
end
function AIGetSignatureActions(context, movement)
local actions = {}
-- if the behavior has any defined actions, pick from that list, otherwise revert to archetype's
local actions_pool = context.behavior:GetSignatureActions(context)
if not actions_pool or #actions_pool == 0 then
actions_pool = context.archetype.SignatureActions
end
local unit = context.unit
movement = movement or false
for _, action in ipairs(actions_pool) do
if (action.movement == movement) and action:MatchUnit(unit) then
actions[#actions + 1] = action
end
end
return actions
end
function AISelectAction(context, actions, base_weight, dbg_available_actions)
local available = {}
local weight = base_weight or 0
context.action_states = context.action_states or {}
for _, action in ipairs(actions) do
context.action_states[action] = {}
local weight_mod, disable, priority = AIGetBias(action.BiasId, context.unit)
disable = disable or context.disable_actions[action.BiasId or false]
if not disable then
action:PrecalcAction(context, context.action_states[action])
if action:IsAvailable(context, context.action_states[action]) then
local action_weight = MulDivRound(action.Weight, weight_mod, 100)
priority = priority or action.Priority
if dbg_available_actions then
table.insert(dbg_available_actions, { action = action, weight = action_weight, priority = priority })
end
if priority then
return action
end
available[#available + 1] = action
available[available] = action_weight
weight = weight + action_weight
elseif dbg_available_actions then
table.insert(dbg_available_actions, { action = action, weight = false })
end
end
end
if weight > 0 then
local roll = InteractionRand(weight, "AISignatureAction", context.unit)
for _, action in ipairs(available) do
local w = available[action]
if roll <= weight then
return action
end
roll = roll - weight
end
end
return available[#available]
end
function AIChooseSignatureAction(context)
local weight = context.archetype.BaseAttackWeight
context.choose_actions = { { action = false, weight = weight, priority = false } },
AIUpdateBiases()
local sig_actions = AIGetSignatureActions(context)
return AISelectAction(context, sig_actions, weight, context.choose_actions)
end
function AIChooseMovementAction(context)
local actions = AIGetSignatureActions(context, true)
AIUpdateBiases()
return AISelectAction(context, actions, context.archetype.BaseMovementWeight)
end
function AIFindDestinations(unit, context)
local pos = GetPassSlab(unit) or unit:GetPos()
local destinations, paths, dest_ap, dest_path, voxel_to_dest, closest_free_pos = AIBuildArchetypePaths(unit, pos, context)
if not closest_free_pos then
if unit.ActionPoints == 0 then
assert(not "AI try to act with 0 action points!!!")
else
print("AI can't find unit free destination prints!!!")
printf(" AP = %d", unit.ActionPoints)
printf(" Command = %s", unit.command)
printf(" Status effects: %s", table.concat(table.keys(unit.StatusEffects), ", "))
printf(" Pos: %s", tostring(unit:GetPos()))
printf(" Pass slab pos: %s", tostring(GetPassSlab(unit) or ""))
printf(" Target dummy pos %s", unit.target_dummy and tostring(unit.target_dummy:GetPos()) or "")
local o = GetOccupiedBy(unit:GetPos(), unit)
if o then
printf("Other pos %s", tostring(o:GetPos()))
printf("Other target dummy pos %s", o.target_dummy and tostring(o.target_dummy:GetPos()) or "")
printf("Other efResting=%d", o:GetEnumFlags(const.efResting))
if o.reposition_dest then
printf("Other reposition dest=%s", tostring(point(stance_pos_unpack(o.reposition_dest))))
end
end
assert(not "AI can't find unit free destination")
end
end
local crouch_idx = StancesList.Crouch
local important_dests = context.important_dests or {}
context.important_dests = important_dests
local change_stance_costs = {}
for stance_idx in ipairs(StancesList) do
change_stance_costs[stance_idx] = GetStanceToStanceAP(StancesList[stance_idx], "Crouch")
end
-- preprocess destinations to find those where we need to change stance at the dest to take cover
local low = const.CoverLow
--local high = const.CoverHigh
for i, dest in ipairs(destinations) do
local x, y, z, stance_idx = stance_pos_unpack(dest)
if stance_idx ~= crouch_idx then
local cost = change_stance_costs[stance_idx]
local ap = dest_ap[dest]
if cost and ap and ap >= cost then
local up, right, down, left = GetCover(x, y, z)
if up then
local cover_low = up == low or right == low or down == low or left == low
--local cover_high = up == high or right == high or down == high or left == high
if cover_low then --and not cover_high then
table.remove_value(important_dests, dest)
local new_dest = stance_pos_pack(x, y, z, crouch_idx)
destinations[i] = new_dest
voxel_to_dest[point_pack(x, y, z)] = new_dest
dest_ap[new_dest] = ap - cost
dest_path[new_dest] = dest_path[dest]
table.insert_unique(important_dests, new_dest)
end
end
end
end
end
context.destinations = destinations -- available destinations
context.dest_ap = dest_ap -- dest -> available ap
context.combat_paths = paths
context.dest_combat_path = dest_path -- dest -> index in context.combat_paths (to reach this dest)
context.voxel_to_dest = voxel_to_dest
context.closest_free_pos = closest_free_pos
context.all_destinations = AIEnumValidDests(context)
end
MapVar("g_BiasMarkers", false)
function AICreateContext(unit, context)
local gx, gy, gz = unit:GetGridCoords()
local weapon = unit:GetActiveWeapons()
local default_attack = unit:GetDefaultAttackAction(nil, "ungrouped", nil, "sync")
local enemies = table.icopy(GetEnemies(unit))
for _, groupname in ipairs(unit.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
if not g_BiasMarkers then
InitAIBiasMarkers()
end
-- fallback when our whole team doesn't have a visual on the enemy but we're still aware
if #(enemies or empty_table) == 0 then
enemies = table.ifilter(GetAllEnemyUnits(unit), function(idx, enemy) return not enemy:HasStatusEffect("Hidden") end)
end
-- special-case when having ManningEmplacement status - filter out non targetable enemies
if unit:HasStatusEffect("ManningEmplacement") then
enemies = table.ifilter(enemies, function(idx, enemy) return enemy:IsThreatened({unit}) end)
end
table.sortby_field(enemies, "handle")
local pos = GetPassSlab(unit)
if not pos then -- can happen if the unit is on impassable for some reason
--assert(false, "GetPassSlab failed for unit " .. unit.session_id)
local x, y, z = unit:GetPosXYZ()
local gx, gy, gz = WorldToVoxel(x, y, z)
if not z then
gz = nil
end
pos = point(VoxelToWorld(gx, gy, (gz)))
end
local wx, wy, wz = pos:xyz()
context = context or {}
context.unit = unit
context.unit_pos = pos
context.start_ap = unit.ActionPoints
context.archetype = unit:GetArchetype()
context.unit_grid_voxel = point_pack(gx, gy, gz)
context.unit_world_voxel = point_pack(pos)
context.unit_stance_pos = stance_pos_pack(wx, wy, wz, StancesList[unit.stance])
context.max_attacks = unit.MaxAttacks
context.dest_target = {} -- dest -> picked target (if any)
context.dest_target_score = {} -- dest -> estimated damage
context.weapon = weapon
context.default_attack = default_attack
context.default_attack_cost = default_attack:GetAPCost(unit)
context.EffectiveRange = IsKindOf(weapon, "Firearm") and weapon.WeaponRange / 2 or 1
context.ExtremeRange = IsKindOf(weapon, "Firearm") and weapon.WeaponRange or 1
context.enemies = enemies
context.enemy_visible = {} -- [enemy] -> true/false
context.enemy_visible_by_team = {} -- [enemy] -> true/false
context.enemy_pos = {}
context.enemy_grid_voxel = {}
context.enemy_pack_pos_stance = {}
context.enemy_dir = {}
context.stance_pos_to_vis_enemies = {}
context.allies = unit.team.units
context.ally_grid_voxel = {}
context.ally_pack_pos_stance = {}
context.ally_pos = {}
context.voxel_heal_target = {}
context.voxel_heal_score = {}
context.forced_signature_action = false
context.apply_bias = true
context.disable_actions = {} -- support for custom filtering for signature action selection by BiasId
NetUpdateHash("AICreateContext", unit, pos, unit.stance, context.start_ap, context.archetype.id, context.max_attacks, weapon and weapon.class, weapon and weapon.id, default_attack.id)
if unit:HasStatusEffect("Stimmed") then
context.max_attacks = context.max_attacks + 1
end
for _, action in ipairs(context.archetype.SignatureActions) do
context.can_heal = context.can_heal or IsKindOf(action, "AIActionBandage")
end
if not context.can_heal then
for _, behavior in ipairs(context.archetype.Behaviors) do
for _, action in ipairs(behavior.SignatureActions) do
context.can_heal = context.can_heal or IsKindOf(action, "AIActionBandage")
end
end
end
for i, enemy in ipairs(enemies) do
local x, y, z = enemy:GetGridCoords()
context.enemy_grid_voxel[enemy] = point_pack(x, y, z)
context.enemy_pack_pos_stance[enemy] = GetPackedPosAndStance(enemy)
local enemy_pos = GetPassSlab(enemy) or SnapToVoxel(enemy:GetPos())
context.enemy_pos[enemy] = enemy_pos
if not pos:Equal2D(enemy_pos) then
local dir = enemy_pos - pos
dir = dir:SetInvalidZ()
context.enemy_dir[enemy] = SetLen(dir, guim)
else
context.enemy_dir[enemy] = point(0, 0, guim)
end
context.enemy_visible[enemy] = HasVisibilityTo(unit, enemy)
context.enemy_visible_by_team[enemy] = HasVisibilityTo(unit.team, enemy)
end
if context.behavior then
context.behavior:EnumDestinations(unit, context)
else
AIFindDestinations(unit, context)
end
AIUpdateDestLosCache(unit, context)
for i, ally in ipairs(context.allies) do
local x, y, z = ally:GetGridCoords()
context.ally_grid_voxel[ally] = point_pack(x, y, z)
context.ally_pack_pos_stance[ally] = GetPackedPosAndStance(ally)
context.ally_pos[ally] = ally:GetPos()
end
unit.ai_context = context
return context
end
MapVar("g_AIDestEnemyLOSCache", {})
function dbgShowAIDestCache()
DbgClearVectors()
DbgClearTexts()
for dest, los in pairs(g_AIDestEnemyLOSCache) do
local x, y, z, stance_idx = stance_pos_unpack(dest)
z = z or terrain.GetHeight(x, y)
DbgAddVector(point(x, y, z), point(0, 0, guim), los and const.clrGreen or const.clrRed)
DbgAddText(StancesList[stance_idx], point(x, y, z), const.clrWhite)
end
end
function AIUpdateDestLosCache(unit, context)
assert(CurrentThread()) -- the function will sleep internally due to the amount of calculations performed
--local tStart = GetPreciseTicks()
--ic("AIUpdateDestLosCache start", #units)
local sight = unit:GetSightRadius()
local all_destinations = context.all_destinations
local enemies = context.enemies
if #enemies == 0 then return end
NetUpdateHash("AIUpdateDestLosCache_Start", GameTime(), sight, #all_destinations, hashParamTable(all_destinations), #enemies, hashParamTable(context.enemy_pack_pos_stance))
local dests
local los_cache = g_AIDestEnemyLOSCache
for _, dest in ipairs(all_destinations) do
if los_cache[dest] == nil then
if not dests then dests = {} end
dests[#dests + 1] = dest
los_cache[dest] = false
end
end
if dests then
local max_los_checks = 100
local targets = {}
local srcs = {}
local enemies_count = #enemies
local next_dest_idx = 1
local start_dest_idx = 1
local cur_enemy = 1
while true do
local ppos = context.enemy_pack_pos_stance[enemies[cur_enemy]]
local count = #targets
local last_dest_idx = Min(#dests, next_dest_idx + max_los_checks - count - 1)
for i = next_dest_idx, last_dest_idx do
count = count + 1
targets[count] = ppos
srcs[count] = dests[i]
end
next_dest_idx = last_dest_idx + 1
if next_dest_idx > #dests then
next_dest_idx = 1
cur_enemy = cur_enemy + 1
end
if count >= max_los_checks or cur_enemy > enemies_count then
local los_any, los_data = CheckLOS(targets, srcs, sight)
if los_any then
local visible_dests = 0
for i, value in ipairs(los_data) do
if value then
local dest = srcs[i]
if not los_cache[dest] then
los_cache[dest] = true
visible_dests = visible_dests + 1
end
end
end
if visible_dests >= #dests then
break
end
if cur_enemy < enemies_count or cur_enemy == enemies_count and next_dest_idx == 1 then
-- There will be more LOS checks. Remove visible destinations from dests list to not cast more lines from there
if #targets >= #dests then
for i = #dests, 1, -1 do
if los_cache[dests[i]] then
table.remove(dests, i)
if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end
end
end
elseif start_dest_idx <= last_dest_idx then
for i = last_dest_idx, start_dest_idx, -1 do
if los_cache[dests[i]] then
table.remove(dests, i)
if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end
end
end
else
for i = #dests, start_dest_idx, -1 do
if los_cache[dests[i]] then
table.remove(dests, i)
if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end
end
end
for i = last_dest_idx, 1, -1 do
if los_cache[dests[i]] then
table.remove(dests, i)
if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end
end
end
end
if #dests == 0 then
assert(#dests > 0)
break
end
end
end
if cur_enemy > enemies_count then
break
end
start_dest_idx = next_dest_idx
table.iclear(targets)
table.iclear(srcs)
if GetInGameInterfaceMode() ~= "IModeAIDebug" then
Sleep(10) --yield
end
end
end
end
NetUpdateHash("AIUpdateDestLosCache_End", GameTime())
--printf("AIUpdateDestLosCache: %d ms for %s", GetPreciseTicks() - tStart, unit.unitdatadef_id)
end
function AIHasLOSToEnemyFromDest(dest)
return not not g_AIDestEnemyLOSCache[dest]
end
function AICalcAttacksAndAim(context, ap)
local aim_cost = const.Scale.AP
if GameState.RainHeavy then
aim_cost = MulDivRound(aim_cost, 100 + const.EnvEffects.RainAimingMultiplier, 100)
end
local cost = context.default_attack_cost
local num_attacks = Min(ap / cost, context.max_attacks)
if context.force_max_aim then
num_attacks = Min(ap / (cost + aim_cost * context.weapon.MaxAimActions), context.max_attacks)
end
local remaining = ap - num_attacks * cost
local aims = {}
local attack_idx = 1
while remaining > aim_cost do
local aim = (aims[attack_idx] or 0) + 1
if aim > context.weapon.MaxAimActions then
break
end
aims[attack_idx] = aim
attack_idx = attack_idx + 1
if attack_idx > num_attacks then
attack_idx = 1
end
remaining = remaining - aim_cost
end
return num_attacks, aims
end
function AIBuildArchetypePaths(unit, pos, context)
local stationary = context.stationary
local paths = {}
local destinations, dest_path, dest_ap, voxel_to_dest = {}, {}, {}, {}
if stationary or CombatActions.Move:GetUIState{unit} ~= "enabled" then
local dest = GetPackedPosAndStance(unit)
local x, y, z = stance_pos_unpack(dest)
local voxel = point_pack(x, y, z)
destinations[1] = dest
dest_ap[dest] = unit.ActionPoints
voxel_to_dest[voxel] = dest
return destinations, paths, dest_ap, dest_path, voxel_to_dest, voxel
end
local archetype = unit:GetArchetype()
local goto_stance = archetype.MoveStance
local pref_stance = archetype.PrefStance
local move_stance_idx = StancesList[goto_stance] or 0
local pref_stance_idx = StancesList[pref_stance] or 0
local ps_ap = (unit.species == "Human") and (unit.ActionPoints - GetStanceToStanceAP(unit.stance, pref_stance)) or unit.ActionPoints
local ms_ap = (unit.species == "Human") and (unit.ActionPoints - GetStanceToStanceAP(unit.stance, goto_stance)) or unit.ActionPoints
local move_path = CombatPath:new()
move_path:RebuildPaths(unit, ms_ap, pos, goto_stance)
local dest_voxels = table.keys(move_path.destinations, true)
local pref_path
if goto_stance == pref_stance then
pref_path = move_path
else
local visited = move_path.destinations
pref_path = CombatPath:new()
pref_path:RebuildPaths(unit, ps_ap, pos, pref_stance)
for voxel in sorted_pairs(pref_path.destinations) do
if not visited[voxel] then
dest_voxels[#dest_voxels+1] = voxel
end
end
end
local important_dests = context.important_dests or {}
local min_melee_dist = 2 * const.SlabSizeX
local move_paths_ap = move_path.paths_ap
local pref_paths_ap = pref_path.paths_ap
for _, voxel in ipairs(dest_voxels) do
local x, y, z = point_unpack(voxel)
local move_ap = move_paths_ap[voxel]
local pref_ap = pref_paths_ap[voxel]
local mn_ap = move_ap and (ms_ap - move_ap) or -1
local pn_ap = pref_ap and (ps_ap - pref_ap) or -1
local dest
if pn_ap > mn_ap then
assert(pref_ap)
dest = stance_pos_pack(x, y, z, pref_stance_idx)
destinations[#destinations+1] = dest
dest_path[dest] = pref_stance_idx
dest_ap[dest] = pn_ap
elseif move_ap then
dest = stance_pos_pack(x, y, z, move_stance_idx)
destinations[#destinations+1] = dest
dest_path[dest] = move_stance_idx
dest_ap[dest] = mn_ap
else
dest = stance_pos_pack(x, y, z, StancesList[unit.stance])
assert(dest == context.unit_stance_pos)
destinations[#destinations+1] = dest
dest_path[dest] = move_stance_idx
dest_ap[dest] = unit.ActionPoints
end
voxel_to_dest[voxel] = dest
if not table.find(important_dests, dest) then
if context.EffectiveRange <= 1 then
-- make sure all potential melee positions are included in the end and not cut off by CollapsePoints
for enemy, enemy_ppos in pairs(context.enemy_pack_pos_stance) do
if stance_pos_dist(enemy_ppos, dest) < min_melee_dist then
table.insert_unique(important_dests, dest)
break
end
end
end
-- also do the same for allies, since we might wanna heal them
if context.can_heal then
for _, ally in ipairs(context.allies) do
local ppos = GetPackedPosAndStance(ally)
if stance_pos_dist(ppos, dest) < min_melee_dist then
table.insert_unique(important_dests, dest)
break
end
end
end
end
end
destinations = CollapsePoints(destinations, 1)
context.important_dests = important_dests
for _, dest in ipairs(important_dests) do
if dest_ap[dest] and CanOccupy(unit, stance_pos_unpack(dest)) then
table.insert_unique(destinations, dest)
end
end
-- filter out destinations someone already called dibs for
for _, u in ipairs(context.allies) do
if u ~= unit and u.ai_context then
local idx = table.find(destinations, u.ai_context.ai_destination)
if idx then
destinations[idx] = destinations[#destinations]
destinations[#destinations] = nil
end
end
end
paths[goto_stance] = move_path
paths[move_stance_idx] = move_path
paths[pref_stance] = pref_path
paths[pref_stance_idx] = pref_path
return destinations, paths, dest_ap, dest_path, voxel_to_dest, move_path.closest_free_pos
end
function AIScoreDest(context, policies, dest, grid_voxel, base_score, visual_voxels, score_details)
local score = 0
local x, y, z, stance_idx = stance_pos_unpack(dest)
if not grid_voxel then
local vx, vy, vz = WorldToVoxel(x, y, z)
grid_voxel = point_pack(vx, vy, vz)
end
local voxels, head = context.unit:GetVisualVoxels(point_pack(x, y, z), StancesList[stance_idx], visual_voxels)
if AreVoxelsInFireRange(voxels) then
score = const.AIAvoidFireWeigth
if score_details then
score_details[#score_details + 1] = "ADJACENT FIRE"
score_details[#score_details + 1] = const.AIAvoidFireWeigth
end
elseif g_SmokeObjs[head] then
score = const.AIAvoidFireWeigth
if score_details then
score_details[#score_details + 1] = "GASSED AREA"
score_details[#score_details + 1] = const.AIAvoidGasWeigth
end
end
for _, policy in ipairs(policies) do
local peval = policy:EvalDest(context, dest, grid_voxel)
local pscore = MulDivRound(peval or 0, policy.Weight, 100)
local failed = policy.Required and pscore == 0
score = score + pscore
if score_details then
score_details[#score_details + 1] = (failed and "[FAILED] " or "") .. policy:GetEditorView()
score_details[#score_details + 1] = pscore
end
if failed then
return 0
end
end
score = (base_score or 0) + score
-- bombard zone modifier
for _, zone in ipairs(g_Bombard) do
local dist = zone:GetDist(x, y, z)
local radius = zone.radius * const.SlabSizeX
if dist <= radius then
local mod = MulDivRound(dist, const.AIAvoidBombardEdge, radius) + MulDivRound(radius - dist, const.AIAvoidBombardCenter, radius)
local loss = MulDivRound(score, 100 - mod, 100)
if score_details and loss > 0 then
score_details[#score_details + 1] = "BOMBARD ZONE"
score_details[#score_details + 1] = -loss
end
score = Max(0, score - loss)
end
end
-- apply modifiers from bias markers at the end
if context.apply_bias then
local unit = context.unit
for _, marker in ipairs(g_BiasMarkers) do
local bias = marker:GetAIBias(unit, dest)
if bias ~= 100 then
score = MulDivRound(score, bias, 100)
if score_details then
score_details[#score_details + 1] = string.format("Bias Marker %s (%%): ", marker.ID)
score_details[#score_details + 1] = bias
end
end
end
end
return score
end
MapSlabsBBox_MaxZ = 100000
function AIEnumValidDests(context)
local unit = context.unit
local r = context.archetype.OptLocSearchRadius * const.SlabSizeX
local ux, uy, uz = point_unpack(context.unit_grid_voxel)
local px, py, pz = VoxelToWorld(ux, uy, uz)
local bbox = box(px - r, py - r, 0, px + r + 1, py + r + 1, MapSlabsBBox_MaxZ)
local dests, dest_added = {}, {}
local function push_dest(x, y, z, context, dests, dest_added, ux, uy, uz)
local gx, gy, gz = WorldToVoxel(x, y, z)
if not IsCloser(gx, gy, gz, ux, uy, uz, context.archetype.OptLocSearchRadius) then
return
end
if not CanOccupy(unit, x, y, z) then
return
end
local world_voxel = point_pack(x, y, z)
local dest = context.voxel_to_dest[world_voxel]
if not dest then
dest = stance_pos_pack(x, y, z, StancesList[context.archetype.PrefStance])
end
if not dest_added[dest] then
dests[#dests + 1] = dest
dest_added[dest] = true
end
end
ForEachPassSlab(bbox, push_dest, context, dests, dest_added, ux, uy, uz)
-- add current pos
if not dest_added[context.unit_stance_pos] then
local x, y, z = stance_pos_unpack(context.unit_stance_pos)
if CanOccupy(unit, x, y, z) then
dests[#dests + 1] = context.unit_stance_pos
dest_added[context.unit_stance_pos] = true
end
end
-- add from context.destinations
for _, dest in ipairs(context.destinations) do
if not dest_added[dest] then
dests[#dests + 1] = dest
end
end
dests = CollapsePoints(dests, 1)
for _, dest in ipairs(context.important_dests) do
table.insert_unique(dests, dest)
end
return dests
end
function AIFindOptimalLocation(context, dest_score_details)
if context.best_dest then
-- optimal location doesn't change across behaviors, no need to recalc it
return context.best_dest
end
local unit = context.unit
context.best_dests = {}
local r = context.archetype.OptLocSearchRadius * const.SlabSizeX
local ux, uy, uz = point_unpack(context.unit_grid_voxel)
local px, py, pz = VoxelToWorld(ux, uy, uz)
local bbox = box(px - r, py - r, 0, px + r + 1, py + r + 1, MapSlabsBBox_MaxZ)
context.best_score = 0
local unit_voxels = {}
local dest_scores = {}
local policies = table.ifilter(context.archetype.OptLocPolicies, function(idx, policy) return policy:MatchUnit(unit) end)
for _, dest in ipairs(context.all_destinations) do
local x, y, z = stance_pos_unpack(dest)
local gx, gy, gz = WorldToVoxel(x, y, z)
local world_voxel = point_pack(x, y, z)
local grid_voxel = point_pack(gx, gy, gz)
--eval_voxel(x, y, z, context, ux, uy, uz)
if not context.voxel_to_dest[world_voxel] then
context.voxel_to_dest[world_voxel] = dest
end
local scores
if dest_score_details then
scores = {}
dest_score_details[dest] = scores
end
table.iclear(unit_voxels)
local score = AIScoreDest(context, policies, dest, grid_voxel, 0, unit_voxels, scores)
if score > 0 then
context.best_score = Max(context.best_score, score)
local threshold = MulDivRound(context.best_score, const.AIDecisionThreshold, 100)
if score >= threshold then
dest_scores[dest] = score
context.best_dests[#context.best_dests + 1] = dest
for i = #context.best_dests, 1, -1 do
local dest = context.best_dests[i]
if dest_scores[dest] < threshold then
table.remove(context.best_dests, i)
end
end
end
end
if scores then
scores.final_score = score
end
end
-- check if a best dest candidate is on our starting voxel, default to it
for _, dest in ipairs(context.best_dests) do
if stance_pos_dist(context.unit_stance_pos, dest) == 0 then
context.best_dest = dest
end
end
if not context.best_dest and #(context.best_dests or empty_table) > 0 then
if #(context.best_dests or empty_table) > 15 then
context.collapsed = CollapsePoints(context.best_dests, 1)
else
context.collapsed = context.best_dests
end
local pf_dests = {}
for i, dest in ipairs(context.collapsed) do
local x, y, z = stance_pos_unpack(dest)
pf_dests[i] = point(x, y, z)
end
context.best_dest_path = pf.GetPosPath(unit, pf_dests)
if #(context.best_dest_path or empty_table) > 0 then
local voxel = point_pack(SnapToPassSlabXYZ(context.best_dest_path[1]))
local dest = context.voxel_to_dest[voxel]
if not dest then
-- try non-snapped
voxel = point_pack(context.best_dest_path[1])
dest = context.voxel_to_dest[voxel]
end
--assert(dest and (not dest_score_details or dest_score_details[dest]))
context.best_dest = dest
end
end
context.dest_scores = dest_scores
context.best_dest = context.best_dest or context.voxel_to_dest[context.unit_world_voxel] or context.unit_stance_pos
if context.dest_combat_path[context.best_dest] then
table.insert_unique(context.important_dests, context.best_dest)
table.insert_unique(context.destinations, context.best_dest)
end
return context.best_dest
end
function AICalcPathDistances(context)
local unit = context.unit
local path_voxels, voxel_dist, total_dist
if context.best_dest_path then
path_voxels, voxel_dist, total_dist = CalcPathVoxels(context.best_dest_path)
end
context.path_voxels = path_voxels
context.path_to_target = table.copy(path_voxels or empty_table)
context.voxel_dist = voxel_dist
context.total_dist = total_dist
-- calc distance to optimal location from each dest
if path_voxels and voxel_dist then
AICalcDistancesFromReachableLocations(context) -- will add path nodes to path_voxels and voxel_dist
else
-- no path to target, use default distances on all reachable voxels
context.dest_dist = {}
end
end
function AIGetWeaponCheckRange(unit, weapon, action)
if IsKindOf(weapon, "MeleeWeapon") then
local tiles = unit.body_type == "Large animal" and 2 or 1
local range = (2 * tiles + 1) * const.SlabSizeX / 2
return range, true
elseif IsKindOf(weapon, "Firearm") then
local max_range = weapon.WeaponRange * const.SlabSizeX
if action.AimType ~= "cone" then
max_range = 15 * max_range / 10
end
return max_range
end
end
--MapVar("g_AIDamageScoreLog", {})
function AIAllyInDanger(allies, ally_pos, pos, target, dist_near, dist_far)
local target_pos = target:GetPos()
local v = target:GetPos() - pos
local d = const.AIFriendlyFire_MaxRange
for _, ally in ipairs(allies) do
if ally:GetDist2D(pos) <= const.AIFriendlyFire_MaxRange then
local ally_pos = ally_pos and ally_pos[ally] or ally:GetPos()
local dist, x, y, z = DistSegmentToPt2D(pos, target_pos, ally_pos)
local nearest = point(x, y, z)
local d1 = pos:Dist2D(nearest)
local dist_threshold = MulDivRound(dist_near, Clamp(0, d, d - d1), d) + MulDivRound(dist_far, Clamp(0, d, d1), d)
if dist < dist_threshold then
local v1 = nearest - pos
if Dot2D(v, v1) > 0 then
return true
end
end
end
end
end
function AIPrecalcDamageScore(context, destinations, preferred_target, debug_data)
local unit = context.unit
local weapon = context.weapon
local action = CombatActions[context.override_attack_id or false] or context.default_attack
local archetype = context.archetype
local behavior = context.behavior
if not weapon or context.reposition or unit:HasStatusEffect("Burning") then
return
end
if not destinations and context.damage_score_precalced then
return
end
local action_targets = action:GetTargets({unit})
local targets = table.ifilter(action_targets, function(idx, target) return unit:IsOnEnemySide(target) end)
if #targets == 0 then
return
end
context.damage_score_precalced = true
local target_score_mod = {}
local tsr = archetype.TargetScoreRandomization
for i, target in ipairs(targets) do
target_score_mod[i] = 100 + ((tsr > 0) and unit:RandRange(-tsr, tsr) or 0)
end
context.target_score_mod = target_score_mod
local base_mod = unit[weapon.base_skill]
local cost_ap = context.override_attack_cost or context.default_attack_cost
local max_check_range, is_melee = AIGetWeaponCheckRange(unit, weapon, action)
local is_heavy = IsKindOf(weapon, "HeavyWeapon")
local hit_modifiers = Presets["ChanceToHitModifier"]["Default"]
-- stance mod
local modCrouchBonus = 0
local modProneBonus = 0
--if IsKindOf(weapon, "Firearm") then
--modCrouchBonus = hit_modifiers.AttackerStance:ResolveValue("CrouchBonus")
--modProneBonus = hit_modifiers.AttackerStance:ResolveValue("ProneBonus")
local value = GetComponentEffectValue(weapon, "AccuracyBonusProne", "bonus_cth")
if value then
modProneBonus = modProneBonus + value
end
--end
-- ground difference mod
local MinGroundDifference = hit_modifiers.GroundDifference:ResolveValue("RangeThreshold") * const.SlabSizeZ / 100
local modHighGround = hit_modifiers.GroundDifference:ResolveValue("HighGround")
local modLowGround = hit_modifiers.GroundDifference:ResolveValue("LowGround")
-- cover
local modCover = hit_modifiers.RangeAttackTargetStanceCover:ResolveValue("Cover")
local modSameTarget = hit_modifiers.SameTarget:ResolveValue("Bonus")
local target_policies = archetype.TargetingPolicies
if behavior and #(behavior.TargetingPolicies or empty_table) > 0 then
target_policies = behavior.TargetingPolicies
end
local dest_target = context.dest_target
local dest_target_score = context.dest_target_score
local dest_ap = context.dest_ap
local aim_mod = Presets.ChanceToHitModifier.Default.Aim
local dest_cth = {}
context.dest_cth = dest_cth
local lof_params
local attacker_pos = unit:GetPos()
-- script-driven modifiers (based on groups)
local target_modifiers
for _, groupname in ipairs(unit.Groups) do
local group_modifiers = gv_AITargetModifiers[groupname]
for target_group, mod in pairs(group_modifiers) do
target_modifiers = target_modifiers or {}
target_modifiers[target_group] = (target_modifiers[target_group] or 0) + mod
for _, obj in ipairs(Groups[target_group]) do
if IsKindOf(obj, "Unit") and not table.find(targets, obj) then
table.insert(targets, obj) -- make sure the target is considired regardless if it's an enemy or not
table.insert(target_score_mod, 100 + ((tsr > 0) and unit:RandRange(-tsr, tsr) or 0))
end
end
end
end
if unit:HasStatusEffect("StationedMachineGun") or unit:HasStatusEffect("ManningEmplacement") then
local ow_units = {unit}
targets = table.ifilter(targets, function(idx, target) return target:IsThreatened(ow_units, "overwatch") end)
end
if not IsValidTarget(preferred_target) or (IsKindOf(preferred_target, "Unit") and preferred_target:IsIncapacitated() or not table.find(targets, preferred_target)) then
preferred_target = nil
end
if weapon and not is_melee then
lof_params = {
obj = unit,
action_id = action.id,
weapon = weapon,
step_pos = false,
stance = false,
range = max_check_range,
prediction = true,
output_collisions = true,
}
if not destinations or #destinations > 1 then
lof_params.target_spot_group = "Torso"
end
end
--[[ local logdata = {}
if destinations then
table.insert(g_AIDamageScoreLog, logdata)
end
logdata.preferred_target = preferred_target and (IsKindOf(preferred_target, "Unit") and _InternalTranslate(preferred_target.Name or "") or preferred_target.class) or tostring(preferred_target)--]]
destinations = destinations or context.destinations
NetUpdateHash("AIPrecalcDamageScore", unit, hashParamTable(destinations), hashParamTable(targets), preferred_target)
for j, upos in ipairs(destinations) do
local ux, uy, uz, ustance_idx = stance_pos_unpack(upos)
local ustance = StancesList[ustance_idx]
uz = uz or terrain.GetHeight(ux, uy)
local ap = dest_ap[upos] or 0
local best_target, best_cth
local best_score = 0
local potential_targets, target_score, target_cth = {}, {}, {}
if weapon and ap >= cost_ap then
local pos_mod = base_mod
pos_mod = pos_mod + (ustance_idx == 2 and modCrouchBonus or ustance_idx == 3 and modProneBonus or 0)
local targets_attack_data
if not is_melee then
attacker_pos = point(ux, uy, uz)
lof_params.step_pos = point_pack(ux, uy, uz)
lof_params.stance = ustance
targets_attack_data = GetLoFData(unit, targets, lof_params)
end
for k, target in ipairs(targets) do
local tpos = GetPackedPosAndStance(target)
local dist = stance_pos_dist(upos, tpos)
if dist <= (max_check_range or dist) and (is_melee or targets_attack_data[k] and not targets_attack_data[k].stuck) then
local tx, ty, tz, tstance_idx = stance_pos_unpack(tpos)
tz = tz or terrain.GetHeight(tx, ty)
local hit_mod = pos_mod
if not is_heavy then
hit_mod = hit_mod + (uz > tz + MinGroundDifference and modHighGround or uz < tz - MinGroundDifference and modLowGround or 0)
hit_mod = hit_mod + (unit:GetLastAttack() == target and modSameTarget or 0)
end
local target_cover = GetCoverFrom(tpos, upos)
if target_cover == const.CoverLow or target_cover == const.CoverHigh then
hit_mod = hit_mod + modCover
end
local penalty = is_heavy and 0 or (100 - weapon:GetAccuracy(dist))
local mod = hit_mod - penalty --dist_penalty
-- environmental modifiers when applicable
local apply, value, target_spot_group, action, weapon1, weapon2, lof, aim, opportunity_attack
apply, value = hit_modifiers.Darkness:CalcValue(unit, target, target_spot_group, action, weapon1, weapon2, lof, aim, opportunity_attack, attacker_pos)
if apply then
mod = mod + value
end
if not is_heavy and unit:IsPointBlankRange(target) then
mod = MulDivRound(mod, 100 + const.AIPointBlankTargetMod, 100)
end
mod = Max(0, mod)
if mod > const.AIShootAboveCTH then
-- calc base score based on cth/attacks/aiming
local base_mod = mod
local attacks, aims = AICalcAttacksAndAim(context, ap)
mod = 0
for i = 1, attacks do
local use, bonus
if (aims[i] or 0) > 0 then
use, bonus = aim_mod:CalcValue(unit, nil, nil, nil, nil, nil, nil, aims[i])
end
mod = mod + base_mod + (use and bonus or 0)
end
-- modify score by archetype-specific weight and (optional) targeting policies
mod = MulDivRound(mod, archetype.TargetBaseScore, 100)
for _, policy in ipairs(target_policies) do
local peval = policy:EvalTarget(unit, target)
mod = mod + MulDivRound(peval or 0, policy.Weight, 100)
end
if IsKindOf(target, "Unit") and (target:IsDowned() or target:IsGettingDowned()) then
mod = MulDivRound(mod, 5, 100)
end
local attack_data = targets_attack_data and targets_attack_data[k]
local ally_in_danger = attack_data and (attack_data.best_ally_hits_count or 0) > 0
if action and action.AimType == "cone" then
ally_in_danger = ally_in_danger or AIAllyInDanger(context.allies, context.ally_pos, attacker_pos, target, const.AIFriendlyFire_LOFConeNear, const.AIFriendlyFire_LOFConeFar)
else
ally_in_danger = ally_in_danger or AIAllyInDanger(context.allies, context.ally_pos, attacker_pos, target, const.AIFriendlyFire_LOFWidth, const.AIFriendlyFire_LOFWidth)
end
if ally_in_danger then
mod = MulDivRound(mod, const.AIFriendlyFire_ScoreMod, 100)
end
mod = MulDivRound(mod, target_score_mod[k], 100)
-- apply group-based modifiers
if target_modifiers and IsKindOf(target, "Unit") then
local group_mod = 0
for _, groupname in ipairs(target.Groups) do
group_mod = group_mod + (target_modifiers[groupname] or 0)
end
if group_mod > 0 then
mod = MulDivRound(mod, group_mod, 100)
end
end
--[[table.insert(logdata, {
name = IsKindOf(target, "Unit") and _InternalTranslate(target.Name or "") or target.class,
score = mod
})--]]
if mod > 0 and target == preferred_target then
best_target = target
best_score = mod
best_cth = base_mod
potential_targets = {}
break
end
best_score = Max(best_score, mod)
target_cth[target] = base_mod
target_score[target] = mod
local threshold = MulDivRound(best_score or 0, const.AIDecisionThreshold, 100)
if mod >= threshold then
potential_targets[#potential_targets + 1] = target
for i = #potential_targets, 1, -1 do
local target = potential_targets[i]
local score = target_score[target]
if score < threshold then
table.remove(potential_targets, i)
end
end
--best_target, best_score, best_cth = target, mod, base_mod
end
end
end
end
end
if #potential_targets > 0 then
local total = 0
for _, target in ipairs(potential_targets) do
local score = target_score[target]
total = total + score
if debug_data then
debug_data[target] = score
end
end
local roll = InteractionRand(total, "AIDecision")
for _, target in ipairs(potential_targets) do
local score = target_score[target]
if roll < score then
best_target = target
break
end
roll = roll - score
end
best_target = best_target or potential_targets[#potential_targets] or false
best_score = target_score[best_target] or 0
best_cth = target_cth[best_target] or 0
end
--[[
if destinations and IsKindOf(best_target, "Unit") then
if best_target == preferred_target then
printf("%s (%d) selected target (preferred): %s (score %d)", _InternalTranslate(unit.Name or ""), unit.handle, _InternalTranslate(best_target.Name or ""), best_score)
else
printf("%s (%d) selected target: %s (score %d)", _InternalTranslate(unit.Name or ""), unit.handle, _InternalTranslate(best_target.Name or ""), best_score)
printf(" potential targets:")
for _, target in ipairs(potential_targets) do
printf(" %s (score %d)", _InternalTranslate(target.Name or ""), target_score[target])
end
end
end--]]
--logdata.chosen_target = best_target and (IsKindOf(best_target, "Unit") and _InternalTranslate(best_target.Name or "") or best_target.class) or tostring(best_target)
dest_target_score[upos] = best_score
dest_target[upos] = best_target
dest_cth[upos] = best_cth
end
end
function AIScoreReachableVoxels(context, policies, opt_loc_weight, dest_score_details, cur_dest_preference)
local unit = context.unit
policies = table.ifilter(policies, function(idx, policy) return policy:MatchUnit(unit) end)
unit.ai_end_turn_search = {}
local total_dist = context.total_dist
local dest_dist = context.dest_dist or empty_table
local curr_dest = context.voxel_to_dest[context.unit_world_voxel] or context.voxel_to_dest[context.closest_free_pos] or context.unit_stance_pos
local dist = dest_dist[curr_dest] or total_dist
local score = -opt_loc_weight
if (total_dist or 0) > 0 then
score = MulDivRound(score, dist, total_dist)
end
local unit_voxels = {}
local best_end_score = curr_dest and AIScoreDest(context, policies, curr_dest, context.unit_grid_voxel, score, unit_voxels)
-- cache the best voxel on the way to optimal location to use as fallback if needed
local best_dist_score, closest_dest
local potential_dests, dest_scores = {curr_dest}, {best_end_score}
for _, dest in ipairs(context.destinations) do
total_dist = Max(total_dist or 0, dest_dist[dest] or 0)
end
for _, dest in ipairs(context.destinations) do
local score = 0
local scores
local dist = dest_dist[dest] or 100*guim
local dist_score = 0
if total_dist and total_dist > 0 then
dist_score = MulDivRound(100 - MulDivRound(100, dist, total_dist), opt_loc_weight, 100)
end
if dist_score > (best_dist_score or 0) then
best_dist_score, closest_dest = dist_score, dest
end
score = score + dist_score
if dest_score_details then
scores = { "Distance to optimal location", dist_score }
dest_score_details[dest] = scores
end
table.iclear(unit_voxels)
score = AIScoreDest(context, policies, dest, nil, score, unit_voxels, scores)
if MulDivRound(best_end_score or 0, const.AIDecisionThreshold, 100) <= score then
best_end_score = Max(score, best_end_score or 0)
local n = #potential_dests
potential_dests[n+1] = dest
dest_scores[n+1] = score
local threshold = MulDivRound(best_end_score, const.AIDecisionThreshold, 100) -- updated threshold
for i = n, 1, -1 do
if dest_scores[i] < threshold then
table.remove(dest_scores, i)
table.remove(potential_dests, i)
end
end
end
if scores then
scores.final_score = score
end
end
-- pick best_end_dest/score from potential_dests
assert(#potential_dests > 0)
context.best_end_dest = false
if cur_dest_preference == "prefer" then
if table.find(potential_dests, curr_dest) then
context.best_end_dest = curr_dest
end
elseif cur_dest_preference == "avoid" then
if #potential_dests > 1 then
table.remove_value(potential_dests, curr_dest)
end
end
NetUpdateHash("AIScoreReachableVoxels", unit, unit:GetPos(), unit.ActionPoints, context.archetype.id, #(context.destinations or ""), hashParamTable(context.destinations), #(potential_dests or ""), hashParamTable(potential_dests), cur_dest_preference)
if not context.best_end_dest then
local total = 0
for _, score in ipairs(potential_dests) do
total = total + score
end
local roll = InteractionRand(total, "AIDecision")
for i, dest in ipairs(potential_dests) do
local score = dest_scores[i]
if score <= roll then
context.best_end_dest = dest
break
end
roll = roll - score
end
context.best_end_dest = context.best_end_dest or potential_dests[#potential_dests] or curr_dest
end
context.best_end_score = best_end_score
context.closest_dest = closest_dest
return context.best_end_dest, context.best_end_score
end
function CalcPathVoxels(path)
local dist = 0
if not IsPoint(path[1]) then
local pt_path = {}
for i, ppos in ipairs(path) do
pt_path[i] = point(point_unpack(ppos))
end
path = pt_path
end
local processed_path = { path[1] }
local voxel_dist = {}
local voxels = {}
voxel_dist[point_pack(path[1])] = 0
local function push_path_segment(seg_start, seg_end, path_dist, tunnel)
local seg_dist = seg_start:Dist(seg_end)
if not tunnel and seg_dist > const.SlabSizeX/2 then
local midpt = (seg_start + seg_end) / 2
push_path_segment(seg_start, midpt, path_dist)
push_path_segment(midpt, seg_end, path_dist + seg_dist / 2)
else
processed_path[#processed_path + 1] = seg_end
local x, y, z = GetPassSlabXYZ(seg_end)
local pck_end = x and point_pack(x, y, z)
if pck_end and not voxel_dist[pck_end] then
voxel_dist[pck_end] = path_dist + seg_dist
voxels[#voxels + 1] = pck_end
--[[
local pt = point(x, y, z)
if not pt:IsValidZ() then pt = pt:SetTerrainZ() end
DbgAddVector(pt, point(0, 0, guim), const.clrGreen)
DbgAddText(tostring(path_dist + seg_dist), pt + point(0, 0, guim/2), const.clrWhite)--]]
end
end
return seg_dist
end
local dist = 0
local marker = InvalidPos()
local seg_start_idx, seg_end_idx
--DbgClearVectors()
--DbgClearTexts()
for i = 1, #path do
if not seg_start_idx then
seg_start_idx = path[i] ~= marker and i
elseif not seg_end_idx then
seg_end_idx = path[i] ~= marker and i
end
if seg_start_idx and seg_end_idx then
--[[
local pt1 = path[seg_end_idx]
local pt2 = path[seg_start_idx]
if not pt1:IsValidZ() or pt1:z() < terrain.GetHeight(pt1) + 50*guic then
pt1 = pt1:SetTerrainZ(100*guic)
end
if not pt2:IsValidZ() or pt2:z() < terrain.GetHeight(pt2) + 50*guic then
pt2 = pt2:SetTerrainZ(100*guic)
end
DbgAddVector(pt1, point(0, 0, guim), const.clrWhite)
DbgAddVector(pt2, point(0, 0, guim), const.clrWhite)
printf("seg %d: %s - %s", seg_start_idx, tostring(pt2), tostring(pt1))
DbgAddVector(pt1, pt2 - pt1, seg_end_idx > seg_start_idx + 1 and const.clrYellow or const.clrWhite)
DbgAddText(tostring(seg_start_idx), (pt1+pt2)/2, const.clrBlue)
--]]
dist = dist + push_path_segment(path[seg_start_idx], path[seg_end_idx], dist, seg_end_idx > seg_start_idx + 1)
seg_start_idx = seg_end_idx
seg_end_idx = false
end
end
return voxels, voxel_dist, dist
end
function AICalcDistancesFromReachableLocations(context)
local voxel_idx = 1
local stance = context.archetype.MoveStance
local tunnel_mask = stance == "Prone" and const.TunnelTypeWalk or -1
local processed = {}
local voxel_to_dest = context.voxel_to_dest
local path_voxels = context.path_voxels
local voxel_dist = context.voxel_dist
local dest_dist = {}
context.dest_dist = dest_dist
for voxel, dist in pairs(context.voxel_dist) do
local dest = voxel_to_dest[voxel]
if dest then
context.dest_dist[dest] = dist
end
end
--DbgClearVectors()
--DbgClearTexts()
while path_voxels[voxel_idx] do
local voxel = path_voxels[voxel_idx]
local dest = voxel_to_dest[voxel]
if not processed[voxel] then
processed[voxel] = true
local px, py, pz = point_unpack(voxel)
--[[
local pt = point(px, py, pz)
if not pt:IsValidZ() then pt = pt:SetTerrainZ() end
DbgAddVector(pt, point(0, 0, 2*guim), const.clrBlue)
DbgAddText(dest and dest_dist[dest] and tostring(dest_dist[dest]) or "n/a", pt + point(0, 0, guim), const.clrWhite)
DbgAddText(voxel_dist[voxel] and tostring(voxel_dist[voxel]) or "n/a", pt + point(0, 0, guim/2), const.clrYellow)
--]]
ForEachPassSlabStep(px, py, pz, tunnel_mask, function(x, y, z, tunnel)
local curr_voxel = point_pack(x, y, z)
local curr_dest = voxel_to_dest[curr_voxel]
if curr_dest and dest then
assert(voxel_dist[voxel])
local x2, y2, z2 = point_unpack(voxel)
local dx, dy, dz = x - x2, y - y2, (z and z2) and z - z2 or 0
local dist = voxel_dist[voxel] + sqrt(dx*dx + dy*dy + dz*dz) -- the tile is guaranteed to be reachable, so we can take linear distance
if not voxel_dist[curr_voxel] or voxel_dist[curr_voxel] > dist then
-- if the step is a tunnel, we need to check if it goes both ways to filter out shortcuts from target to current location
if not tunnel or pf.GetTunnel(tunnel.end_point, tunnel:GetPos()) then
voxel_dist[curr_voxel] = dist
dest_dist[curr_dest] = dist
end
end
path_voxels[#path_voxels + 1] = curr_voxel
if not voxel_dist[curr_voxel] then
voxel_dist[curr_voxel] = dist
end
if not dest_dist[curr_dest] then
dest_dist[curr_dest] = dist
end
end
end)
end
voxel_idx = voxel_idx + 1
end
end
function AIGetAttackArgs(context, action, target_spot_group, aim_type, override_target)
local upos = GetPackedPosAndStance(context.unit)
local target = override_target or context.dest_target[upos]
local args = { target = target, target_spot_group = target_spot_group or "Torso" }
local dest_ap
if context.ai_destination then
local u_x, u_y, u_z = stance_pos_unpack(upos)
local dest_x, dest_y, dest_z = stance_pos_unpack(context.ai_destination)
if point(u_x, u_y, u_z) ~= point(dest_x, dest_y, dest_z) then
dest_ap = context.dest_ap[context.ai_destination]
end
end
local unit_ap = dest_ap or context.unit:GetUIActionPoints()
if action.id == "Overwatch" then
local attacks, aim = context.unit:GetOverwatchAttacksAndAim(action, args, unit_ap)
args.num_attacks = attacks
args.aim_ap = aim
elseif aim_type ~= "None" then
args.aim = context.weapon.MaxAimActions
if aim_type == "Remaining AP" then
while args.aim > 0 and not context.unit:HasAP(action:GetAPCost(context.unit, args)) do
args.aim = args.aim - 1
end
end
end
local cost = action:GetAPCost(context.unit, args)
local has_ap = cost >= 0 and (unit_ap >= cost)
return args, has_ap, target
end
function AIFilterTargetPoints(unit, target_pts, min_range, max_range)
for i = #target_pts, 1, -1 do
local dist = unit:GetDist(target_pts[i])
if dist == 0 or (max_range and dist > max_range) then
table.remove(target_pts, i)
elseif min_range and min_range < max_range and dist < min_range then
table.remove(target_pts, i)
end
end
end
function AICalcAOETargetPoints(context, min_range, max_range, max_radius)
local target_pts = {}
local unit = context.unit
local enemies = context.enemies
-- add enemy positions
for i, enemy in ipairs(enemies) do
if VisibilityCheckAll(unit, enemy, nil, const.uvVisible) then
target_pts[#target_pts + 1] = context.enemy_pos[enemy]
end
end
local num_targets = #target_pts
-- add midpoints of enemy pairs
for i = 1, num_targets - 1 do
for j = i + 1, num_targets do
local pt = (target_pts[i] + target_pts[j]) / 2
if not max_radius or pt:Dist(target_pts[i]) <= max_radius then
target_pts[#target_pts + 1] = pt
end
end
end
-- add midpoints of enemy triples
for i = 1, num_targets - 2 do
for j = i + 1, num_targets - 1 do
for k = j + 1, num_targets do
local pt = (target_pts[i] + target_pts[j] + target_pts[k]) / 3
if not max_radius or pt:Dist(target_pts[i]) <= max_radius then
target_pts[#target_pts + 1] = pt
end
end
end
end
-- filter out target points not in range
AIFilterTargetPoints(unit, target_pts, min_range, max_range)
return target_pts
end
function AIPrecalcConeTargetZones(context, action_id, additional_target_pt, stance)
if context.target_locked then return {} end
local unit = context.unit
local weapon = context.weapon
local params = weapon:GetAreaAttackParams(action_id, unit)
local min_range = params.min_range * const.SlabSizeX
local max_range = params.max_range * const.SlabSizeX
local target_pts = AICalcAOETargetPoints(context, min_range, max_range)
if additional_target_pt then
target_pts[#target_pts + 1] = additional_target_pt
end
-- calc cone areas for each remaining target point
local zones = {}
local cone_angle = params.cone_angle
local targets = {}
local attack_pos = unit:GetPos() -- make sure we're using the current position in case the unit has moved
local units = table.copy(context.enemies)
table.iappend(units, GetAllAlliedUnits(unit))
local unit_sight = unit:GetSightRadius()
for zi, pt in ipairs(target_pts) do
local dir = pt - attack_pos
if dir:Len() > 0 then
local target_pos = (attack_pos + SetLen(dir, max_range)):SetTerrainZ()
local zone = {
target_pos = target_pos,
units = {},
}
zones[#zones + 1] = zone
local angle = CalcOrientation(attack_pos, pt)
local los_any, los_targets = CheckLOS(units, unit, unit:GetDist(target_pos), nil, cone_angle, angle)
if los_any then
for i, target_unit in ipairs(units) do
if los_targets[i] and IsValidTarget(target_unit) then
zone.units[#zone.units + 1] = target_unit
table.insert_unique(targets, target_unit)
end
end
end
end
end
local check_ally
if action_id == "Overwatch" then
local atk_action = context.default_attack
local aim_type = atk_action.AimType
local is_aoe = aim_type == "cone" or aim_type == "aoe" or aim_type == "parabola aoe" or aim_type == "line aoe"
check_ally = not is_aoe
end
-- filter LOS targets
local max_distance = Min(unit_sight, weapon:GetMaxRange())
local los_any, los_targets = CheckLOS(targets, unit, max_distance)
if not los_any then
for _, zone in ipairs(zones) do
table.iclear(zone.units)
end
return zones
end
for i = #targets, 1, -1 do
if not los_any or not los_targets[i] then
for _, zone in ipairs(zones) do
table.remove_value(zone.units, targets[i])
end
table.remove(targets, i)
end
end
-- check chance to hit
local targets_attack_data = GetLoFData(unit, targets, {
obj = unit,
action_id = context.default_attack.id,
weapon = weapon,
stance = unit.stance,
range = max_distance,
target_spot_group = "Torso",
prediction = true,
})
local action = CombatActions[action_id]
local args = { target_spot_group = false }
for i, attack_data in ipairs(targets_attack_data) do
local target = targets[i]
local chance_to_hit = 0
if attack_data and not attack_data.stuck then
for j, hit_info in ipairs(attack_data.lof) do
if not check_ally or hit_info.ally_hits_count == 0 then
args.target_spot_group = hit_info.target_spot_group
chance_to_hit = unit:CalcChanceToHit(target, action, args, "chance_only")
if chance_to_hit > 0 then
break
end
end
end
end
if chance_to_hit == 0 then
for _, zone in ipairs(zones) do
table.remove_value(zone.units, target)
end
end
end
return zones
end
local function IsUnitHit(hit)
if not IsKindOf(hit.obj, "Unit") then return false end
if hit.damage > 0 then return true end
for _, effect in ipairs(hit.effects) do
if effect and effect ~= "" then
return true
end
end
end
function AIPrecalcGrenadeZones(context, action_id, min_range, max_range, blast_radius, aoeType, target_pts)
if context.target_locked then return {} end
if not target_pts then
target_pts = AICalcAOETargetPoints(context, min_range, max_range, blast_radius)
else
-- make sure the target points are within the allowed range
AIFilterTargetPoints(context.unit, target_pts, min_range, max_range)
end
-- calculate parabolas and affected units to each target point
local zones = {}
local action = CombatActions[action_id]
local args = { target = false }
for i, target_pt in ipairs(target_pts) do
args.target = target_pt
local results = action:GetActionResults(context.unit, args)
local units
local trajectory = results.trajectory or empty_table
local pos = #trajectory > 0 and trajectory[#trajectory].pos or results.target_pos
if pos and (aoeType == "smoke" or aoeType == "toxicgas" or aoeType == "teargas") then
local water = terrain.IsWater(pos) and terrain.GetWaterHeight(pos)
if not (water and (not pos:IsValidZ() or water >= pos:z())) then
pos = SnapToPassSlab(pos) or pos
local dx, dy = 1, 1
for i = #trajectory - 1, 1, -1 do
local step = trajectory[i]
if step.pos:Dist2D(pos) > 0 then
local px, py = step.pos:xy()
local x, y = pos:xy()
dx = (px == x) and 1 or ((x - px) / abs(x - px))
dy = (py == y) and 1 or ((y - py) / abs(y - py))
break
end
end
local gx, gy, gz = WorldToVoxel(pos)
local smoke, blocked = PropagateSmokeInGrid(gx, gy, gz, dx, dy)
local smoke_voxels = {}
for _, wpt in pairs(smoke) do
local ppos = point_pack(WorldToVoxel(wpt))
smoke_voxels[ppos] = true
end
for _, unit in ipairs(g_Units) do
local _, head = unit:GetVisualVoxels()
if smoke_voxels[head] then
units = units or {}
table.insert(units, unit)
end
end
end
else
for _, hit in ipairs(results) do
if IsUnitHit(hit) then
units = units or {}
table.insert(units, hit.obj)
end
end
end
if units then
zones[#zones + 1] = { target_pos = target_pt, units = units }
end
end
--print("grenade targeting precalc in", GetPreciseTicks() - tstart, "ms")
return zones
end
function AIPrecalcLandmineZones(context)
if context.target_locked then return {} end
local weapon = context.weapon
if not IsKindOf(weapon, "Firearm") then
return {}
end
if not context.mine_zones then
local unit = context.unit
local sight = unit:GetSightRadius()
local max_range = Min(weapon.WeaponRange * const.SlabSizeX, sight)
local landmines = MapGet(unit, max_range, "Landmine", function(o, unit)
return o:SeenBy(unit)
end, unit)
local zones = {}
for _, mine in ipairs(landmines) do
local aoe_params = mine:GetAreaAttackParams(nil, unit, mine:GetPos())
aoe_params.prediction = true
local results = GetAreaAttackResults(aoe_params, 0)
local units
for _, hit in ipairs(results) do
if IsKindOf(hit.obj, "Unit") and hit.damage > 0 then
if not units then
units = {}
end
table.insert(units, hit.obj)
end
end
if units then
zones[#zones + 1] = { target = mine, units = units }
end
end
context.mine_zones = zones
end
return context.mine_zones
end
function AISelectHealTarget(context, dest, grid_voxel, heal_policy)
if context.voxel_heal_score[grid_voxel] then
return context.voxel_heal_target[grid_voxel], context.voxel_heal_score[grid_voxel]
end
local x, y, z = point_unpack(grid_voxel)
local best_target, best_score = false, 0
local dx, dy, dz = stance_pos_unpack(dest)
local ppos = point_pack(dx, dy, dz)
for _, ally in ipairs(context.allies) do
local hpp = MulDivRound(ally.HitPoints, 100, ally.MaxHitPoints)
local score
if hpp <= heal_policy.MaxHp and not ally:IsDead() then
local bleed = 0
if ally:HasStatusEffect("Bleeding") then
bleed = heal_policy.BleedingWeight
end
local gx, gy, gz = point_unpack(context.ally_grid_voxel[ally])
if ally == context.unit or IsMeleeRangeTarget(context.unit, ppos, nil, ally) then --(abs(x - gx) <= 1 and abs(y - gy) <= 1 and abs(z - gz) <= 1) then
score = MulDivRound(100 - hpp, heal_policy.HpWeight, 100) + bleed
end
if ally == context.unit then
score = MulDivRound(score, heal_policy.SelfHealMod, 100)
end
end
score = score or 0
if not best_score or score > best_score then
best_target, best_score = ally, score
end
end
local ap_at_dest = context.dest_ap[dest] or 0
if ap_at_dest >= CombatActions.Bandage.ActionPoints then
best_score = MulDivRound(best_score, heal_policy.CanUseMod, 100)
end
context.voxel_heal_target[grid_voxel] = best_target
context.voxel_heal_score[grid_voxel] = best_score
return best_target, best_score
end
function AIEvalStimTarget(unit, target, rules)
if target:IsDead() or target:HasStatusEffect("Stimmed") then
return 0
end
local score = 0
for _, rule in ipairs(rules) do
if table.find(target.AIKeywords or empty_table, rule.Keyword) then
score = score + rule.Weight
end
end
return score
end
local AITurnPhasePriority = {
Early = 1,
Normal = 2,
Late = 3,
}
function AIGetNextPhaseUnits(units, max)
local best_units, best_prio
for _, unit in ipairs(units) do
local behavior = unit.ai_context and unit.ai_context.behavior
if behavior then
local turn_phase = behavior:GetTurnPhase(unit)
local prio = AITurnPhasePriority[turn_phase] or 999
if not best_prio or prio < best_prio then
best_units, best_prio = {unit}, prio
elseif prio == best_prio then
best_units[#best_units + 1] = unit
end
if max and #(best_units or empty_table) >= max then
break
end
end
end
return best_units
end
function IsMeleeRangeTarget(attacker, attack_pos, attack_stance, target, target_pos, target_stance, attacker_face_angle)
if not IsValidTarget(target) then return end
if IsSittingUnit(target) then
target_pos = target_pos or target.last_visit:GetPos()
target_stance = "Crouch"
end
return IsMeleeRangeTargetC(attacker, attack_pos, attack_stance, target, target_pos, target_stance, attacker_face_angle)
end
function GetMeleeRangePositions(attacker, target, target_pos, check_occupied)
if IsSittingUnit(target) then
target_pos = target.last_visit:GetPos()
end
return GetMeleeRangePositionsC(attacker, target, target_pos, check_occupied)
end
function GetClosestMeleeRangePos(attacker, target, target_pos, check_occupied)
if IsSittingUnit(target) then
target_pos = target.last_visit:GetPos()
end
return GetClosestMeleeRangePosC(attacker, target, target_pos, check_occupied)
end
function AIRangeCheck(context, ppt1, target, ppt2, range_type, range_min, range_max)
if range_type == "Melee" then
local p1 = point_pack(VoxelToWorld(point_unpack(ppt1)))
local p2 = point_pack(VoxelToWorld(point_unpack(ppt2)))
return IsMeleeRangeTarget(context.unit, p1, context.unit.stance, target, p2, target.stance)
end
if range_type ~= "Absolute" then
-- weapon range based
assert(range_type == "Weapon")
local base_range = context.ExtremeRange
range_min = range_min and MulDivRound(range_min, base_range, 100)
range_max = range_max and MulDivRound(range_max, base_range, 100)
end
local x1, y1, z1 = point_unpack(ppt1)
local x2, y2, z2 = point_unpack(ppt2)
if (range_min or 0) > 0 and IsCloser(x1, y1, z1, x2, y2, z2, range_min) then
return false
end
if (range_max or 0) > 0 and not IsCloser(x1, y1, z1, x2, y2, z2, range_max + 1) then
return false
end
return true
end
function AIReloadWeapons(unit)
if IsMerc(unit) then return end
local firearms = select(3, unit:GetActiveWeapons("Firearm"))
table.iappend(firearms, select(3, unit:GetActiveWeapons("HeavyWeapon")))
for _, firearm in ipairs(firearms) do
if not firearm.ammo then
local ammos = unit:GetAvailableAmmos(firearm) or empty_table
local ammo
if #ammos > 0 then
ammo = ammos[1]
ammo.Amount = Max(ammo.Amount, firearm.MagazineSize)
unit:ReloadWeapon(firearm, ammo, "delay fx", "ai")
CreateFloatingText(unit, T(160472488023, "Reload"))
ObjModified(unit)
else
ammos = GetAmmosWithCaliber(firearm.Caliber, "sorted")
if #ammos > 0 then
ammo = PlaceInventoryItem(ammos[1].id)
ammo.Amount = firearm.MagazineSize
unit:ReloadWeapon(firearm, ammo, "delay fx", "ai")
CreateFloatingText(unit, T(160472488023, "Reload"))
DoneObject(ammo)
ObjModified(unit)
end
end
elseif firearm.ammo.Amount < Max(1, firearm.MagazineSize / 2) then
local ammo = firearm.ammo
ammo.Amount = firearm.MagazineSize
unit:ReloadWeapon(firearm, ammo, "delay fx", "ai")
CreateFloatingText(unit, T(160472488023, "Reload"))
ObjModified(unit)
end
end
end
function AIPickScoutLocation(unit)
local AIScoutLocationSearchRadius = 5 * guim
-- pick a new position around alive enemy randomly, prefer non-hidden enemies
local enemies = GetAllEnemyUnits(unit)
if #enemies == 0 then
return
end
local targets
local nearest, nearby = {}, {}
for _, enemy in ipairs(enemies) do
local dist = unit:GetDist(enemy)
if dist <= AIScoutLocationSearchRadius then
nearest[#nearest + 1] = enemy
targets = nearest
elseif dist <= 2*AIScoutLocationSearchRadius then
nearby[#nearby + 1] = enemy
targets = targets or nearby
end
end
targets = targets or enemies
local enemy = table.interaction_rand(enemies, "Combat")
local ux, uy, uz = enemy:GetGridCoords()
local px, py, pz = VoxelToWorld(ux, uy, uz)
local r = AIScoutLocationSearchRadius
local bbox = box(px - r, py - r, 0, px + r + 1, py + r + 1, MapSlabsBBox_MaxZ)
local dests, dest_added = {}, {}
local function push_dest(x, y, z, dests, dest_added, ux, uy, uz)
local gx, gy, gz = WorldToVoxel(x, y, z)
if not IsCloser(gx, gy, gz, ux, uy, uz, AIScoutLocationSearchRadius) then
return
end
local world_voxel = point_pack(x, y, z)
if not dest_added[world_voxel] then
dests[#dests + 1] = world_voxel
dest_added[world_voxel] = true
end
end
ForEachPassSlab(bbox, push_dest, dests, dest_added, ux, uy, uz)
if #dests > 0 then
local voxel = table.interaction_rand(dests, "Combat")
local x, y, z = point_unpack(voxel)
return point(x, y, z)
end
end
function AIUpdateScoutLocation(unit)
if not unit.last_known_enemy_pos then
return
end
local sight = unit:GetSightRadius()
if CheckLOS(unit.last_known_enemy_pos, unit, sight) then
-- scouted here, next time pick a different location if still necessary
unit.last_known_enemy_pos = nil
end
end
MapVar("g_MGPriorityAssignment", {})
function AIAssignToEmplacements(team)
local emplacements = MapGet("map", "MachineGunEmplacement")
local units = table.ifilter(team.units, function(idx, unit) return unit.CanManEmplacements end)
-- update emplacements' appeal for the team
for _, emplacement in ipairs(emplacements) do
local targets = #units > 0 and emplacement:GetEnemyUnitsInArea(units[1]) or empty_table
local appeal = MulDivRound(emplacement.appeal[team.side] or 0, Max(0, 100 - emplacement.appeal_decay), 100)
for _, enemy in ipairs(targets) do
local dist = emplacement:GetDist(enemy)
local diff = abs(dist - emplacement.appeal_optimal_dist)
appeal = appeal + Max(0, emplacement.appeal_per_target + MulDivRound(emplacement.appeal_per_meter, dist, guim))
end
emplacement.appeal[team.side] = appeal
if not SpawnedByEnabledMarker(emplacement) or not emplacement.enabled then
emplacement.appeal[team.side] = 0
end
end
if emplacements then
table.sort(emplacements, function(a, b) return a.appeal[team.side] > b.appeal[team.side] end)
end
for _, emplacement in ipairs(emplacements) do
local assigned_unit = g_Combat:GetEmplacementAssignment(emplacement)
if (emplacement.appeal[team.side] or 0) > emplacement.appeal_use_threshold then
if not emplacement.manned_by and not assigned_unit then
-- free for grabs, find a unit to man the MG
-- check priority assignment first
local gunner
for _, unit in ipairs(g_MGPriorityAssignment) do
if IsValidTarget(unit) and unit.team == team and unit.CanManEmplacements and not unit:IsIncapacitated() then
gunner = unit
break
end
end
if not gunner then
local emplacement_pos = SnapToPassSlab(emplacement:GetPosXYZ())
if emplacement_pos then
table.sort(units, function(a, b) return IsCloser(emplacement_pos, a, b) end)
local closest, closest_pf_dist
for _, u in ipairs(units) do
-- select unit closest to the emplacement (by pathfind)
local emp = g_Combat:GetEmplacementAssignment(u)
if not emp and (not closest or IsCloser(u, emplacement_pos, closest_pf_dist)) then
local has_path, path_len, closest_pos = pf.PosPathLen(u, emplacement_pos, nil, 0, 0, u, 0, nil, 0)
if has_path and closest_pos == emplacement_pos then
if not closest_pf_dist or path_len < closest_pf_dist then
closest, closest_pf_dist = u, path_len
end
end
end
end
gunner = closest
end
end
if gunner then
g_Combat:AssignEmplacement(emplacement, gunner)
end
elseif assigned_unit and assigned_unit.team == team then
if emplacement.manned_by and emplacement.manned_by ~= assigned_unit then
-- somebody else took it, clean up assignment
g_Combat:AssignEmplacement(emplacement, nil)
end
end
elseif assigned_unit and assigned_unit.team == team then
g_Combat:AssignEmplacement(emplacement, nil)
end
end
end
function AIEnemyWeaponsCombo()
local types = table.map(GetWeaponTypes(), "id")
table.insert_unique(types, "Pistol")
table.insert_unique(types, "Revolver")
table.insert_unique(types, "MeleeWeapon")
table.insert_unique(types, "Unarmed")
return types
end
function measure_func(func, num_invocations, ...)
num_invocations = num_invocations or 0
if num_invocations < 1 then
return
end
local start = GetPreciseTicks()
for i = 1, num_invocations do
func(...)
end
local elapsed_ms = GetPreciseTicks() - start
printf("%d invocations finished in %d ms for (%d ms average)", num_invocations, elapsed_ms, elapsed_ms / num_invocations)
end
DefineClass.AIBiasMarker = {
__parents = { "GridMarker" },
properties = {
{ category = "AI Bias", id = "UnitGroups", name = "UnitGroups", editor = "string_list", default = false, items = function (self) return GetUnitGroups() end },
{ category = "AI Bias", id = "Bias", editor = "number", min = 0, max = 1000, scale = "%", slider = true, default = 100, help = "modifier applied to AI evaluations of destinations inside the marker area"},
},
}
function AIBiasMarker:GetAIBias(unit, dest)
if not unit or not self:IsMarkerEnabled() then return 100 end
local x, y, z = stance_pos_unpack(dest)
z = z or terrain.GetHeight(x, y)
x, y = WorldToVoxel(x, y, z)
if not self:IsVoxelInsideArea2D(x, y) then
return 100
end
local apply_groups = g_BiasMarkers[self] or empty_table
for _, group in ipairs(unit.Groups) do
if apply_groups[group] then
return self.Bias
end
end
return 100
end
function InitAIBiasMarkers()
g_BiasMarkers = g_BiasMarkers or MapGetMarkers("GridMarker", nil, function(m) return IsKindOf(m, "AIBiasMarker") end) or false
for _, marker in ipairs(g_BiasMarkers) do
local apply_grous = {}
g_BiasMarkers[marker] = apply_grous
for _, group in ipairs(marker.UnitGroups) do
apply_grous[group] = true
end
end
end
function AICheckIndoors(dest)
if g_AIDestIndoorsCache[dest] == nil then
local x, y, z = stance_pos_unpack(dest)
local volume = EnumVolumes(point(x, y, z), "smallest")
g_AIDestIndoorsCache[dest] = not not volume
end
return g_AIDestIndoorsCache[dest]
end