myspace / Lua /Tactical /CombatCamera.lua
sirnii's picture
Upload 1816 files
b6a38d7 verified
raw
history blame
64.6 kB
--[[
CombatCam_ShowAttack(attacker, target)
Focus the camera on the attack, attempting to show both the attacker and the target on the screen.
If another CombatCam_ShowAttack is currently in progress, the call will be queued and wait until all previous calls
are finished.
CombatCam_ShowAttackNew()
Focus the target of the attack. If another CombatCam_ShowAttackNew is currently in progress, this means an interrupt has
occured and it will be executed before the initial action is finished. This allows for specific behavior of the camera
during interrupts.
LockCameraMovement(reason), UnlockCameraMovement(reason)
Change the LockedMovement state of the tactical camera (reason-based).
CreateAIExecutionController
Wait until the previous execution controller is destroyed and create a new one.
The execution controller handles AI activity by selecting groups of units to play simultaneously (when possible),
positioning the camera accordingly and triggering the actions in question. Covers both the normal AI turn and Reposition phase.
--]]
if FirstLoad then
g_CombatCamAttackStack = {} -- [2n-1] = attacker, [2n] = target; n >= 1
const.CombatCamExplosionDelay = 1500
const.MaxSimultaneousUnits = 5
end
local MinAPToPlay = 2 * const.Scale.AP
MapVar("s_CameraMoveLockReasons", {})
MapVar("g_AITurnContours", {})
MapVar("g_ShowTargetBadge", {})
function LockCameraMovement(reason)
if (next(s_CameraMoveLockReasons) == nil) then
cameraTac.SetLockedMovement(true)
end
s_CameraMoveLockReasons[reason] = true
end
function UnlockCameraMovement(reason, unlock_all)
if unlock_all then
for reason, _ in pairs(s_CameraMoveLockReasons) do
s_CameraMoveLockReasons[reason] = nil
end
else
s_CameraMoveLockReasons[reason] = nil
end
if (next(s_CameraMoveLockReasons) == nil) then
cameraTac.SetLockedMovement(false)
end
end
function AdjustCombatCamera(state, instant, target, floor, sleepTime, noFitCheck)
if not CanYield() then -- In Co-Op DoPointsFitScreen will yield
CreateGameTimeThread(AdjustCombatCamera, state, instant, target, floor, sleepTime, noFitCheck)
return
end
if state == "set" then
if instant then
cameraTac.SetLookAtAngle(40*60)
table.change(hr, "Enemy turn TacCamera Angle", { CameraTacLookAtAngle = 40*60 })
table.change(hr, "Instant Vertical Camera Movement", {CameraTacInterpolatedVerticalMovementTime = 0 })
table.change(hr, "Enemy turn TacCamera Height", { CameraTacHeight = 1500 })
cameraTac.SetForceMaxZoom(true, 0, true)
else
table.change(hr, "Enemy turn TacCamera Height", { CameraTacHeight = 1500 })
table.change(hr, "Enemy turn TacCamera Angle", { CameraTacLookAtAngle = 40*60 })
cameraTac.SetForceMaxZoom(true)
end
if target then
if not floor then
floor = GetStepFloor(target)
end
sleepTime = sleepTime or 1000
if noFitCheck or not DoPointsFitScreen({IsPoint(target) and target or target:GetVisualPos()}, nil, const.Camera.BufferSizeNoCameraMov) then
SnapCameraToObj(target, "force", floor, sleepTime)
end
end
elseif state == "reset" then
hr.CameraTacClampToTerrain = true
if table.changed(hr, "Instant Vertical Camera Movement") then
table.restore(hr, "Instant Vertical Camera Movement")
end
if cameraTac.GetForceMaxZoom() then
cameraTac.SetForceMaxZoom(false)
end
if table.changed(hr, "Enemy turn TacCamera Angle") then
table.restore(hr, "Enemy turn TacCamera Angle")
end
if table.changed(hr, "Enemy turn TacCamera Height") then
table.restore(hr, "Enemy turn TacCamera Height")
end
if target then
if not floor then
floor = GetStepFloor(target)
end
sleepTime = sleepTime or 1000
if noFitCheck or not DoPointsFitScreen({IsPoint(target) and target or target:GetVisualPos()}, nil, const.Camera.BufferSizeNoCameraMov) then
SnapCameraToObj(target, "force", floor, sleepTime)
end
end
end
end
function OnMsg.NewMapLoaded()
cameraTac.SetLockedMovement(false)
g_CombatCamAttackStack = {}
end
local function CombatCam_CheckDeactivate()
if not cameraTac.IsActive() or #g_CombatCamAttackStack > 0 or CurrentActionCamera or IsSetpiecePlaying() then
return
end
UnlockCameraMovement("CombatCamera")
cameraTac.SetForceMaxZoom(false)
end
local CombatCam_ScreenBuffer = 20
local CombatCam_DepthScale = 100
local CombatCam_NetZone = false
local CombatCam_ZoneThread = false
function NetSyncEvents.CalcCameraZone(zone)
CombatCam_NetZone = zone
if IsValidThread(CombatCam_ZoneThread) then --only exists if both clients are calling the func
Msg(CombatCam_ZoneThread)
end
CombatCam_ZoneThread = false
end
function CalcCombatZone(buffer, depth_scale)
buffer = buffer or CombatCam_ScreenBuffer
depth_scale = depth_scale or CombatCam_DepthScale
local w, h = UIL.GetScreenSize():xy()
local x1, y1 = MulDivRound(w, 100 - buffer, 100), MulDivRound(h, 100 - buffer, 100)
local x2, y2 = MulDivRound(w, buffer, 100), MulDivRound(h, 100 - buffer, 100)
local zone = {}
local wx1, wy1 = GetTerrainCursorXY(x1, y1):xy()
local wx2, wy2 = GetTerrainCursorXY(x2, y2):xy()
zone[1] = point(wx1, wy1)
zone[2] = point(wx2, wy2)
local dx, dy = MulDivRound(wy1 - wy2, depth_scale, 100), MulDivRound(wx2 - wx1, depth_scale, 100) -- subtract, scale and apply rotation
local rx, ry = GetTerrainCursorXY(w / 2, MulDivRound(h, buffer, 100)):xy()
local rdx, rdy = rx - wx1, ry - wy1
if dx*rdx + dy*rdy < 0 then
dx, dy = -dx, -dy
end
zone[3] = point(wx2 + dx, wy2 + dy)
zone[4] = point(wx1 + dx, wy1 + dy)
local cx, cy = 0, 0
for i, pos in ipairs(zone) do
local x, y = pos:xy()
cx, cy = cx + x, cy + y
end
zone.center = point(cx / 4, cy / 4)
return zone
end
function NetSyncEvents.TestCalcCombatZone()
local z1, z2
CreateGameTimeThread(function()
z1 = CombatCam_CalcZone()
end)
CreateGameTimeThread(function()
z2 = CombatCam_CalcZone()
end)
print("TestCalcCombatZone", z1, z2)
end
function CombatCam_CalcZone(buffer, depth)
NetUpdateHash("CombatCam_CalcZone")
if not cameraTac.IsActive() and not IsGameReplayRunning() then --gamereplay should get recorded values
assert(not netInGame) --this can cause desyncs, try n catch it when it happens;
return
end
local playingReplay = IsGameReplayRunning()
local recordingReplay = not not GameRecord
if netInGame or playingReplay then
assert(CurrentThread() and IsGameTimeThread())
if not NetIsHost() or playingReplay then
CombatCam_NetZone = false
if not IsValidThread(CombatCam_ZoneThread) then
CombatCam_ZoneThread = CurrentThread()
end
local wokeup = WaitMsg(CombatCam_ZoneThread, 11000)
if CombatCam_NetZone then --if it timeouted something is wrong with the net game, roll with it.
local ret = CombatCam_NetZone
return ret
end
assert(false, "client failed to get host's cam zone")
end
end
local zone = CalcCombatZone(buffer, depth)
if netInGame or recordingReplay then
if NetIsHost() or recordingReplay then
if not IsValidThread(CombatCam_ZoneThread) then
CombatCam_ZoneThread = CurrentThread()
NetSyncEvent("CalcCameraZone", zone)
end
WaitMsg(CombatCam_ZoneThread, 11000)
end
end
return zone
end
function CombatCam_DbgZone(zone)
for i = 1, 4 do
DbgAddVector(zone[i])
end
DbgAddVector(zone.center)
NetUpdateHash("CombatCam_DbgZone", hashParamTable(zone), zone[1], zone[2], zone[3], zone[4])
end
function CountUnitsInZone(x, y, units, zone, return_units)
local cx, cy = zone.center:xy()
local count = 0
local selected = return_units and {} or nil
for _, u in ipairs(units) do
-- offset from current unit -> add to zone center -> check if inside zone
local ux, uy
if IsValid(u) then
ux, uy = u:GetVisualPosXYZ()
else
assert(IsPoint(u))
ux, uy = u:xy()
end
local pos = point(cx + ux - x, cy + uy - y)
if IsPointInsidePoly2D(pos, zone) then
count = count + 1
if selected then
selected[#selected + 1] = u
end
end
end
return count, selected
end
local function CombatCam_RemoveAttacker(unit)
--if unit == g_CombatCamAttackStack[1] then
table.remove(g_CombatCamAttackStack, 1)
table.remove(g_CombatCamAttackStack, 1)
CombatCam_CheckDeactivate()
--end
Msg("CombatCamAttackQueueUpdate")
end
--OnMsg.CombatActionEnd = CombatCam_RemoveAttacker
OnMsg.ActionCameraRemoved = CombatCam_CheckDeactivate
OnMsg.SetpieceDialogClosed = CombatCam_CheckDeactivate
--[[function CombatCam_FailSafeUpdate()
if #g_CombatCamAttackStack > 0 then
local unit = g_CombatCamAttackStack[1]
if not HasCombatActionInProgress(unit) then
CombatCam_RemoveAttacker(unit)
end
end
CombatCam_CheckDeactivate()
end
MapGameTimeRepeat("CombatCam_FailSafe", 100, CombatCam_FailSafeUpdate)]]
local function CombatCam_CalcAttackCamPos(zone, attacker, target)
if not (IsValid(attacker) and attacker:IsValidPos() or IsPoint(attacker)) then
return
end
local lookat = attacker
local target_pos = IsValid(target) and target:IsValidPos() and target:GetVisualPos() or IsPoint(target) and target
if target_pos and target_pos:IsValid() then
local attack_pos = IsValid(attacker) and attacker:GetVisualPos() or attacker
if not target_pos:IsValidZ() then
target_pos = target_pos:SetTerrainZ()
end
local x, y = zone.center:xy()
lookat = (attack_pos + target_pos) / 2
if CountUnitsInZone(x, y, {attack_pos, target_pos}, zone) == 2 then
return
end
end
if IsCloser(zone.center, lookat, 5*guim) then
return
end
return lookat, zone
end
--[[MapVar("g_CombatCamShowAttackLog", false)
function dbgCombatCamAttack(i)
if not g_CombatCamShowAttackLog or not i or #g_CombatCamShowAttackLog < i then
return
end
local item = g_CombatCamShowAttackLog[i]
DbgClearVectors()
DbgAddVector(item.attacker_pos, point(0, 0, 3*guim), const.clrWhite)
DbgAddVector(item.target_pos, point(0, 0, 3*guim), const.clrRed)
local zone = item.zone
for i = 1, 4 do
local ti = 1 + i % 4
DbgAddVector(zone[i]:SetTerrainZ(10*guic), (zone[ti] - zone[i]):SetZ(0), const.clrGreen)
end
end--]]
function CombatCam_ShowAttack(attacker, target)
local zone = CombatCam_CalcZone()
if IsPointInsidePoly2D(attacker, zone) and (not target or IsPointInsidePoly2D(target, zone)) or CurrentActionCamera then
return
end
LockCameraMovement("CombatCamera") -- queued calls will lock multiple times with the same reason (equivalent to single lock) and _Deactivate will only unlock when the queue is empty
-- add in queue
g_CombatCamAttackStack[#g_CombatCamAttackStack + 1] = attacker
g_CombatCamAttackStack[#g_CombatCamAttackStack + 1] = target
-- wait until we're the first item in the queue
while g_CombatCamAttackStack[1] ~= attacker do
WaitMsg("CombatCamAttackQueueUpdate", 100)
end
-- wait until action camera is done
while ActionCameraPlaying do
WaitMsg("ActionCameraRemoved", 100)
end
if not HasCombatActionInProgress(attacker) then
return CombatCam_RemoveAttacker(attacker)
end
local zone = CombatCam_CalcZone()
if not zone then
return
end
local lookat = CombatCam_CalcAttackCamPos(zone, attacker, target)
--[[g_CombatCamShowAttackLog = g_CombatCamShowAttackLog or {}
table.insert(g_CombatCamShowAttackLog, {
zone = zone,
attacker = attacker,
attacker_pos = attacker:GetVisualPos(),
target = target,
target_pos = IsValid(target) and target:GetVisualPos() or target,
})--]]
if not lookat then -- already in camera
return
end
local x, y
if IsValid(lookat) then
x, y = lookat:GetVisualPosXYZ()
else
x, y = lookat:xy()
end
if CountUnitsInZone(x, y, {attacker, target}, zone) < 2 then
cameraTac.SetForceMaxZoom(true)
-- todo: maybe try to fit target in the zone instead
end
local floorAttacker = GetStepFloor(attacker)
local floorTarget = GetStepFloor(target)
floor = Max(floorAttacker, floorTarget)
SnapCameraToObj(lookat, "force", floor)
Sleep(500)
end
MapVar("showAttack", false)
function CombatCam_ShowAttackNew(attacker, target, willBeinterrupted, results, freezeCamPos, changeFloorOnly)
if ActionCameraPlaying then
return
end
--queued calls will lock multiple times with the same reason (equivalent to single lock) and _Deactivate will only unlock when the queue is empty
LockCameraMovement("CombatCamera")
cameraTac.SetForceMaxZoom(false)
cameraTac.SetForceMaxZoom(true)
table.insert(g_CombatCamAttackStack, 1, attacker)
table.insert(g_CombatCamAttackStack, 2, target)
showAttack = showAttack or CreateGameTimeThread(function()
repeat
local attacker = g_CombatCamAttackStack[1]
local target = not IsPoint(g_CombatCamAttackStack[2]) and g_CombatCamAttackStack[2]:GetVisualPos() or g_CombatCamAttackStack[2]
local isTargetUnit = IsKindOf(g_CombatCamAttackStack[2], "Unit") and g_CombatCamAttackStack[2] or false
-- wait until action camera is done
while ActionCameraPlaying do
WaitMsg("ActionCameraRemoved", 100)
end
local floor = GetStepFloor(target)
local pos, look = cameraTac.GetPosLookAt()
local cameraInZone = DoPointsFitScreen({target}, look, const.Camera.BufferSizeNoCameraMov)
if not willBeinterrupted then
-- do not lock to target if interrupt will follow, but go through the other logic
-- to queue the snap camera later when the attack will be executed
if not freezeCamPos then
SnapCameraToObj(cameraInZone and look or target, "force", floor)
elseif changeFloorOnly then
cameraTac.SetFloor(floor, hr.CameraTacInterpolatedMovementTime * 10, hr.CameraTacInterpolatedVerticalMovementTime * 10)
end
else
willBeinterrupted = false
end
ShowBadgesOfTargets(isTargetUnit and {isTargetUnit} or results, "show")
local interrupted = false
local consecutiveAttacks = false
while not g_CombatCamAttackStack[1]:IsIdleCommand() do
if g_CombatCamAttackStack[1] ~= attacker then
interrupted = true
break
elseif g_CombatCamAttackStack[1] == g_CombatCamAttackStack[3] then
table.remove(g_CombatCamAttackStack, 3)
table.remove(g_CombatCamAttackStack, 3)
consecutiveAttacks = true
break
end
Sleep(100)
end
if not interrupted then
ShowBadgesOfTargets(isTargetUnit and {isTargetUnit} or results, "hide")
if not consecutiveAttacks then
CombatCam_RemoveAttacker(attacker)
ClearAITurnContours()
end
end
until #g_CombatCamAttackStack <= 0
showAttack = false
end)
end
function ShowBadgesOfTargets(results, show)
if show == "show" then
for _, obj in ipairs(results.hit_objs or results) do
if IsKindOf(obj, "Unit") and obj.ui_badge then
table.insert(g_ShowTargetBadge, obj)
obj.ui_badge:SetActive(true, "showTarget")
end
end
elseif show == "hide" then
for _, obj in ipairs(results.hit_objs or results) do
if IsKindOf(obj, "Unit") and obj.ui_badge then
local currentTeam = g_Combat and g_Teams[g_Combat.team_playing]
if not currentTeam or currentTeam.control ~= "UI" then
obj.ui_badge:SetActive(false, "showTarget")
else
obj.ui_badge.active_reasons.showTarget = false
end
table.remove(g_ShowTargetBadge, table.find(g_ShowTargetBadge, obj))
end
end
end
end
---------------------------------------
MapVar("g_AIExecutionController", false)
MapVar("g_AIExecutionControllerCamera", false)
function CreateAIExecutionController(obj, testActions)
while g_AIExecutionController do
WaitMsg("ExecutionControllerDeactivate", 500)
end
AIExecutionController:new(obj)
g_AIExecutionController.testAllAttacks = testActions
return g_AIExecutionController
end
DefineClass.AIExecutionController_Camera = {
__parents = { "InitDone" }
}
function AIExecutionController_Camera:Done()
UnlockCameraMovement(self)
end
function AIExecutionController_Camera:SelectObjsInZone(objs, zone)
if not zone or not objs or #objs == 0 then
return
end
local clusters = ClusterUnits(objs)
-- pick cluster closest to current zone
local nearest, ndist
for _, cluster in ipairs(clusters) do
local dist = zone.center:Dist(point(cluster.x, cluster.y))
if not nearest or dist < ndist then
nearest, ndist = cluster, dist
end
end
return nearest and nearest.objs
end
function AIExecutionController_Camera:FitObjsInZone(objs, zone, floor, sleep_time)
if not objs or #objs == 0 then return end
local x, y = zone.center:xy()
local in_zone = CountUnitsInZone(x, y, objs, zone)
floor = floor or HighestFloorOfGroup(objs)
if in_zone < #objs then
local center = IsValid(objs[1]) and objs[1]:GetVisualPos() or objs[1]
for i = 2, #objs do
center = center + (IsValid(objs[i]) and objs[i]:GetVisualPos() or objs[i])
end
center = center / #objs
SnapCameraToObj(center, "force", floor, sleep_time)
if sleep_time then
Sleep(sleep_time)
return true
end
end
return false
end
-- Not sync
function AIExecutionController_Camera:CombatCamCalcZone()
return CalcCombatZone()
end
function AIExecutionController_Camera:ShowUnits(units, wait_time)
assert(CurrentThread())
local pov_team = GetPoVTeam()
LockCameraMovement(self)
local w, h = UIL.GetScreenSize():xy()
local pos, restore_pt = cameraTac.GetPosLookAt()
local restore_floor = cameraTac.GetFloor()
local willMoveCam
while #units > 0 and g_Combat do
local zone = self:CombatCamCalcZone()
if not zone then
break
end
local group = self:SelectObjsInZone(units, zone)
willMoveCam = self:FitObjsInZone(group, zone, g_Teams[g_CurrentTeam].control == "UI" and restore_floor or false, wait_time) or willMoveCam
for _, unit in ipairs(group) do
table.remove_value(units, unit)
pov_team.seen_units = pov_team.seen_units or {}
table.insert(pov_team.seen_units, unit:GetHandle())
end
if g_AIExecutionController and g_AIExecutionController ~= self then
return
end
end
if g_Combat and willMoveCam then
SnapCameraToObj(restore_pt, nil, restore_floor)
Sleep(500)
end
end
DefineClass.AIExecutionController = {
__parents = { "InitDone", "AIExecutionController_Camera" },
label = false,
reposition = false,
restore_camera_obj = false,
claimed_markers = false,
tracked_pois = false,
cinematic_combat_camera = false,
attacker = false,
target = false,
zone = false,
enable_logging = false,
override_notification = false,
override_notification_text = false,
units_playing = false,
start_time = 0,
group_to_follow = false,
track_group = false,
currently_playing = false,
testAllAttacks = false,
fallbackMoveTracking = false,
}
function AIExecutionController:Init()
assert(not g_AIExecutionController)
g_AIExecutionController = self
self.claimed_markers = {}
self.units_playing = {}
Msg("ExecutionControllerActivate")
end
function AIExecutionController:Done()
NetUpdateHash("AIExecutionController_Done")
assert(g_AIExecutionController == self)
UnlockCameraMovement(self, "unlock_all")
if self.restore_camera_obj then
AdjustCombatCamera("reset", nil, self.restore_camera_obj, nil, nil, "noFitCheck")
end
g_AIExecutionController = false
Msg("ExecutionControllerDeactivate")
ObjModified(SelectedObj)
end
function AIExecutionController:IsUnitPlaying(unit)
return self.units_playing[unit]
end
function AIExecutionController:UpdateControlledUnits(units)
local new_units = {}
for _, unit in ipairs(units) do
local should_play = (not unit:IsAware() and unit.pending_aware_state) or (unit.ActionPoints >= MinAPToPlay)
local valid_target = IsValidTarget(unit)
if valid_target and not unit:IsDefeatedVillain() and not unit:IsIncapacitated() and not unit.team.neutral and unit.command ~= "ExitMap" and should_play then
if not self.units_playing[unit] then
self.units_playing[unit] = true
unit:UpdateHighlightMarking()
end
if not unit:IsAware() then
if unit.pending_aware_state == "aware" then
if unit:HasStatusEffect("Suspicious") or unit:HasStatusEffect("Surprised") then
unit:AddStatusEffect("OpeningAttackBonus")
end
unit:RemoveStatusEffect("Suspicious")
unit:RemoveStatusEffect("Unaware")
unit:RemoveStatusEffect("Surprised")
if unit:HasStatusEffect("Unconscious") then
unit.pending_aware_state = nil
else
new_units[#new_units + 1] = unit
end
elseif unit.pending_aware_state == "surprised" then
unit:AddStatusEffect("Surprised")
unit.pending_aware_state = nil
elseif unit.pending_aware_state == "suspicious" then
unit:AddStatusEffect("Suspicious")
unit:RemoveStatusEffect("Unaware")
unit.pending_aware_state = nil
end
elseif unit.pending_aware_state == "reposition" then
new_units[#new_units + 1] = unit
else
unit.pending_aware_state = nil
new_units[#new_units + 1] = unit
end
elseif valid_target then
unit.pending_aware_state = nil
new_units[#new_units + 1] = unit
end
end
return new_units
end
MapVar("g_LastTurnAILog", {})
function AIExecutionController:Log(...)
if self.enable_logging then
local line = string.format(...)
g_LastTurnAILog[#g_LastTurnAILog + 1] = string.format("[AI][%d] %s", GameTime(), line)
end
end
function DelayAfterExplosion()
if g_LastExplosionTime then
NetUpdateHash("DelayAfterExplosion", g_LastExplosionTime, const.CombatCamExplosionDelay)
Sleep(Max(0, g_LastExplosionTime + const.CombatCamExplosionDelay - GameTime()))
end
end
local function FallbackDespawnExitMapUnits()
if not g_AIExecutionController or g_AIExecutionController.start_time + 3000 > GameTime() then
return
end
for _, unit in ipairs(g_Units) do
if unit.command == "ExitMap" then
unit:SetCommand("Despawn")
end
end
end
if FirstLoad then
mp_resolution_results = false
end
function NetSyncEvents.GetResolution(player_id, res)
mp_resolution_results = mp_resolution_results or {}
mp_resolution_results[player_id] = res
Msg("ResUpdated")
end
function Mp_SetUserRes(res)
CreateRealTimeThread(function()
if GameState.sync_loading then
WaitMsg("SyncLoadingDone") --dont do this while changing maps n such
end
mp_resolution_results = mp_resolution_results or {}
NetSyncEvent("GetResolution", netUniqueId, res)
local ok = WaitMsg("ResUpdated", 5 * 1000)
if not ok then
assert("Failed to update res table for MP.")
end
end)
end
function OnMsg.SystemSize(res)
if netInGame then
Mp_SetUserRes(res)
end
end
function OnMsg.NetGameJoined()
Mp_SetUserRes(UIL.GetScreenSize())
end
function OnMsg.NetGameLeft()
mp_resolution_results = false
end
function NetSyncEvents.Mp_DoPointsFitScreen(res)
Msg("DoesFitScreen", res)
end
function Mp_PickSmallerPlayingField(choices)
--Based on w/h ration choose the bigger one as it often means smaller gameplay area shown
local player1Ratio = MulDivRound(mp_resolution_results[1]:x(), 10000, mp_resolution_results[1]:y())
local player2Ratio =MulDivRound(mp_resolution_results[2]:x(), 10000, mp_resolution_results[2]:y())
return player1Ratio > player2Ratio and choices[1] or choices[2]
end
function DoPointsFitScreen(points, screenCenterPos, screenBufferPerc)
NetUpdateHash("DoPointsFitScreen")
if not cameraTac.IsActive() and not IsGameReplayRunning() then
assert(not netInGame)
return
end
local playingReplay = IsGameReplayRunning()
local recordingReplay = not not GameRecord
if netInGame and NetIsHost() and table.count(netGamePlayers) == 2 and (not mp_resolution_results or #mp_resolution_results ~= 2) then
assert(false, "[DoPointsFitScreen] Failed to get both players resolutions.")
return
end
if netInGame and not NetIsHost() or playingReplay then
local ok, res = WaitMsg("DoesFitScreen", 5 * 1000)
if not ok then
assert(false, "[DoPointsFitScreen] Failed to receive result from host.")
return
end
return res
end
local doesFit = true
local smallerResolution = table.count(netGamePlayers) == 2 and Mp_PickSmallerPlayingField(mp_resolution_results) or UIL.GetScreenSize()
local screenSize = smallerResolution
local screenBufferW = screenBufferPerc and MulDivRound(screenSize:x(), screenBufferPerc, 100) or 0
local screenBufferH = screenBufferPerc and MulDivRound(screenSize:y(), screenBufferPerc, 100) or 0
local bufferedScreenMinPoint = point(screenBufferW, screenBufferH)
local bufferedScreenMaxPoint = smallerResolution - point(screenBufferW, screenBufferH)
local safeArea = box(bufferedScreenMinPoint, bufferedScreenMaxPoint)
local ptCamera, ptCameraLookAt = GetCameraPosLookAtOnPos(screenCenterPos)
local pointsPosOnScreen = { GameToScreenFromView(ptCamera, ptCameraLookAt, screenSize:x(), screenSize:y(), table.unpack(points)) }
for _, scrnPoint in pairs(pointsPosOnScreen) do
if not safeArea:Point2DInside(scrnPoint) then
doesFit = false
break
end
end
if not next(pointsPosOnScreen) then
doesFit = false
end
if netInGame and NetIsHost() and table.count(netGamePlayers) == 2 or recordingReplay then
NetSyncEvent("Mp_DoPointsFitScreen", doesFit)
local ok, res = WaitMsg("DoesFitScreen", 5 * 1000)
if not ok then
assert(false, "[DoPointsFitScreen] Failed to send result to client.")
return
end
return res
end
return doesFit
end
local MoveAndAttack = { RunAndGun = true, MobileShot = true, Charge = true, HyenaCharge = true }
local AOE_keywords = { "Soldier", "Control", "Explosives", "Ordnance" }
local AOE_archetypes = { "Artillery" }
local function UnitAoeChance(unit)
local ai_context = unit.ai_context
local aoe_chance = 0
for _, keyword in ipairs(unit.AIKeywords) do
if table.find(AOE_keywords, keyword) then
aoe_chance = aoe_chance + 100
end
end
if table.find(AOE_archetypes, ai_context.archetype.id) then
aoe_chance = aoe_chance + 100
end
return Clamp(aoe_chance, 0, 100)
end
local function __AIExecutionControllerExecute(self, units, reposition, played_units)
assert(CurrentThread())
if not g_Combat then return end
local pov_team = GetPoVTeam()
local max_sight_radius = MulDivRound(const.Combat.AwareSightRange, const.SlabSizeX * const.Combat.SightModMaxValue, 100)
self.start_time = GameTime()
DelayAfterExplosion()
ObjModified(g_Combat) -- update ui
LockCameraMovement(self)
g_AIDestEnemyLOSCache = {}
g_AIDestIndoorsCache = {}
if self.enable_logging then
g_LastTurnAILog = {}
end
if self.override_notification then
ShowTacticalNotification(self.override_notification, true, self.override_notification_text)
end
--repo and turn notifications need to be neutral based on the allyInUnits flag
local function FindAllyInUnits(units)
for _, unit in ipairs(units) do
if unit.team.side == "ally" or unit.team.player_team then
return true
end
end
return false
end
local allyInUnits = FindAllyInUnits(units)
local moveAttackException --flag to keep check if some unit action will stop the cinematic camera trigger for this group
local hiddenTurnShowMercs --flag to show once mercs during hidden turn (only the first time it happens it is possible the camere to not be showing anything of interest)
-- start of turn
if not self.reposition then
-- StartAI on all aware units in 'units' since it is needed for AIGetNextPhaseUnits; only applies to normal turn
if not self.override_notification then
if allyInUnits then
ShowTacticalNotification("allyTurnPhase")
else
ShowTacticalNotification("enemyTurnPhase")
end
end
for _, unit in ipairs(units) do
if not unit:IsIncapacitated() and unit:IsAware() and unit.ActionPoints > 0 then
unit:StartAI() -- this can indirectly sleep internally in AIUpdateDestLosCache
table.insert_unique(played_units, unit)
end
end
if not self.override_notification then
if allyInUnits then
HideTacticalNotification("allyTurnPhase")
else
HideTacticalNotification("enemyTurnPhase")
end
end
end
self:Log("Start turn execution (%d units)", #units)
local awareness_anims_played
local to_play = {}
local engaged = false
if #units > 0 and g_Combat and netInGame then
--sync camera for both clients before using it to determine zones
local closestUnit = false
local closestDist = max_int
for _, unit in ipairs(pov_team.units) do
for i = 1, #units do
local otherUnit = units[i]
if otherUnit == unit then
closestUnit = unit
goto continue
elseif not closestUnit or IsCloser(unit, otherUnit, closestUnit) then
closestUnit = unit
end
end
end
::continue::
if closestUnit then
SnapCameraToObj(closestUnit, nil, nil, 1000)
NetUpdateHash("SnapCameraToObj", closestUnit)
end
end
while #units > 0 and g_Combat do --units contain all the units to be played by the execution controller
if self.reposition and not g_Combat.enemies_engaged then
local engage = true
if self.label == "AlwaysReady" then
-- check if 'units' contain anyone other than 'activator'
engage = false
for _, unit in ipairs(units) do
engage = engage or (unit ~= self.activator)
end
end
if engage then
g_Combat.enemies_engaged = true
engaged = true
Msg("RepositionStart")
end
end
-- preprocess units: remove dead/defeated, update awareness
units = self:UpdateControlledUnits(units)
-- also check remaining units in to_play
for i = #to_play, 1, -1 do
local unit = to_play[i]
if not IsValidTarget(unit) or unit:IsDefeatedVillain() or unit.command ~= "Die" or unit.command == "ExitMap" or unit.ActionPoints < MinAPToPlay then
table.remove(to_play, i)
end
end
self:Log("Processing %d units...", #units)
-- select a group of units to play
local zone = CombatCam_CalcZone()
--in multiplayer or in replay recording/playing we are going to wait for zone to arrive through netsync ev;
--sometimes when playing a recording the netsync thread may not execute in the correct order;
--this sleep shifts it to next game ms for that purpose;
Sleep(1)
NetUpdateHash("CombatCam_CalcZone_Done")
local playing
if #to_play > 0 then
playing = to_play
else
playing = self:SelectPlayingUnits(units, zone) or empty_table --get all units that will move together based on the combat zone picked by nearest unit
end
to_play = {}
--local playing = table.icopy(units)
self:Log("%d units selected", #playing)
if #playing == 0 then
break
end
--used for marking only the currently moving/performing actions units
self.currently_playing = playing
local units_repositioning = self.reposition or not not playing[1].pending_aware_state
if Platform.developer then
-- either all units should be repositioning or none of them
for i = 2, #playing do
assert((not not playing[i].pending_aware_state) == units_repositioning)
end
end
-- preparation & tracking of visible positions/destinations, reveal units ending up on a visible destination
local pois = {}
local max_dest_floor = -1
local cinematicUnits = {}
for playing_idx, unit in ipairs(playing) do
local dest
if not g_Combat then break end
if units_repositioning then
if g_Combat and ((self.label == "AlwaysReady" and unit == self.activator) or not g_Combat:IsRepositioned(unit)) then
unit.ActionPoints = MulDivRound(unit:GetMaxActionPoints(), const.Combat.RepositionAPPercent, 100)
if unit:HasStatusEffect("FreeReposition") then
unit.free_move_ap = unit.free_move_ap + 999999
unit.ActionPoints = unit.ActionPoints + 999999
end
unit:StartAI()
if not g_Combat or unit:IsIncapacitated() then break end
table.insert_unique(played_units, unit)
if self.label ~= "AlwaysReady" or unit ~= self.activator then
unit:PickRepositionDest()
end
end
dest = unit.reposition_dest -- can be ai_context.ai_destination or a dest from a reposition marker
if unit.reposition_marker then
self.claimed_markers[#self.claimed_markers] = unit.reposition_marker
end
if unit.pending_aware_state == "reposition" then
unit.pending_aware_state = nil
end
self:Log(" Unit %s (%d) reposition dest: %d (%s)", unit.unitdatadef_id, unit.handle, dest, unit.reposition_marker and "marker" or "no marker")
assert(not dest or CanOccupy(unit, stance_pos_unpack(dest)))
else
assert(unit.ai_context and unit.ai_context.behavior)
unit.ai_context.behavior:Think(unit)
-- debug code: check same destination
if playing_idx > 1 then
local dest = unit.ai_context.ai_destination
local occupied = dest and point(stance_pos_unpack(dest)) or GetPassSlab(unit) or SnapToVoxel(unit)
for k = 1, playing_idx - 1 do
local unit2 = playing[k]
local dest2 = unit2.ai_context.ai_destination
local occupied2 = dest2 and point(stance_pos_unpack(dest2)) or GetPassSlab(unit2) or SnapToVoxel(unit2)
if occupied == occupied2 then
printf('Occupied ai_destination %s. AI behaviors: %s, %s', tostring(occupied), unit.ai_context.behavior.class, unit2.ai_context.behavior.class)
assert(false, "occupied ai_destination!!!")
for j = 1, 20 do
unit.ai_context.behavior:Think(unit)
end
end
end
end
if not g_Combat then break end
unit.ai_context.behavior:TakeStance(unit)
if not g_Combat then break end
dest = unit.ai_context.ai_destination
local willMove = unit.ai_context.ai_destination and (stance_pos_dist(unit.ai_context.ai_destination, stance_pos_pack(unit)) ~= 0)
if willMove then
local currPos = unit:GetVisualPos()
local destPost = point(stance_pos_unpack(unit.ai_context.ai_destination))
willMove = currPos:Dist(destPost) > const.Camera.MinTrackDistance
end
local isTargetUnit = IsKindOf(unit.ai_context.dest_target[unit.ai_context.ai_destination], "Unit")
local target = isTargetUnit and unit.ai_context.dest_target[unit.ai_context.ai_destination]
local middlePoint = target and (point(stance_pos_unpack(unit.ai_context.ai_destination)) + target:GetVisualPos()) / 2
local hasAp = not unit.ai_context.dest_ap[unit.ai_context.ai_destination] or unit.ai_context.dest_ap[unit.ai_context.ai_destination] >= unit.ai_context.default_attack_cost
local willFit = middlePoint and DoPointsFitScreen({ target:GetVisualPos(), point(stance_pos_unpack(unit.ai_context.ai_destination)) },
middlePoint,
10)
local attack_action = unit:GetDefaultAttackAction(false, true)
local interrupts = unit:CheckProvokeOpportunityAttacks(attack_action, "attack interrupt", {unit.target_dummy or unit})
moveAttackException = moveAttackException or unit.ai_context and unit.ai_context.movement_action and MoveAndAttack[unit.ai_context.movement_action.action_id] or MoveAndAttack[unit.action_command]
if not self.testAllAttacks and isTargetUnit and hasAp and willMove and willFit and not interrupts and not g_Combat:GetEmplacementAssignment(unit) and target.visible then
local aoe_chance = UnitAoeChance(unit)
if aoe_chance ~= 100 then
cinematicUnits[unit.handle] = aoe_chance
table.insert(cinematicUnits, unit)
end
end
self:Log(" Unit %s (%d) (archetype: %s, behavior: %s) dest: %s", unit.unitdatadef_id, unit.handle, unit.current_archetype, unit.ai_context.behavior:GetEditorView(), tostring(dest))
assert(not dest or CanOccupy(unit, stance_pos_unpack(dest)))
end
if HasVisibilityTo(pov_team, unit) then
pois[#pois + 1] = unit
end
if dest then
local rx, ry, rz, rs = stance_pos_unpack(dest)
unit:ClearEnumFlags(const.efResting)
assert(CanDestlock(unit, rx, ry, rz or const.InvalidZ, nil, false))
PlaceDestlock(unit, rx, ry, rz)
local step_pos = point(rx, ry, rz)
local willReveal = RevealUnitBeforeMove(unit, {goto_pos = step_pos, goto_stance = rs})
if willReveal then
pois[#pois + 1] = unit
end
max_dest_floor = Max(max_dest_floor, GetFloorOfPos(step_pos:xyz()))
--[[local volume = EnumVolumes(step_pos, "smallest")
if volume then
local floor = GetFloorOfPos(step_pos:xyz())
max_dest_floor = Max(max_dest_floor, floor)
end--]]
end
max_dest_floor = Max(max_dest_floor, GetStepFloor(unit))
end
-- destroy destlocks and apply efResting (before starting movement)
for i = #playing, 1, -1 do
playing[i]:ClearPath()
end
-- Remove action camera if on.
assert(netInGame or not not ActionCameraPlaying == not not CurrentActionCamera)
if ActionCameraPlaying or CurrentActionCamera then
RemoveActionCamera(true)
if ActionCameraPlaying then
WaitMsg("ActionCameraRemoved", 5000)
end
end
local cinematicUnit
for _, unit in ipairs(cinematicUnits) do
local aoe_chance = cinematicUnits[unit.handle]
if cinematicUnit and cinematicUnits[cinematicUnit.handle] > aoe_chance or not cinematicUnit then
cinematicUnit = unit
end
end
if cinematicUnit and not moveAttackException then
StartCinematicCombatCamera(cinematicUnit, cinematicUnit.ai_context.dest_target[cinematicUnit.ai_context.ai_destination])
end
-- move camera if needed, update tactical notifications
local sleep_t = 500
local did_sleep = false
if #pois > 0 then
local floor
if max_dest_floor > -1 then
floor = Clamp(max_dest_floor, hr.CameraTacMinFloor, hr.CameraTacMaxFloor)
end
--did_sleep = CenterCameraOnObj(pois, floor, sleep_t)
if not self.override_notification then
HideTacticalNotification("turn")
if FindAllyInUnits(pois) then
ShowTacticalNotification(units_repositioning and "allyRepositionPhase" or "allyTurnPhase", true)
else
ShowTacticalNotification(units_repositioning and "enemyRepositionPhase" or "enemyTurnPhase", true)
end
end
else
if not self.override_notification then
HideTacticalNotification("turn")
if FindAllyInUnits(playing) then
ShowTacticalNotification(units_repositioning and "allyHiddenRepoPhase" or "allyHiddenTurnPhase", true)
else
ShowTacticalNotification(units_repositioning and "hiddenEnemyRepoPhase" or "hiddenEnemyTurnPhase", true)
end
end
end
if IsCompetitiveGame() and not did_sleep then
Sleep(sleep_t) --sync with other client combatcam, who may or may not have slept
end
if not IsCompetitiveGame() then
NetUpdateHash("__AIExecutionControllerExecute_playing", hashParamTable(playing))
end
self.zone = CombatCam_CalcZone()
local attacker, mover
if (not pois or #pois <= 0) and not hiddenTurnShowMercs then
local selected = self:SelectObjsInZone(pov_team.units, self.zone)
local closestMerc = false
local center = self.zone.center
for _, merc in ipairs(selected) do
if not closestMerc or IsCloser(center, merc, closestMerc) then
closestMerc = merc
hiddenTurnShowMercs = true
end
end
AdjustCombatCamera("set", nil, closestMerc)
else
AdjustCombatCamera("set")
end
Sleep(500)
-- start movement (parallel)
for i, unit in ipairs(playing) do
if not g_AITurnContours[unit.handle] then
local enemy = unit.team.side == "enemy1" or unit.team.side == "enemy2" or unit.team.side == "neutralEnemy"
g_AITurnContours[unit.handle] = SpawnUnitContour(unit, enemy and "CombatEnemy" or "CombatAlly")
ShowBadgeOfAttacker(unit, true)
end
local result = "continue"
self:Log(" Unit %s (%d) movement start", unit.unitdatadef_id, unit.handle)
if units_repositioning then
if awareness_anims_played then
unit.pending_awareness_role = nil
end
if table.find(pois, unit) and not self.cinematic_combat_camera then
g_AIExecutionController.tracked_pois = g_AIExecutionController.tracked_pois or {}
table.insert(g_AIExecutionController.tracked_pois, unit)
end
StartCombatAction("Reposition", unit, 0)
elseif unit:HasStatusEffect("ManningEmplacement") and unit:GetArchetype() ~= Archetypes.EmplacementGunner then
-- leave emplacement and restart
AIPlayCombatAction("MGLeave", unit, 0)
result = "restart"
elseif unit.ai_context.ai_destination then
local unitAIinfo = unit.ai_context
local lastStanding = IsLastUnitInTeam(unit.team.units)
local willMove = stance_pos_dist(unitAIinfo.ai_destination, stance_pos_pack(unit)) ~= 0
local isTargetUnit = IsKindOf(unitAIinfo.dest_target[unitAIinfo.ai_destination], "Unit")
local hasAp = not unitAIinfo.dest_ap[unitAIinfo.ai_destination] or unitAIinfo.dest_ap[unitAIinfo.ai_destination] >= unitAIinfo.default_attack_cost
if not attacker and willMove and isTargetUnit and hasAp and not lastStanding then
attacker = unit
elseif not mover and willMove and not isTargetUnit and not lastStanding then
mover = unit
end
local trackPos = table.find(pois, unit)
local trackMove
if willMove and not self.cinematic_combat_camera and trackPos then
trackMove = true
end
result = unit.ai_context.behavior:BeginMovement(unit, trackMove)
end
if result ~= "continue" then
self:Log(" Execution interrupted: %s", result or "false")
-- the movement was interrupted, break execution for all remaining units
local limit = (result == "restart") and i or (i + 1)
for j = #playing, limit, -1 do
to_play[#to_play + 1] = playing[j]--store all units that were paused from playing because of an interruption
playing[j] = nil
end
break
end
end
if attacker then
PlayVoiceResponseGroup(attacker, "AIStartingTurnAttack")
elseif mover then
PlayVoiceResponse(mover, "AIStartingTurnMoving")
end
-- wait movement to resolve
assert(self.zone)
WaitAllCombatActionsEnd()
WaitUnitsInIdle(nil, FallbackDespawnExitMapUnits) -- wait other commands to end (dying, opportunity attacks)
self.tracked_pois = nil -- stop tracking
self.group_to_follow = nil
self.track_group = nil
self.zone = nil -- seems like it is not used for anything
awareness_anims_played = true -- only play these one per reposition phase
self:Log("Movement phase finished (%d units playing)", #playing)
ClearAITurnContours()
WaitActionCamDonePlayingSync()
-- post-movement update before starting over
for _, unit in ipairs(playing) do
-- remove all scouted locations first in case any of the behaviors/actions causes a restart
if unit.ai_context then -- might be dead
unit.ai_context.behavior:EndMovement(unit)
AIUpdateScoutLocation(unit)
end
end
local end_combat
--for i, unit in ipairs(playing) do
while #(playing or empty_table) > 0 and g_Combat do
-- select unit that would cause minimal camera movement (pos + target)
local unit
if cinematicUnit then
unit = cinematicUnit
cinematicUnit = false
else
unit = PickClosestUnit(playing)
end
if unit then
table.remove_value(playing, unit)
else
-- no valid unit was found
table.iclear(playing)
end
if IsValid(unit) and not unit:IsDead() then
unit.pending_aware_state = nil
if units_repositioning then
StartCombatAction("RepositionOpeningAttack", unit, 0)
WaitCombatActionsEnd(unit)
ClearAITurnContours()
while ActionCameraPlaying do
WaitMsg("ActionCameraRemoved", 100)
end
table.remove_value(units, unit)
else
local status = AIExecuteUnitBehavior(unit, self.testAllAttacks)
if status ~= "restart" then
table.remove_value(units, unit)
else
-- break the execution to restart it starting with current unit
-- note: if there's more processing of 'playing' below at some time, entries starting with current one need to be
-- removed from it (see above at BeginMovement)
table.iappend(to_play, playing)
break
end
end
-- check for early end combat, abort
if not g_Combat or g_Combat:ShouldEndCombat() then
end_combat = true
break
end
Sleep(500)
end
end
if end_combat then
break
end
-- update 'units' with newly alerted ones (pending_aware_state) from all in g_Units (other teams can reposition during AI turn)
for _, unit in ipairs(g_Units) do
if not unit:IsDead() and not unit:IsAware() and unit.pending_aware_state == "aware" then
if not self.reposition and unit.team == g_Teams[g_CurrentTeam] then
-- units from the currently playing team do not get a Reposition, they get a normal turn instead
unit:RemoveStatusEffect("Unaware")
unit:RemoveStatusEffect("Surprised")
unit:RemoveStatusEffect("Suspicious")
unit.pending_aware_state = nil
unit:StartAI()
table.insert_unique(played_units, unit)
end
table.insert_unique(units, unit)
--update flag for combat notifications
allyInUnits = FindAllyInUnits(units)
end
end
end
ObjModified(g_Combat) -- update ui
-- end of turn
if self.override_notification then
HideTacticalNotification(self.override_notification)
else
HideTacticalNotification("turn")
end
-- release claimed markers
for _, marker in ipairs(self.claimed_markers) do
g_RepositionMarkersClaimed[marker] = nil
end
if self.reposition and engaged then
Msg("RepositionEnd")
if g_Combat and not g_Combat.start_reposition_ended then
g_Combat.start_reposition_ended = true
Msg("CombatStartRepositionDone")
end
end
self:Log("Execution finished")
if g_Combat then
g_Combat:EndCombatCheck()
end
end
-- These changes can sometimes be left over when loading a save during enemy turn and stuff like that
function OnMsg.EnterSector()
table.restore(hr, "Enemy turn TacCamera Angle", true)
table.restore(hr, "Enemy turn TacCamera Height", true)
end
MapVar("g_UnawareQueue", {})
function AIExecutionController:Execute(units)
local is_player_control = g_Combat and g_Combat.is_player_control
if is_player_control then
g_Combat:SetPlayerControl(false)
end
local played_units = {}
g_LastUnitToShoot = false
g_UnawareQueue = {}
sprocall(__AIExecutionControllerExecute, self, units, nil, played_units)
if g_Encounter then
g_Encounter:FinalizeTurn()
end
for _, unit in ipairs(played_units) do
unit.ai_context = nil
end
if is_player_control then
g_Combat:SetPlayerControl(true)
end
local check
for _, unit in ipairs(g_UnawareQueue) do
unit:AddStatusEffect("Unaware")
check = true
end
g_LastUnitToShoot = false
if check and g_Combat then
g_Combat:EndCombatCheck()
end
end
function AIExecutionController:SelectPlayingUnits(units, zone)
local reposition_units = table.ifilter(units, function(idx, unit) return unit.pending_aware_state == "aware" or unit.pending_aware_state == "reposition" or unit == self.activator end)
if #reposition_units > 0 then
-- in reposition phase only care about repositioning units, in ai turn phase prioritize them if there are any
units = reposition_units
else -- return to normal turn phase selection logic when in ai turn phase and nobody is repositioning
units = table.ifilter(units, function(idx, unit) return unit:IsAware() and unit.ActionPoints >= MinAPToPlay and not unit:GetBandageTarget() end)
units = AIGetNextPhaseUnits(units)
end
--filter playing units by side
local side = next(units) and units[1].team.side
--filter playing units by floor
local minFloor
for _, unit in ipairs(units) do
local unitFloor = GetStepFloor(unit)
if not minFloor or minFloor > unitFloor then
minFloor = unitFloor
end
end
local selected = table.copy(units or empty_table)
selected = table.ifilter(selected, function(idx, unit)
local unitFloor = GetStepFloor(unit)
return unit.team.side == side and unitFloor == minFloor
end)
selected = self:SelectObjsInZone(selected, zone)
--filter by being interrupted
if #reposition_units <= 0 then
local interruptedGroup = false
for idx, unit in ipairs(selected) do
local pathDummies = unit:GenerateTargetDummiesFromPath(unit.ai_context.dest_combat_path)
local interrupted = unit:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", pathDummies)
if interrupted and idx == 1 then
interruptedGroup = true
end
if (not not interruptedGroup) ~= (not not interrupted) then
table.remove(selected, idx)
end
end
end
while #(selected or empty_table) > const.MaxSimultaneousUnits do
table.remove(selected)
end
return selected
end
function CountUnitsInArea(x, y, objs, r)
local group = {}
for _, obj in ipairs(objs) do
local ox, oy
if IsValid(obj) then
ox, oy = obj:GetVisualPosXYZ()
else
ox, oy = obj:xy()
end
if IsCloser2D(x, y, ox, oy, r) then
group[#group + 1] = obj
end
end
return #group, group
end
function ClusterUnits(objs)
objs = objs or g_Units
local r = 0
r = 10*guim
local clusters = {}
for _, obj in ipairs(objs) do
local x, y
if IsValid(obj) then
x, y = obj:GetVisualPosXYZ()
else
assert(IsPoint(obj))
x, y = obj:xy()
end
local cluster = { x = x, y = y }
clusters[#clusters + 1] = cluster
cluster.count, cluster.objs = CountUnitsInArea(cluster.x, cluster.y, objs, r)
end
for idx, cluster in ipairs(clusters) do
repeat
local cx, cy = cluster.x, cluster.y
local count, next_potential_objs = CountUnitsInArea(cx, cy, objs, 2*r)
if count > cluster.count then
-- try moving to the new midpoint and see if we lose some of the existing objects with our normal radius
local x, y = midpoint(next_potential_objs)
local next_count, next_objs = CountUnitsInArea(x, y, objs, r)
local lost
for _, obj in ipairs(cluster.objs) do
lost = lost or not table.find(next_objs, obj)
end
if not lost then
cluster.x, cluster.y = x, y
cluster.count = next_count
cluster.objs = next_objs
end
end
local change = cx ~= cluster.x or cy ~= cluster.y
until not change
end
-- sort by size
table.sortby_field_descending(clusters, "count")
-- go over objs, find the largest cluster they belong to and remove them from all the others
for _, obj in ipairs(objs) do
local cluster_idx
for i, cluster in ipairs(clusters) do
if table.find(cluster.objs, obj) then
cluster_idx = i
break
end
end
for j = cluster_idx + 1, #clusters do
table.remove_value(clusters[j].objs, obj)
end
end
for i = #clusters, 1, -1 do
clusters[i].count = #clusters[i].objs
if clusters[i].count == 0 then
table.remove(clusters, i)
end
end
return clusters
end
function midpoint(objs)
local cx, cy, cz = 0, 0, 0
for _, obj in ipairs(objs) do
local x, y, z
if IsValid(obj) then
x, y, z = obj:GetVisualPosXYZ()
else
assert(IsPoint(obj))
x, y, z = obj:xyz()
end
cx, cy, cz = cx + x, cy + y, cz + (z or terrain.GetHeight(x, y))
end
if #objs > 0 then
cx, cy, cz = cx / #objs, cy / #objs, cz / #objs
end
return cx, cy, cz
end
-- Sync version
function AIExecutionController:CombatCamCalcZone()
return CombatCam_CalcZone()
end
function AIExecutionController:ShowUnits(units, wait_time)
WaitActionCamDonePlayingSync()
return AIExecutionController_Camera.ShowUnits(self, units, wait_time)
end
function CenterCameraOnObj(objs, floor, sleep_time)
if not objs or #objs == 0 then return end
local center = IsValid(objs[1]) and objs[1]:GetVisualPos() or objs[1]
for i = 2, #objs do
center = center + (IsValid(objs[i]) and objs[i]:GetVisualPos() or objs[i])
end
center = center / #objs
AdjustCombatCamera("set", nil, center, floor, sleep_time, "NoFitCheck")
if sleep_time then
Sleep(sleep_time)
return true
end
return false
end
function StartCinematicCombatCamera(attacker, target)
local isNear = DoPointsFitScreen({attacker:GetVisualPos()}, nil, const.Camera.BufferSizeNoCameraMov)
local floor = GetStepFloor(attacker)
AdjustCombatCamera("set", nil, not isNear and attacker, floor, not isNear and 1000 or 0)
Sleep(not isNear and 1000 or 500)
AILockTarget(attacker)
g_AIExecutionController.cinematic_combat_camera = true
g_AIExecutionController.attacker = attacker
g_AIExecutionController.target = target
end
function StopCinematicCombatCamera()
if IsCinematicCCPlaying() then
Sleep(1000)
local attacker = g_AIExecutionController.attacker
g_AIExecutionController.cinematic_combat_camera = false
g_AIExecutionController.attacker = false
g_AIExecutionController.target = false
return true, attacker
else
return false
end
end
function IsCinematicCCPlaying()
return g_AIExecutionController and g_AIExecutionController.cinematic_combat_camera
end
local function AICinematicCombatCamera()
if not g_AIExecutionController or g_AIExecutionController.tracked_pois
or not g_AIExecutionController.cinematic_combat_camera
or not g_AIExecutionController.attacker
or not g_AIExecutionController.target then
return
end
local midPointX, midPointY, midPointZ = midpoint({g_AIExecutionController.attacker, g_AIExecutionController.target})
--maybe use the floor of the closer unit to the camera's current pos
local floor = GetStepFloor(g_AIExecutionController.target)
SnapCameraToObj(point(midPointX, midPointY, midPointZ), "force", floor, 5000, "none")
end
DefineConstInt("Camera", "MinTrackDistance", 3, "voxelSizeX", "The minimum distance (in slabs) required to active the tracking camera, else it will lock to init pos once. Also used for cinematic unit cond.")
local function AIExecutionTrackUnits()
if not g_AIExecutionController or not g_AIExecutionController.tracked_pois or
#g_AIExecutionController.tracked_pois == 0 or #g_CombatCamAttackStack > 1 then
return
end
if ActionCameraPlaying then
return
end
g_AIExecutionController.tracked_pois = table.ifilter(g_AIExecutionController.tracked_pois, function(idx, poi) return not IsKindOf(poi, "Unit") or HasCombatActionInProgress(poi) end)
--If the followed group is not determined, run cluster algroithm on destination.
--This will populate the groupToFollow with units that will have similar final destination.
if not g_AIExecutionController.group_to_follow or #g_AIExecutionController.group_to_follow == 0 then
g_AIExecutionController.group_to_follow = {}
g_AIExecutionController.track_group = false
local destPoints = {}
for _, unit in ipairs(g_AIExecutionController.tracked_pois) do
local unitFinalDestination = unit.ai_context.ai_destination or unit.reposition_dest --pick the reposition dest if unit is making reposition
if unitFinalDestination then
local x, y, z = stance_pos_unpack(unitFinalDestination)
local pt = point(x, y, z)
table.insert(destPoints, pt)
destPoints[pt] = unit
end
end
local clusters = ClusterUnits(destPoints)
table.sortby_field_descending(clusters, "count")
local bestClusterOfDest = clusters[1]
local objsInCluster = bestClusterOfDest and bestClusterOfDest.objs or {}
for _, pt in ipairs(objsInCluster) do
table.insert(g_AIExecutionController.group_to_follow, destPoints[pt])
end
--only track group that will move x distance, otherwise just snap once to its center
if #destPoints > 0 and GetDistGroupInitAndDestPoint(destPoints) > const.Camera.MinTrackDistance then
g_AIExecutionController.track_group = true
end
if not g_AIExecutionController.track_group and next(g_AIExecutionController.group_to_follow) then
if not DoPointsFitScreen({unpack_params(objsInCluster)}, nil, const.Camera.BufferSizeNoCameraMov) then
CenterCameraOnObj(g_AIExecutionController.group_to_follow, HighestFloorOfGroup(g_AIExecutionController.group_to_follow), 500)
end
end
end
if not g_AIExecutionController or not next(g_AIExecutionController.group_to_follow) then
--for some reason there is no group to follow
return
end
--Check if the currently followed group by the camera is too far apart.
local trackedUnitsClusters = ClusterUnits(g_AIExecutionController.group_to_follow)
--Pick the group/cluster with more units in it to be the one the camera will track.
--This might happen very rarely. Most cases will be only one cluster.
local biggestCluster
for _, cluster in ipairs(trackedUnitsClusters) do
if not biggestCluster or biggestCluster.count < cluster.count then
biggestCluster = cluster
end
end
local maxFloor = HighestFloorOfGroup(biggestCluster.objs)
--Track the groupToFollow by moving the camera in the midpoint of the group.
if biggestCluster and g_AIExecutionController.track_group then
CenterCameraOnObj(biggestCluster.objs, maxFloor)
end
end
local function TrackMeleeCharge()
if not g_TrackingChargeAttacker or not g_AIExecutionController then
return
end
if IsCinematicCCPlaying() or ActionCameraPlaying then
return
end
if gv_DebugMeleeCharge then
print("tracking melee charge attacker")
end
local floor = GetStepFloor(g_TrackingChargeAttacker)
SnapCameraToObj(g_TrackingChargeAttacker:GetVisualPos(), "force", floor)
end
MapGameTimeRepeat("AIExecutionTracking", 50, AIExecutionTrackUnits)
MapGameTimeRepeat("AICinematicCombat", 50, AICinematicCombatCamera)
MapGameTimeRepeat("AITrackMeleeCharge", 50, TrackMeleeCharge)
MapVar("s_EnemySightedQueue", {})
local function CheckEnemySightedQueue()
if #s_EnemySightedQueue == 0 then return end
if (next(CombatActions_RunningState) ~= nil) or MoveAndAttackSyncState == 1 then
return
end
if ActionCameraPlaying or g_AIExecutionController then
s_EnemySightedQueue = {}
return
end
local igi = GetInGameInterfaceModeDlg()
if not IsKindOfClasses(igi, "IModeCombatMovement", "IModeExploration") then
s_EnemySightedQueue = {}
return
end
g_AIExecutionControllerCamera = AIExecutionController_Camera:new()
CreateGameTimeThread(function(controller)
local units = s_EnemySightedQueue
s_EnemySightedQueue = {}
controller:ShowUnits(units, 1500)
DoneObject(controller)
end, g_AIExecutionControllerCamera)
end
function OnMsg.EnemySighted(team, enemy)
if GameState.sync_loading then return end
if g_Combat and g_AIExecutionController then
local tacNotState = GetDialog("TacticalNotification") and GetDialog("TacticalNotification").state
local repoPhase = table.find(tacNotState, "mode", "hiddenEnemyRepoPhase")
local normalPhase = table.find(tacNotState, "mode", "hiddenEnemyTurnPhase")
if repoPhase or normalPhase then
HideTacticalNotification("turn")
ShowTacticalNotification(repoPhase and "enemyRepositionPhase" or "enemyTurnPhase", true)
end
end
if g_Combat and team == GetPoVTeam() and not enemy.dummy and team == g_Teams[g_CurrentTeam] then
local handle = enemy:GetHandle()
if not table.find(team.seen_units or empty_table, handle) then
-- queue seen enemies until the end of the current combat action, show all seen enemies afterwards
s_EnemySightedQueue[#s_EnemySightedQueue + 1] = enemy
CheckEnemySightedQueue()
--[[ if not HasAnyCombatActionInProgress("all") then
RestoreDefaultMode(false, false) -- for co-op
end]]
end
end
end
function ClearAITurnContours(specificUnit)
for unitHandle, contour in pairs(g_AITurnContours) do
if not specificUnit or specificUnit.handle == unitHandle then
DestroyMesh(contour)
g_AITurnContours[unitHandle] = nil
ShowBadgeOfAttacker(HandleToObject[unitHandle], false)
end
end
end
function OnMsg.UnitDied(unit)
ClearAITurnContours(unit)
end
function ClearAllCombatBadges()
for _, unit in ipairs(g_ShowTargetBadge) do
ShowBadgeOfAttacker(unit, false)
end
end
OnMsg.CombatActionEnd = CheckEnemySightedQueue
OnMsg.ExecutionControllerDeactivate = CheckEnemySightedQueue
function PickClosestUnit(group)
-- select unit that would cause minimal camera movement (pos + target)
local zone = CombatCam_CalcZone()
local unit, best_lookat
for _, u in ipairs(group) do
if IsValid(u) and u:IsValidPos() then
local target = AIGetIntendedTarget(u)
local lookat = zone and CombatCam_CalcAttackCamPos(zone, u, target)
if not lookat then
unit = u
break
end
if not best_lookat or IsCloser(zone.center, lookat, best_lookat) then
unit = u
best_lookat = lookat
end
end
end
return unit
end
function ShowBadgeOfAttacker(attacker, show)
if show then
table.insert(g_ShowTargetBadge, attacker)
if attacker.ui_badge then
attacker.ui_badge:SetActive(show, "showAttacker")
end
elseif attacker then
local currentTeam = g_Combat and g_Teams[g_Combat.team_playing]
if not currentTeam or currentTeam.control ~= "UI" then
if attacker.ui_badge then
attacker.ui_badge:SetActive(show, "showAttacker")
end
elseif attacker.ui_badge then
attacker.ui_badge.active_reasons.showAttacker = false
end
table.remove(g_ShowTargetBadge, table.find(g_ShowTargetBadge, attacker))
end
end
function HighestFloorOfGroup(group)
if not next(group) then return cameraTac.IsActive() and cameraTac.GetFloor() end
local maxFloor
for _, unit in ipairs(group) do
local floor = GetStepFloor(unit)
if not maxFloor or maxFloor < floor then
maxFloor = floor
end
end
return maxFloor
end
function GetDistGroupInitAndDestPoint(destPointsAndUnits)
local current_center = destPointsAndUnits[destPointsAndUnits[1]]:GetVisualPos()
local dest_center = destPointsAndUnits[1]
for i = 2, #destPointsAndUnits do
current_center = current_center + destPointsAndUnits[destPointsAndUnits[i]]:GetVisualPos()
dest_center = dest_center + destPointsAndUnits[i]
end
current_center = current_center / #destPointsAndUnits
dest_center = dest_center / #destPointsAndUnits
return current_center:Dist(dest_center)
end
MapVar("g_TrackingChargeAttacker", false)
GameVar("gv_DebugMeleeCharge", false) --set to true to see prints for the melee charge camera behavior
function ShouldTrackMeleeCharge(attacker, target)
if IsCinematicCCPlaying() or ActionCameraPlaying or not g_AIExecutionController then
g_TrackingChargeAttacker = false
if gv_DebugMeleeCharge then
print("skip melee charge camera logic because of non ai or cinematic camera or action camera")
end
return
end
local attackerPos = attacker:GetVisualPos()
local targetPos = target:GetVisualPos()
local initFitCheck = DoPointsFitScreen({attackerPos, targetPos}, nil, const.Camera.BufferSizeNoCameraMov)
if initFitCheck then
g_TrackingChargeAttacker = false
if gv_DebugMeleeCharge then
print("camera will not move as it is in a good spot")
end
return
end
--the second fit check is based on the midpoint of the attacker and target pos
local midPoint = (attackerPos + targetPos) / 2
local secondFitCheck = DoPointsFitScreen({attackerPos, targetPos}, midPoint, const.Camera.BufferSizeNoCameraMov)
if secondFitCheck then
local floor = GetStepFloor(target)
AdjustCombatCamera("set", nil, targetPos, floor, nil, "NoFitCheck")
g_TrackingChargeAttacker = false
if gv_DebugMeleeCharge then
print("snap the camera to the target and don't do anything else (the action would be visible)")
end
return
end
g_TrackingChargeAttacker = attacker
end
function AddToCameraTrackingBehavior(unit, args)
if g_AIExecutionController and unit then
if args.fallbackMove then --fallback move, need to calc los to new pos and handle reseting the tracking flag
local willReveal = RevealUnitBeforeMove(unit, args)
if willReveal then
if not g_AITurnContours[unit.handle] then
local enemy = unit.team.side == "enemy1" or unit.team.side == "enemy2" or unit.team.side == "neutralEnemy"
g_AITurnContours[unit.handle] = SpawnUnitContour(unit, enemy and "CombatEnemy" or "CombatAlly")
ShowBadgeOfAttacker(unit, true)
g_AIExecutionController.fallbackMoveTracking = true
args.trackMove = true
end
end
end
if args.trackMove then
g_AIExecutionController.tracked_pois = g_AIExecutionController.tracked_pois or {}
table.insert(g_AIExecutionController.tracked_pois, unit)
return args.fallbackMove, true--means that the unit will be visible and tracked by the camera
end
end
end
function OnMsg.UnitMovementDone(unit, action_id)
if g_AIExecutionController and action_id == "Move" and g_AIExecutionController.fallbackMoveTracking then
g_AIExecutionController.tracked_pois = nil
g_AIExecutionController.group_to_follow = nil
g_AIExecutionController.track_group = nil
g_AIExecutionController.fallbackMoveTracking = nil
ClearAITurnContours(unit)
ShowBadgeOfAttacker(unit, false)
end
end
function RevealUnitBeforeMove(unit, args)
local goto_pos = args.goto_pos
local units, step_pos_duplicated_arr
local pov_team = GetPoVTeam()
for i, pu in ipairs(pov_team.units) do
local sight = pu:GetSightRadius(unit, nil, goto_pos)
if IsCloser(pu, goto_pos, sight + 1) then
if not units then
units = {}
step_pos_duplicated_arr = {}
end
table.insert(units, pu)
table.insert(step_pos_duplicated_arr, goto_pos)
end
end
if not units then
return
end
local los_any, result = CheckLOS(step_pos_duplicated_arr, units)
if los_any then
local goto_stance = StancesList.Standing --args.goto_stance --for now, assume the end stance will be standing as there is a bug around that logic
for i, los in ipairs(result) do
if los == 2 or los == 1 and goto_stance == StancesList.Standing then
NetSyncEvent("RevealToTeam", unit, table.find(g_Teams, pov_team))
return true
end
end
end
end