diff --git a/changelog/snippets/other.7273.md b/changelog/snippets/other.7273.md new file mode 100644 index 0000000000..6551e17448 --- /dev/null +++ b/changelog/snippets/other.7273.md @@ -0,0 +1,2 @@ +- Unified objective logic under a new class (#7273) +- Improved ObjectiveGroup (#7273) diff --git a/lua/SimObjectives.lua b/lua/SimObjectives.lua index d5780554ba..f4cd5a7cd7 100644 --- a/lua/SimObjectives.lua +++ b/lua/SimObjectives.lua @@ -4,28 +4,45 @@ -- Copyright © 2006 Gas Powered Games, Inc. All rights reserved. ----------------------------------------------------------------- ----@alias ObjectiveType 'primary' | 'secondary' | 'Bonus' ----@alias ObjectiveStatus 'complete' | 'incomplete' ----@alias ArmyStatistic "Units_Active" | "Units_Killed" | "Units_History" | "Enemies_Killed" | "Economy_TotalProduced_Energy" | "Economy_TotalConsumed_Energy" | "Economy_Income_Energy" | "Economy_Output_Energy" | "Economy_Stored_Energy" | "Economy_Reclaimed_Energy" | "Economy_MaxStorage_Energy" | "Economy_PeakStorage_Energy" | "Economy_TotalProduced_Mass" | "Economy_TotalConsumed_Mass" | "Economy_Income_Mass" | "Economy_Output_Mass" | "Economy_Stored_Mass" | "Economy_Reclaimed_Mass" | "Economy_MaxStorage_Mass" | "Economy_PeakStorage_Mass", - ----@class Objective ----@field Tag string # Unique identifier used to sync between sim <-> UI ----@field Active boolean # Flag to indicate the objective is in progress ----@field Complete boolean # Flag to indicate success or failure ----@field Hidden boolean # Flag to indicate hiding the objective from screen ----@field Decals table # Table of decals associated with the objective ----@field IconOverrides string[] # Array of strings to override the strategical icon ----@field VizMarkers VizMarker[] # Array of visibility markers associated with the objective ----@field UnitMarkers ObjectiveArrow[] # Array of unit markers associated with the objective ----@field Decal Decal # A single decal ----@field NextTargetTag any # ??? ----@field PositionUpdateThreads any # ??? ----@field Title string # Title of the object, supports strings with LOC ----@field Description string # Description of the object, supports strings with LOC ----@field SimStartTime number # Set when the objective starts ----@field AddProgressCallback function # Adds a progression callback ----@field AddResultCallback function # Adds a completion callback ----@field ManualResult fun(obj: Objective, result: boolean)|nil # Ends the objective with given result +---@alias ObjectiveType +---| 'primary' # Main objective, reguired to complete the mission +---| 'secondary' # Optional objectives +---| 'bonus' # Optional objectives, usually hidden, for extra challenges. +---@alias ObjectiveStatus +---| 'complete' # Objective is finished +---| 'incomplete' # Objective in progress +---@alias ArmyStatistic +---| "Units_Active" # Own alive units +---| "Units_BeingBuilt" # Own units under construction +---| "Units_Killed" # Own units lost +---| "Units_History" # Own units built totals +---| "Enemies_Killed" # Enemy units killed +---| 'Enemies_MassValue_Destroyed' # Sum of mass cost of enemry units destroyed +---| 'Enemies_EnergyValue_Destroyed' # Sum of energy cost of enemry units destroyed +---| 'Enemies_Commanders_Destroyed' # Number of enemy commanders destroyed +---| "Economy_TotalProduced_Energy" +---| "Economy_TotalConsumed_Energy" +---| "Economy_Income_Energy" +---| "Economy_Output_Energy" +---| "Economy_Stored_Energy" +---| "Economy_Reclaimed_Energy" +---| "Economy_MaxStorage_Energy" +---| "Economy_PeakStorage_Energy" +---| "Economy_TotalProduced_Mass" +---| "Economy_TotalConsumed_Mass" +---| "Economy_Income_Mass" +---| "Economy_Output_Mass" +---| "Economy_Stored_Mass" +---| "Economy_Reclaimed_Mass" +---| "Economy_MaxStorage_Mass" +---| "Economy_PeakStorage_Mass", +---@alias ObjectiveAction "Kill" | "Capture" | "Build" | "Protect" | "Timer" | "Move" | "Reclaim" | "Repair" | "Locate" | "Group" | "KillOrCapture" +---@alias ObjectiveFactionIcon "Aeon" | "Cybran" | "UEF" | "Seraphim" +---@alias ObjectiveExpireResult +---| "complete" # Objective completes when the timer expires +---| "failed" # Objective fails when the timer expires +---@alias ObjectiveProgressCallback fun(current: integer, required: integer) Function is is called when objective progress changes +---@alias ObjectiveResultCallback fun(success: boolean, data?: any) Function that is called when objective ends, each objective can pass different data -- SUPPORTED OBJECTIVE TYPES: -- Kill @@ -47,17 +64,83 @@ local ScenarioUtils = import("/lua/sim/scenarioutilities.lua") local Triggers = import("/lua/scenariotriggers.lua") local VizMarker = import("/lua/sim/vizmarker.lua").VizMarker +local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow +local ObjectiveGroup = import("/lua/sim/objectives/ObjectiveGroup.lua").ObjectiveGroup + local objNum = 0 -- Used to create unique tags for objectives local DecalLOD = 4000 local objectiveDecal = '/env/utility/decals/objective_debug_albedo.dds' local SavedList = {} --- Return one of the human players in a game, using this instead of GetFocusArmy() --- to get a deterministic army index in coop. +local mobileAirCategories = categories.AIR * categories.MOBILE + +local actionIcons = { + kill = "/game/orders/attack_btn_up.dds", + capture = "/game/orders/convert_btn_up.dds", + build = "/game/orders/production_btn_up.dds", + protect = "/game/orders/guard_btn_up.dds", + timer = "/game/orders/guard_btn_up.dds", + move = "/game/orders/move_btn_up.dds", + reclaim = "/game/orders/reclaim_btn_up.dds", + repair = "/game/orders/repair_btn_up.dds", + locate = "/game/orders/omni_btn_up.dds", + group = "/game/orders/move_btn_up.dds", + killorcapture = "/game/orders/attack_capture_btn_up.dds", +} + +local targetFactionIcons = { + Cybran = "/textures/ui/common/faction_icon-lg/cybran_ico.dds", + Aeon = "/textures/ui/common/faction_icon-lg/aeon_ico.dds", + UEF = "/textures/ui/common/faction_icon-lg/uef_ico.dds", + Seraphim = "/textures/ui/common/faction_icon-lg/seraphim_ico.dds", +} + +---Returns path to icon associated with objective action +---@param actionString ObjectiveAction +---@return string iconPath Empty string when action is not found. +function GetActionIcon(actionString) + local action = string.lower(actionString) + return actionIcons[action] or "" +end + +local compareFunctions = { + ["<="] = function(a, b) return a <= b end, + [">="] = function(a, b) return a >= b end, + ["<"] = function(a, b) return a < b end, + [">"] = function(a, b) return a > b end, + ["=="] = function(a, b) return a == b end, + ["~="] = function(a, b) return a ~= b end, +} + +---Returns function that compares two elements +---@param operation "<=" | ">=" | "<" | ">" | "==" | "~=" +---@return fun(a, b): boolean +function GetCompareFunc(operation) + local fn = compareFunctions[operation] + if not fn then + WARN("GetCompareFunc: Unsupported compare operation '", operation, "', defaulting to '<'") + return compareFunctions["<"] + end + return fn +end + +---Creates objective target decal with specified dimensions +---@param x number +---@param z number +---@param w number +---@param h number +---@return moho.CDecalHandle +function CreateObjectiveDecal(x, z, w, h) + return CreateDecal(Vector(x, 0, z), 0, objectiveDecal, '', 'Water Albedo', w, h, DecalLOD, 0, 1, 0) +end + +---@type integer local playerArmy -function GetPlayerArmy() +---Return one of the human players in a game, using this instead of GetFocusArmy() to get a deterministic army index in coop. +---@return integer +local function getPlayerArmy() if not playerArmy then - for _, v in ArmyBrains do + for _, v in ipairs(ArmyBrains) do if v.BrainType == 'Human' then playerArmy = v:GetArmyIndex() break @@ -67,264 +150,949 @@ function GetPlayerArmy() return playerArmy end --- Camera objective by roates --- Creates markers that satisfy the objective when they are all inside of the camera viewport --- Camera(objectiveType, completeState, title, description, positionTable) - --- objectiveType = 'primary' or 'bonus' etc... --- completeState = 'complete' or 'incomplete' --- title = title string table from map's string file --- description = description string table from map's string file --- positionTable = table of position tables where markers will be created. {{x1, y1, z1}, {x2, y2, z2}} format -function Camera(objectiveType, completeState, title, description, positionTable) - local numMarkers = 0 - local curMarkers = 0 - local objective = AddObjective(objectiveType, completeState, title, description, nil, positionTable) +local humanArmies +---List of army index to army brain +---@return table +local function getHumanArmies() + if not humanArmies then + humanArmies = {} + local isCoop = ScenarioInfo.type == 'campaign_coop' + for _, brain in pairs(ArmyBrains) do + -- In campaign, if an AI mod is being used to provide an AI 'player', then this should also be included + -- otherwise it can render some missions impossible to complete + if brain.Human or (isCoop and StringStarts(brain.Name, "Player")) then + humanArmies[brain.Army] = brain + end + end + end + return humanArmies +end - local RemoveMarker = function(mark) - mark:Destroy() - curMarkers = curMarkers + 1 +---@param data table +---@return table +local function makeArmyList(data) + local armies = {} + if data.Army then + local brain = GetArmyBrain(data.Army) + armies[brain.Army] = brain + end - UpdateObjective(title, 'Progress', '('..curMarkers..'/'..numMarkers..')', objective.Tag) - objective:OnProgress(curMarkers, numMarkers) + if data.ArmyIndex then + local brain = GetArmyBrain(data.ArmyIndex) + armies[brain.Army] = brain + end - if curMarkers == numMarkers then - objective.Active = false - UpdateObjective(title, 'complete', 'complete', objective.Tag) - objective:OnResult(true) + if data.Armies then + for _, armyName in data.Armies do + if armyName == "HumanPlayers" then + for _, brain in pairs(getHumanArmies()) do + armies[brain.Army] = brain + end + else + local brain = GetArmyBrain(armyName) + armies[brain.Army] = brain + end end end + return armies +end - for i, v in positionTable do - numMarkers = numMarkers + 1 - local newMark = import("/lua/simcameramarkers.lua").AddCameraMarker(v) - newMark:AddCallback(RemoveMarker) +---Returns true if objective exists and is completed. +---@param objective? Objective +---@return boolean +function IsComplete(objective) + if not objective then + return false end + return objective.Complete +end + +---@param objective Objective +---@param unit Unit +---@param markUnits boolean +function BasicUnitTarget(objective, unit, markUnits) + objective:AddUnitTarget(unit, markUnits) +end - objective:OnProgress(curMarkers, numMarkers) - UpdateObjective(title, 'Progress', '('..curMarkers..'/'..numMarkers..')', objective.Tag) +function OnUnitGivenBase(objective, target, unit, newUnit, markUnits) + if unit then + table.removeByValue(target.Units, unit) + end - return objective + table.insert(target.Units, newUnit) + BasicUnitTarget(objective, newUnit, markUnits) end --- ControlGroup --- Complete when specified units matching the target blueprint types are in --- a control group. We don't care exactly which units they are (pre-built or --- newly constructed), as long as the requirements are ment. We just check --- the area for what units are in control groups and look at the blueprints (and optionally --- match the army, use -1 for don't care). --- Target = { --- Requirements = { --- {Category=, CompareOp=, Value=, [ArmyIndex=]}, --- {Category=, CompareOp=, Value=, [ArmyIndex=]}, --- {Category=, CompareOp=, Value=, [ArmyIndex=]}, --- } --- } --- op is one of: '<=', '>=', '<', '>', or '==' -function ControlGroup(Type, Complete, Title, Description, Target) +function CreateTriggers(unit, objective, useOnKilledWhenReclaimed) + if objective.OnUnitGiven then + Triggers.CreateUnitGivenTrigger(objective.OnUnitGiven, unit) + end + if objective.OnUnitCaptured then + Triggers.CreateUnitCapturedTrigger(nil, objective.OnUnitCaptured, unit) + end + if objective.OnUnitKilled then + Triggers.CreateUnitDeathTrigger(objective.OnUnitKilled, unit) + end + if objective.OnUnitReclaimed then + Triggers.CreateUnitReclaimedTrigger(objective.OnUnitReclaimed, unit) + end + if useOnKilledWhenReclaimed then + Triggers.CreateUnitReclaimedTrigger(objective.OnUnitKilled, unit) + end +end - local image = GetActionIcon('group') - local objective = AddObjective(Type, Complete, Title, Description, image, Target) - local lastReqsMet = -1 +function CreateIntelTriggers(unit, objective, isAlreadyLocated) + if objective.OnUnitGiven then + Triggers.CreateUnitGivenTrigger(objective.OnUnitGiven, unit) + end + if objective.OnUnitLocated then + if isAlreadyLocated then + return true + else + Triggers.CreateArmyIntelTrigger(objective.OnUnitLocated, GetArmyBrain(getPlayerArmy()), 'LOSNow', unit, true, categories.ALLUNITS, true, unit:GetAIBrain()) + return false + end + end +end + +---@param objective Objective +---@param IsLoading? boolean +function DeleteObjective(objective, IsLoading) + local userObjectiveUpdate = { + tag = objective.Tag, + updateField = 'delete', + } + if not IsLoading then + table.insert(SavedList, {DeleteArgs = userObjectiveUpdate}) + end + table.insert(Sync.ObjectivesUpdateTable, userObjectiveUpdate) +end + +local allowedUpdateFields = { + ["type"] = true, + ["complete"] = true, + ["title"] = true, + ["description"] = true, + ["image"] = true, + ["progress"] = true, + ["target"] = true, + ["timer"] = true, + ["delete"] = true, +} + +---Updates an objective, referencing it by objective title +---@param title string +---@param updateField string +---@param newData table|string +---@param objTag string +---@param isLoading? boolean +---@param inTime? integer +function UpdateObjective(title, updateField, newData, objTag, isLoading, inTime) + if objTag == 'Invalid' then + return + end + + if not Sync.ObjectivesUpdateTable then + Sync.ObjectivesUpdateTable = {} + end + + if type(objTag) ~= 'string' then + error('SimObjectives error: Invalid type for objTag in UpdateObjective. String expected but got ' .. type(objTag), 2) + elseif type(updateField) ~= 'string' then + error('SimObjectives error: Invalid type for UpdateField in UpdateObjective. String expected but got ' .. type(updateField), 2) + end + + if not isLoading then + table.insert(SavedList, {UpdateArgs = {title, updateField, newData, objTag, true, GetGameTimeSeconds(), n=6}, Tag=objTag}) + end + + -- All fields are stored with lowercase names + updateField = string.lower(updateField) + if not allowedUpdateFields[updateField] then + error('Unknown UpdateField: ' .. updateField .. '. Cannot process UpdateObjective request.', 2) + else + local userObjectiveUpdate = { + title = title, + updateField = updateField, + updateData = newData, + tag = objTag, + loading = isLoading, + } + if updateField == 'complete' then + userObjectiveUpdate['time'] = inTime or GetGameTimeSeconds() + end + table.insert(Sync.ObjectivesUpdateTable, userObjectiveUpdate) + end +end + +-- Update legacy style objective using correct syntax +function UpdateBasicObjective(Objective, UpdateField, NewData) + UpdateObjective(Objective.Title, UpdateField, NewData, Objective.Tag) +end - -- Call ManualResult - objective.ManualResult = function(self, result) +---@class ObjectiveTargetBase +---@field ShowProgress? boolean Defaults to `true` to show the progress in the UI. +---@field Timer? integer Time in seconds after which the objective will end. Set `ExpireResult` to complete of fail the objective. +---@field ExpireResult? ObjectiveExpireResult Requires `Timer` to be set. + +---@class ObjectiveTargetRequirements +---@field Area string +---@field Category EntityCategory +---@field CompareOp CompareType +---@field Value number +---@field ArmyIndex integer +---@field Armies string[] + +---@class ObjectiveTarget : ObjectiveTargetBase +---@field Area? string +---@field Areas? string[] +---@field MarkArea? boolean +---@field MarkUnits? boolean +---@field FlashVisible? boolean +---@field AlwaysVisible? boolean +---@field PercentProgress? boolean Show the progress in UI in percent. Defaults to false. +---@field Unit? Unit +---@field Units? Unit[] +---@field Hidden? boolean Objective will not be displayed in the UI. +---@field ShowFaction? ObjectiveFactionIcon Faction icon will be used as the objective icon. +---@field Category? EntityCategory +---@field NumRequired? integer Specific amount required, if not provided it can be calculated from provided `Units`. `PercentRequired` can be used as well. +---@field PercentRequired? number Alternative to `NumRequired` +---@field Requirements? ObjectiveTargetRequirements + +---Base objective class +---@class Objective +---@field Tag string Unique identifier used to sync between sim <-> UI +---@field Active boolean Flag to indicate the objective is in progress +---@field Complete boolean Flag to indicate success or failure +---@field Type ObjectiveType Primary = required to be completed, secondary = optional, bonus = often hidden for extra challenge +---@field Title string Title of the object, supports strings with LOC +---@field Description string Description of the object, supports strings with LOC +---@field Hidden boolean Flag to indicate hiding the objective from screen +---@field SimStartTime number Set when the objective starts +---@field protected Decals table Dictionary of objective decals associated with the target area names +---@field protected UnitMarkers ObjectiveArrow[] Array of unit markers (yellow bouncing arrow) associated with the objective +---@field protected VizMarkers VizMarker[] Array of visibility markers associated with the objective +---@field protected IconOverrides Unit[] Array of units with added objective strategic icon overlay +---@field protected PositionUpdateThreads table Threads for updating target's position for UI and icon based on available intel +---@field protected ProgressCallbacks ObjectiveProgressCallback[] List of functions to call when the objective progress changes +---@field protected ResultCallbacks ObjectiveResultCallback[] List of functions to call when the objective is completed +---@field protected Timer? thread Timer to finish the objective +---@overload fun(tag: string, title: string, description: string, objType: ObjectiveType, status: ObjectiveStatus, hidden?: boolean, target?: ObjectiveTarget): Objective +local Obj = { + ---Display progress in the UI + ShowProgress = true, + ---Display the progress in % instead. + PercentProgress = false, + ---@protected ID for tracking targets + NextTargetTag = 0, +} +---@param tag string +---@param objType ObjectiveType +---@param status ObjectiveStatus +---@param title string +---@param description string +---@param hidden? boolean +---@param target? ObjectiveTarget +---@protected +function Obj:__init(tag, objType, status, title, description, hidden, target) + self.Tag = tag + self.Type = objType + self.Title = title + self.Description = description + self.Hidden = hidden or false + self.SimStartTime = GetGameTimeSeconds() + + self.Decals = {} + self.UnitMarkers = {} + self.VizMarkers = {} + self.IconOverrides = {} + self.PositionUpdateThreads = {} + self.ProgressCallbacks = {} + self.ResultCallbacks = {} + + if target then + self:AddTarget(target) + end + if status == "complete" then self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' + self.Complete = true + else + self.Active = true + self.Complete = false + end +end +---Adds a function to call when objective ends +---@param cb ObjectiveResultCallback +function Obj:AddResultCallback(cb) + table.insert(self.ResultCallbacks, cb) +end +---Removes a function from the list of callbacks to call when objective ends +---@param cb ObjectiveResultCallback +function Obj:RemoveResultCallback(cb) + table.removeByValue(self.ResultCallbacks, cb) +end +---Adds a function to call when objective progress changes +---@param cb ObjectiveProgressCallback +function Obj:AddProgressCallback(cb) + table.insert(self.ProgressCallbacks, cb) +end +---Removes a function from the list of callbacks to call when objective progress changes +---@param cb ObjectiveProgressCallback +function Obj:RemoveProgressCallback(cb) + table.removeByValue(self.ProgressCallbacks, cb) +end +---Pushes the progress update to the UI if `ShowProgress` is enabled. +---@param current integer +---@param required integer +---@param total? integer Required for the perctange display if its not the same as `required` +function Obj:UpdateProgress(current, required, total) + if not self.ShowProgress then return end + + local progress + if self.PercentProgress then + if not total or total == required then + progress = string.format('(%s%%)', math.ceil(current / required * 100)) else - resultStr = 'failed' + local percentRequired = math.ceil(required / total * 100) + progress = string.format('(%s%%/%s%%)', math.floor(current / total * 100), percentRequired) end - UpdateObjective(Title, 'complete', resultStr, self.Tag) + else + progress = string.format('(%d/%d)', current, required) end - local function WatchGroups(requirements) - local totalReqs = table.getn(requirements) - while objective.Active do - local reqsMet = 0 + UpdateObjective(self.Title, 'Progress', progress, self.Tag) +end +---Called when objective progress changes, updates the UI and runs progress callbacks +---@param current number +---@param required number +---@param total? integer +function Obj:OnProgress(current, required, total) + if not self.Active then return end - for i, requirement in requirements do - local units = ScenarioInfo.ControlGroupUnits - local cnt = 0 - if units then - for _, unit in units do - if not requirement.ArmyIndex or (requirement.ArmyIndex == unit.Army) then - if EntityCategoryContains(requirement.Category, unit) then - if not unit.Marked and objective.MarkUnits then - unit.Marked = true - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - objective:AddUnitTarget(unit) - end - cnt = cnt + 1 - end - end - end - end - if not requirement.CompareFunc then - requirement.CompareFunc = GetCompareFunc(requirement.CompareOp) - end - if requirement.CompareFunc(cnt, requirement.Value) then - reqsMet = reqsMet +1 - end - end + self:UpdateProgress(current, required, total) - if lastReqsMet ~= reqsMet then - local progress = string.format('(%s/%s)', reqsMet, totalReqs) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - objective:OnProgress(reqsMet, totalReqs) - lastReqsMet = reqsMet - end + for _, v in ipairs(self.ProgressCallbacks) do + v(current, required) + end +end +---Dont override these if you want notification. Call Add???Callback intead +---@param success boolean +---@param data? any +function Obj:OnResult(success, data) + if not self.Active then return end + + self.Active = false + self.Complete = success + + local resultStr + if success then + resultStr = 'complete' + else + resultStr = 'failed' + end - if reqsMet == totalReqs then - objective.Active = false - objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) - return - end - WaitTicks(10) + UpdateObjective(self.Title, 'complete', resultStr, self.Tag) + + for _, v in pairs(self.Decals) do + v:Destroy() + end + + for _, v in pairs(self.UnitMarkers) do + v:Destroy() + end + + -- Revert strategic icons + for _, v in pairs(self.IconOverrides) do + if not v:BeenDestroyed() then + v:SetStrategicUnderlay("") end end - UpdateObjective(Title, 'Progress', '(0/0)', objective.Tag) - ForkThread(WatchGroups, Target.Requirements) - return objective + for _, v in pairs(self.VizMarkers) do + v:Destroy() + end + + for _, v in pairs(self.PositionUpdateThreads) do + v:Destroy() + end + + if self.Timer then + self:DestroyTimer() + end + + for _, v in ipairs(self.ResultCallbacks) do + v(success, data) + end + + self.Decals = nil + self.UnitMarkers = nil + self.IconOverrides = nil + self.VizMarkers = nil + self.PositionUpdateThreads = nil + self.ResultCallbacks = nil + self.ProgressCallbacks = nil +end +---Call this to manually end the objective +---@param success boolean +function Obj:ManualResult(success) + self:OnResult(success) +end +---Creates a timer that ends the objective when expired. +---@param time integer In seconds +---@param expireResult? ObjectiveExpireResult Defaults to `failed` +function Obj:AddTimer(time, expireResult) + if self.Timer then return end + + local function onTick(newTime) + UpdateObjective(self.Title, 'timer', {Time = newTime}, self.Tag) + end + + local function onExpired() + self:OnResult(expireResult == 'complete') + end + + self.Timer = Triggers.CreateTimerTrigger(onExpired, time, nil, true, onTick) end +---Cancels the timer running on the objective +--- +---If called before the timer runs out, the timer will just be removed without ending the objective. +function Obj:DestroyTimer() + if not self.Timer then return end --- CreateGroup --- Takes list of objective tables which are produced by the objective creation --- functions such as Kill, Protect, Capture, etc. --- UserCallback is executed when all objectives in the list are complete -function CreateGroup(name, userCallback, numRequired) - local objectiveGroup = { - Name = name, - Active = true, - Objectives = {}, - NumRequired = numRequired, - NumCompleted = 0, - AddObjective = function(self, objective) end, -- Defined later - RemoveObjective = function(self, objective) end, -- Defined later - OnComplete = userCallback, + Sync.ObjectiveTimer = 0 + self.Timer:Destroy() + self.Timer = nil +end +---Creates a new visual marker that is destroyed when objective ends. +---@param x number +---@param z number +---@param radius number +---@param lifetime? number In seconds, defaults to `-1` infinite +---@return VizMarker +function Obj:CreateVisualMarker(x, z, radius, lifetime) + lifetime = lifetime or -1 + local specs = { + X = x, + Z = z, + Radius = radius, + LifeTime = lifetime, + Omni = false, + Vision = true, + Army = getPlayerArmy(), } + ---@type VizMarker + local vizmarker = VizMarker(specs) + if lifetime > 0 then + table.insert(self.VizMarkers, vizmarker) + end - local function OnResult(result) - if not objectiveGroup.Active then + return vizmarker +end +---Creates a marker that attached to the `unit` and provides `Vision` intel. +--- +---This marker is destroyed when the objective ends or when the unit dies. +---@param unit Unit +---@param radius? number Defaults to 8 +---@param lifetime? number In seconds, defaults to `-1` infinite +---@protected +function Obj:AddUnitVisualMarker(unit, radius, lifetime) + local pos = unit:GetPosition() + radius = radius or 8 + local vizmarker = self:CreateVisualMarker(pos[1], pos[3], radius, lifetime) + unit.Trash:Add(vizmarker) + vizmarker:AttachBoneTo(-1, unit, -1) +end +---Creates a maker that provides `Visual` intel over specified `area`. +--- +---This marker is destroyed when the objective ends. +---@param area string +---@param lifetime? number In seconds, defaults to `-1` infinite +---@protected +function Obj:AddAreaVisualMarker(area, lifetime) + local rect = ScenarioUtils.AreaToRect(area) + local width = rect.x1 - rect.x0 + local height = rect.y1 - rect.y0 + local x = rect.x0 + width / 2 + local z = rect.y0 + height / 2 + local radius = math.max(width, height) + + self:CreateVisualMarker(x, z, radius, lifetime) +end +---@param targetTag integer +---@return function +---@protected +function Obj:GetTargetDestroyedNotifyCallback(targetTag) + return function() + if not self.Active then return end - if result then - objectiveGroup.NumCompleted = objectiveGroup.NumCompleted + 1 + if self.PositionUpdateThreads[targetTag] then + KillThread(self.PositionUpdateThreads[targetTag]) + self.PositionUpdateThreads[targetTag] = nil end - if objectiveGroup.NumRequired then - if objectiveGroup.NumCompleted < objectiveGroup.NumRequired then - return - end - else - if objectiveGroup.Objectives then - for _, v in objectiveGroup.Objectives do - if v.Active then + -- When the blip is destroyed, tell objectives we dont + -- have a blip anymore. This doesnt necessarily mean the + -- unit is killed, we simply lost the blip. + local data = { + Type = 'Position', + Value = nil, + BlueprintId = nil, + TargetTag = targetTag, + } + UpdateObjective(self.Title, 'Target', data, self.Tag) + end +end +---Takes a unit that is an objective target and uses its recon detect +---event to notify the objectives that we have a blip for the unit. +---@param unit Unit +---@param targetTag integer +---@protected +function Obj:SetupNotify(unit, targetTag) + ---Add a detectedBy callback to notify the user layer when our recon on the target comes in and out. + ---@param cbunit Unit + ---@param armyindex integer + local detectedByCB = function(cbunit, armyindex) + if not self.Active or armyindex ~= getPlayerArmy() then + return + end + + -- get the blip that is associated with the unit + local blip = cbunit:GetBlip(armyindex) + if not blip then return end + + -- Only provide the target position to the user layer if + -- the blip IsSeenEver() (i.e. has been identified). + self.PositionUpdateThreads[targetTag] = ForkThread(function() + while self.Active do + WaitTicks(10) + if blip:BeenDestroyed() then + return + end + + if blip:IsSeenEver(armyindex) then + local data = { + Type = 'Position', + Value = blip:GetPosition(), + BlueprintId = blip:GetBlueprint().BlueprintId, + TargetTag = targetTag + } + UpdateObjective(self.Title, 'Target', data, self.Tag) + + -- If it's not mobile we can exit the thread since + -- the blip won't move. + if not unit.Dead and not unit:BeenDestroyed() and not EntityCategoryContains(categories.MOBILE, unit) then return end end end - end + end) - objectiveGroup.Active = false - objectiveGroup.OnComplete() + blip:AddDestroyHook(self:GetTargetDestroyedNotifyCallback(targetTag)) end + -- When the unit is detected by an army, have it call this callback + -- function (defined above) + unit:AddDetectedByHook(detectedByCB) - objectiveGroup.AddObjective = function(self, objective) - table.insert(self.Objectives, objective) - objective:AddResultCallback(OnResult) + -- See if we can detect the unit right now + local blip = unit:GetBlip(getPlayerArmy()) + if blip then + detectedByCB(unit, getPlayerArmy()) end - - objectiveGroup.RemoveObjective = function(self, objective) - table.removeByValue(self.Objectives, objective) - end - - return objectiveGroup end +---Take an objective target unit that is owned by the focus army +---Info passed to user layer to handle zoom to button and chiclet image +---@param unit Unit +---@param targetTag integer +---@protected +function Obj:SetupFocusNotify(unit, targetTag) + self.PositionUpdateThreads[targetTag] = ForkThread(function() + while self.Active do + if unit:BeenDestroyed() then + return + end ---- Adds a kill objective ---- | Objective data | Description | ---- | --------------------- | ------------- | ---- | Units | Table of units to kill ---- | MarkUnits | Flag to to mark the units with an objective arrow the units are marked with an objective arrow ---- | Hidden | Flag to hide the objective from the UI ---- | FlashVisible | Flag to give a short visibility burst ---- | ShowProgress | Flag to update the description of the objective in the UI ----@param Type ObjectiveType # Type of objective, used for the strategic icon in the UI ----@param Complete ObjectiveStatus # Completion status, usually this is 'incomplete' unless the player already completed it by chance ----@param Title string # Title of the objective, supports strings with LOC ----@param Description string # Description of the objective, supports strings with LOC ----@param Target table # Objective data, see the description ----@return Objective -function Kill(Type, Complete, Title, Description, Target) - Target.killed = 0 - Target.total = table.getn(Target.Units) + local data = { + Type = 'Position', + Value = unit:GetPosition(), + BlueprintId = unit:GetBlueprint().BlueprintId, + TargetTag = targetTag + } + UpdateObjective(self.Title, 'Target', data, self.Tag) - local image = GetActionIcon('kill') - local objective = AddObjective(Type, Complete, Title, Description, image, Target) + -- If it's not mobile we can exit the thread since the unit won't move. + if not unit.Dead and not unit:BeenDestroyed() and not EntityCategoryContains(categories.MOBILE, unit) then + return + end - -- Call ManualResult - objective.ManualResult = function(self, result) - objective.Active = false - objective:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' + WaitTicks(10) end - UpdateObjective(Title, 'complete', resultStr, objective.Tag) + end) + + Triggers.CreateUnitDeathTrigger(self:GetTargetDestroyedNotifyCallback(targetTag), unit) +end +---@param unit Unit +---@param mark? boolean Adds strategic icon overlay and yellow arrow attached to the unit. Defaults to `true` +---@param flashVisible? boolean Reveals the area through FoW +---@param alwaysVisible? boolean Creates a visual marker over the area +function Obj:AddUnitTarget(unit, mark, flashVisible, alwaysVisible) + self.NextTargetTag = self.NextTargetTag + 1 + + if unit.Army == getPlayerArmy() then + self:SetupFocusNotify(unit, self.NextTargetTag) + else + self:SetupNotify(unit, self.NextTargetTag) end - objective.OnUnitKilled = function(unit) - if not objective.Active then - return + if mark then + if self.Type == 'primary' then + unit:SetStrategicUnderlay('icon_objective_primary') + elseif self.Type == 'secondary' then + unit:SetStrategicUnderlay('icon_objective_secondary') + elseif self.Type == 'bonus' then + unit:SetStrategicUnderlay('icon_objective_bonus') end - Target.killed = Target.killed + 1 + table.insert(self.IconOverrides, unit) - local progress = string.format('(%s/%s)', Target.killed, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - objective:OnProgress(Target.killed, Target.total) - - if Target.killed == Target.total then - UpdateObjective(Title, 'complete', "complete", objective.Tag) - objective.Active = false - objective:OnResult(true, unit) + -- Bouncing arrow on air units look weird, especially when falling down from the sky + if not EntityCategoryContains(mobileAirCategories, unit) then + local marker = ObjectiveArrow({AttachTo = unit}) + table.insert(self.UnitMarkers, marker) end end - objective.OnUnitGiven = function(unit, newUnit) - if not objective.Active then - return - end - OnUnitGivenBase(objective, Target, unit, newUnit, (Target.MarkUnits == nil) or Target.MarkUnits) - CreateTriggers(newUnit, objective, true) -- Reclaiming is same as killing for our purposes + if flashVisible then + self:AddUnitVisualMarker(unit, 2, 1) end - for _, unit in Target.Units do - if not unit.Dead then - -- Mark the units unless MarkUnits == false - if Target.MarkUnits == nil or Target.MarkUnits then - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} + if alwaysVisible then + self:AddUnitVisualMarker(unit) + end +end +---@param unit Unit +---@deprecated +function Obj:AddBasicUnitTarget(unit) + self:AddUnitTarget(unit, true) +end +---@param area string +---@param decal? boolean Creates objectvie decal +---@param flashVisible? boolean Reveals the area through FoW +---@param alwaysVisible? boolean Creates a visual marker over the area +---@protected +function Obj:AddAreaTarget(area, decal, flashVisible, alwaysVisible) + self.NextTargetTag = self.NextTargetTag + 1 + local rect = ScenarioUtils.AreaToRect(area) + + local data = { + Type = 'Area', + Value = rect, + TargetTag = self.NextTargetTag + } + UpdateObjective(self.Title, 'Target', data, self.Tag) + + if decal then + local w = rect.x1 - rect.x0 + local h = rect.y1 - rect.y0 + local x = rect.x0 + (w / 2.0) + local z = rect.y0 + (h / 2.0) + self.Decals[area] = CreateObjectiveDecal(x, z, w, h) + end + if flashVisible then + self:AddAreaVisualMarker(area, 0.01) + end + if alwaysVisible then + self:AddAreaVisualMarker(area) + end +end +---Adds a objective target and set up units / area specified +---@param target ObjectiveTarget +function Obj:AddTarget(target) + local markArea = target.MarkArea + -- Mark the units unless MarkUnits == false + local markUnits = target.MarkUnits == nil or target.MarkUnits + local flashVisible = target.FlashVisible + local alwaysVisible = target.AlwaysVisible + + if target.Area then + self:AddAreaTarget(target.Area, markArea, flashVisible, alwaysVisible) + end + + if target.Areas then + for _, area in pairs(target.Areas) do + self:AddAreaTarget(area, markArea, flashVisible, alwaysVisible) + end + end + + if target.Requirements then + for _, requirement in pairs(target.Requirements) do + self:AddAreaTarget(requirement.Area, markArea, flashVisible, alwaysVisible) + end + end + + if target.Units then + for _, unit in pairs(target.Units) do + if unit.Dead then continue end + + self:AddUnitTarget(unit, markUnits, flashVisible, alwaysVisible) + end + end + + if target.Unit and not target.Unit.Dead then + self:AddUnitTarget(target.Unit, markUnits, flashVisible, alwaysVisible) + end + + if target.Timer then + self:AddTimer(target.Timer, target.ExpireResult) + end + + -- Hide progress only when it's specifically requested + if target.ShowProgress == false then + self.ShowProgress = false + end + + if target.PercentProgress then + self.PercentProgress = target.PercentProgress + end +end + +Objective = ClassSimple(Obj) + +-- Adds and tracks an objective, should not be used directly +---@param type ObjectiveType +---@param complete ObjectiveStatus +---@param title string +---@param description string +---@param actionImage? string Path to texture '/textures/ui/common/missions/mission1.dds' +---@param target? ObjectiveTarget +---@param isLoading? boolean Are we loading a saved game? +---@param loadedTag? string If IsLoading is specified, whats the tag? +---@return Objective +function AddObjective(type, complete, title, description, actionImage, target, isLoading, loadedTag) + if not Sync.ObjectivesTable then + Sync.ObjectivesTable = {} + end + + local hidden = target and target.Hidden + local tag + if isLoading then + tag = loadedTag --[[@as string]] + else + tag = 'Objective' .. objNum + objNum = objNum + 1 + table.insert(SavedList, { + AddArgs = {type, complete, title, description, actionImage, target, true, tag, n = 8}, + Tag = tag + }) + end + + local objective = Objective(tag, type, complete, title, description, hidden, target) + + local userTargets = {} + if target then + if target.ShowFaction then + target.Image = targetFactionIcons[target.ShowFaction] + end + + if target.Requirements then + for _, req in ipairs(target.Requirements) do + if req.Area then + table.insert(userTargets, {Type = 'Area', Value = ScenarioUtils.AreaToRect(req.Area)}) + end + end + elseif target.Timer then + userTargets = {Type = 'Timer', Time = target.Timer} + end + + if target.Category then + local bps = EntityCategoryGetUnitList(target.Category) + if not table.empty(bps) then + table.insert(userTargets, {Type = 'Blueprint', BlueprintId = bps[1]}) + end + end + end + + local userObjectiveData = { + tag = tag, + type = type, + complete = complete, + hidden = hidden, + title = title, + description = description, + actionImage = actionImage, + targetImage = target and target.Image, + progress = "", + targets = userTargets, + loading = isLoading, + StartTime = objective.SimStartTime, + } + + Sync.ObjectivesTable[tag] = userObjectiveData + + return objective +end + +function OnPostLoad() + for _, v in ipairs(SavedList) do + if v.AddArgs then + AddObjective(unpack(v.AddArgs)) + elseif v.UpdateArgs then + UpdateObjective(unpack(v.UpdateArgs)) + elseif v.DeleteArgs then + DeleteObjective(v.DeleteArgs, true) + end + end +end + +---Creates markers that satisfy the objective when they are all inside of the camera viewport +---@param Type ObjectiveType +---@param Complete ObjectiveStatus +---@param Title string +---@param Description string +---@param Target Vector[] table of position tables where markers will be created. {{x1, y1, z1}, {x2, y2, z2}} format +---@return Objective +function Camera(Type, Complete, Title, Description, Target) + local required = table.getn(Target) + local current = 0 + local objective = AddObjective(Type, Complete, Title, Description, nil, Target) + + local RemoveMarker = function(mark) + mark:Destroy() + current = current + 1 + + objective:OnProgress(current, required) + + if current == required then + objective:OnResult(true) + end + end + + for _, v in pairs(Target) do + local newMark = import("/lua/simcameramarkers.lua").AddCameraMarker(v) + newMark:AddCallback(RemoveMarker) + end + + objective:UpdateProgress(current, required) + + return objective +end + +-- ControlGroup +-- Complete when specified units matching the target blueprint types are in +-- a control group. We don't care exactly which units they are (pre-built or +-- newly constructed), as long as the requirements are ment. We just check +-- the area for what units are in control groups and look at the blueprints (and optionally +-- match the army, use -1 for don't care). +-- Target = { +-- Requirements = { +-- {Category=, CompareOp=, Value=, [ArmyIndex=]}, +-- {Category=, CompareOp=, Value=, [ArmyIndex=]}, +-- {Category=, CompareOp=, Value=, [ArmyIndex=]}, +-- } +-- } +-- op is one of: '<=', '>=', '<', '>', or '==' +function ControlGroup(Type, Complete, Title, Description, Target) + local image = GetActionIcon('Group') + local objective = AddObjective(Type, Complete, Title, Description, image, Target) + local lastReqsMet = -1 + + local function WatchGroups(requirements) + local totalReqs = table.getn(requirements) + while objective.Active do + local reqsMet = 0 + + for i, requirement in requirements do + local units = ScenarioInfo.ControlGroupUnits + local cnt = 0 + if units then + for _, unit in units do + if not requirement.ArmyIndex or (requirement.ArmyIndex == unit.Army) then + if EntityCategoryContains(requirement.Category, unit) then + if not unit.Marked and Target.MarkUnits then + unit.Marked = true + objective:AddUnitTarget(unit, true) + end + cnt = cnt + 1 + end + end + end + end + if not requirement.CompareFunc then + requirement.CompareFunc = GetCompareFunc(requirement.CompareOp) + end + if requirement.CompareFunc(cnt, requirement.Value) then + reqsMet = reqsMet +1 + end + end + + if lastReqsMet ~= reqsMet then + objective:OnProgress(reqsMet, totalReqs) + lastReqsMet = reqsMet end - if Target.FlashVisible then - FlashViz(unit) + + if reqsMet == totalReqs then + objective:OnResult(true) + return end + WaitTicks(10) + end + end + objective:UpdateProgress(0, 0) + ForkThread(WatchGroups, Target.Requirements) + + return objective +end + +--- Adds a kill objective +--- | Objective data | Description | +--- | --------------------- | ------------- | +--- | Units | Table of units to kill +--- | MarkUnits | Flag to to mark the units with an objective arrow the units are marked with an objective arrow +--- | Hidden | Flag to hide the objective from the UI +--- | FlashVisible | Flag to give a short visibility burst +--- | ShowProgress | Flag to update the description of the objective in the UI +---@param Type ObjectiveType # Type of objective, used for the strategic icon in the UI +---@param Complete ObjectiveStatus # Completion status, usually this is 'incomplete' unless the player already completed it by chance +---@param Title string # Title of the objective, supports strings with LOC +---@param Description string # Description of the objective, supports strings with LOC +---@param Target table # Objective data, see the description +---@return Objective +function Kill(Type, Complete, Title, Description, Target) + local current = 0 + local total = table.getn(Target.Units) + local required = math.min(Target.NumRequired or total, total) + + local image = GetActionIcon('Kill') + local objective = AddObjective(Type, Complete, Title, Description, image, Target) + + objective.OnUnitKilled = function(unit) + if not objective.Active then return end + + current = current + 1 + objective:OnProgress(current, required) + + if current >= required then + objective:OnResult(true, unit) + end + end + + objective.OnUnitGiven = function(unit, newUnit) + if not objective.Active then return end + + OnUnitGivenBase(objective, Target, unit, newUnit, (Target.MarkUnits == nil) or Target.MarkUnits) + CreateTriggers(newUnit, objective, true) -- Reclaiming is same as killing for our purposes + end + + for _, unit in Target.Units do + if not unit.Dead then CreateTriggers(unit, objective, true) -- Reclaiming is same as killing for our purposes else objective.OnUnitKilled(unit) end end - local progress = string.format('(%s/%s)', Target.killed, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required) return objective end @@ -345,40 +1113,24 @@ end ---@param Target table # Objective data, see the description ---@return Objective function Capture(Type, Complete, Title, Description, Target) - Target.captured = 0 - Target.total = table.getn(Target.Units) - local required = Target.NumRequired or Target.total + local current = 0 + local total = table.getn(Target.Units) + local required = Target.NumRequired or total local returnUnits = {} - local image = GetActionIcon('capture') + local image = GetActionIcon('Capture') local objective = AddObjective(Type, Complete, Title, Description, image, Target) - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - objective.OnUnitCaptured = function(unit, captor) table.insert(returnUnits, unit) if not objective.Active then return end - Target.captured = Target.captured + 1 - local progress = string.format('(%s/%s)', Target.captured, required) - objective:OnProgress(Target.captured, required) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - if Target.captured >= required then - objective.Active = false + current = current + 1 + objective:OnProgress(current, required) + if current >= required then objective:OnResult(true, returnUnits) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end @@ -386,11 +1138,9 @@ function Capture(Type, Complete, Title, Description, Target) if not objective.Active then return end - Target.total = Target.total - 1 - if Target.total < required then - objective.Active = false + total = total - 1 + if total < required then objective:OnResult(false) - UpdateObjective(Title, 'complete', 'failed', objective.Tag) end end @@ -404,24 +1154,13 @@ function Capture(Type, Complete, Title, Description, Target) for _, unit in Target.Units do if not unit.Dead then - -- Mark the units unless MarkUnits == false - if Target.MarkUnits == nil or Target.MarkUnits then - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - end - CreateTriggers(unit, objective, true) -- Reclaiming is same as killing for our purposes - - if Target.FlashVisible then - FlashViz(unit) - end else objective.OnUnitKilled(unit) end end - local progress = string.format('(%s/%s)', Target.captured, required) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required) return objective end @@ -450,28 +1189,6 @@ function KillOrCapture(Type, Complete, Title, Description, Target) local image = GetActionIcon('KillOrCapture') local objective = AddObjective(Type, Complete, Title, Description, image, Target) - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - - objective.UpdateProgress = function() - local progress - if Target.PercentProgress then - progress = string.format('(%s%%/%s%%)', math.floor(((Total - (Total - KilledOrCaptured)) / Total) * 100), PercentRequired) - elseif Target.ShowProgress == nil or Target.ShowProgress then - progress = string.format('(%s/%s)', KilledOrCaptured, NumRequired) - end - UpdateObjective(Title, 'Progress', progress, objective.Tag) - end - -- Keep track of captured units so subsequent kills dont get counted local captured = {} @@ -481,18 +1198,14 @@ function KillOrCapture(Type, Complete, Title, Description, Target) end for _, v in captured do if v == unit then - -- Ignore units already captured return end end KilledOrCaptured = KilledOrCaptured + 1 - objective:OnProgress(KilledOrCaptured, NumRequired) - objective:UpdateProgress() + objective:OnProgress(KilledOrCaptured, Total, NumRequired) if KilledOrCaptured == NumRequired then - objective.Active = false objective:OnResult(true, unit) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end @@ -502,12 +1215,9 @@ function KillOrCapture(Type, Complete, Title, Description, Target) end table.insert(captured, unit) KilledOrCaptured = KilledOrCaptured + 1 - objective:OnProgress(KilledOrCaptured, NumRequired) - objective:UpdateProgress() + objective:OnProgress(KilledOrCaptured, Total, NumRequired) if KilledOrCaptured == NumRequired then - objective.Active = false objective:OnResult(true, unit) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end @@ -517,12 +1227,9 @@ function KillOrCapture(Type, Complete, Title, Description, Target) end KilledOrCaptured = KilledOrCaptured + 1 - objective:OnProgress(KilledOrCaptured, NumRequired) - objective:UpdateProgress() + objective:OnProgress(KilledOrCaptured, Total, NumRequired) if KilledOrCaptured == NumRequired then - objective.Active = false objective:OnResult(true, unit) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end @@ -542,22 +1249,12 @@ function KillOrCapture(Type, Complete, Title, Description, Target) for _, unit in Target.Units do if not unit.Dead then - -- Mark the units unless MarkUnits == false - if Target.MarkUnits == nil or Target.MarkUnits then - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - end - - if Target.FlashVisible then - FlashViz(unit) - end - CreateTriggers(unit, objective) else objective.OnUnitKilled(unit) end end - objective:UpdateProgress() + objective:UpdateProgress(KilledOrCaptured, Total, NumRequired) return objective end @@ -578,37 +1275,21 @@ end ---@param Target table # Objective data, see the description ---@return Objective function Reclaim(Type, Complete, Title, Description, Target) - Target.reclaimed = 0 - Target.total = table.getn(Target.Units) + local current = 0 + local required = table.getn(Target.Units) - local image = GetActionIcon("reclaim") + local image = GetActionIcon("Reclaim") local objective = AddObjective(Type, Complete, Title, Description, image, Target) - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - objective.OnUnitReclaimed = function(unit) if not objective.Active then return end - Target.reclaimed = Target.reclaimed + 1 - local progress = string.format('(%s/%s)', Target.reclaimed, Target.total) - objective:OnProgress(Target.reclaimed, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - if Target.reclaimed == Target.total then - objective.Active = false + current = current + 1 + objective:OnProgress(current, required) + if current == required then objective:OnResult(true) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end @@ -616,9 +1297,7 @@ function Reclaim(Type, Complete, Title, Description, Target) if not objective.Active then return end - objective.Active = false objective:OnResult(false) - UpdateObjective(Title, 'complete', 'failed', objective.Tag) end -- If the unit is captured it can still be reclaimed to complete the @@ -640,13 +1319,10 @@ function Reclaim(Type, Complete, Title, Description, Target) end for _, unit in Target.Units do - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} CreateTriggers(unit, objective) end - local progress = string.format('(%s/%s)', Target.reclaimed, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required) return objective end @@ -665,29 +1341,14 @@ end ---@param Description string # Description of the objective, supports strings with LOC ---@param Target table # Objective data, see the description function ReclaimProp(Type, Complete, Title, Description, Target) - Target.reclaimed = 0 - Target.total = table.getn(Target.Wrecks) + local current = 0 + local required = table.getn(Target.Wrecks) - local image = GetActionIcon("reclaim") - local objective = AddObjective(Type, Complete, Title, Description, image) - - -- Call ManualResult - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end + local image = GetActionIcon("Reclaim") + local objective = AddObjective(Type, Complete, Title, Description, image, Target) local function OnPropKilled(unit) - objective.Active = false objective:OnResult(false) - UpdateObjective(Title, 'complete', 'failed', objective.Tag) end local function OnPropReclaimed(unit) @@ -695,29 +1356,19 @@ function ReclaimProp(Type, Complete, Title, Description, Target) return end - Target.reclaimed = Target.reclaimed + 1 - local progress = string.format('(%s/%s)', Target.reclaimed, Target.total) - objective:OnProgress(Target.reclaimed, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - if Target.reclaimed == Target.total then - objective.Active = false + current = current + 1 + objective:OnProgress(current, required) + if current == required then objective:OnResult(true) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end for _, wreck in Target.Wrecks do - -- Mark the units if MarkUnits == true - if Target.MarkUnits then - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - end Triggers.CreatePropReclaimedTrigger(OnPropReclaimed, wreck) Triggers.CreatePropKilledTrigger(OnPropKilled, wreck) end - local progress = string.format('(%s/%s)', Target.reclaimed, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required) return objective end @@ -738,26 +1389,22 @@ end ---@param Target table # Objective data, see the description ---@return Objective function Locate(Type, Complete, Title, Description, Target) - Target.located = 0 - Target.total = table.getn(Target.Units) + local current = 0 + local required = table.getn(Target.Units) local isLocated = {} - local image = GetActionIcon("locate") + local image = GetActionIcon("Locate") local objective = AddObjective(Type, Complete, Title, Description, image, Target) objective.OnUnitLocated = function(unit) if isLocated[unit] or not objective.Active then return end - Target.located = Target.located + 1 + current = current + 1 isLocated[unit] = true - local progress = string.format('(%s/%s)', Target.located, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - objective:OnProgress(Target.located, Target.total) - if Target.located == Target.total then - objective.Active = false + objective:OnProgress(current, required) + if current == required then objective:OnResult(true) - UpdateObjective(Title, 'complete', "complete", objective.Tag) end end @@ -773,8 +1420,7 @@ function Locate(Type, Complete, Title, Description, Target) CreateIntelTriggers(unit, objective) end - local progress = string.format('(%s/%s)', Target.located, Target.total) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required) return objective end @@ -798,45 +1444,27 @@ function SpecificUnitsInArea(Type, Complete, Title, Description, Target) local image = GetActionIcon('Move') local objective = AddObjective(Type, Complete, Title, Description, image, Target) local total = table.getn(Target.Units) - local numRequired = Target.NumRequired or total - Target.Count = 0 - - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end + local required = Target.NumRequired or total + local current = 0 + ---@param units Unit[] + ---@param rect Rectangle local function WatchArea(units, rect) while objective.Active do local cnt = 0 - for _, unit in units do - if not unit.Dead then - if ScenarioUtils.InRect(unit:GetPosition(), rect) then - cnt = cnt + 1 - end + for _, unit in pairs(units) do + if not unit.Dead and ScenarioUtils.InRect(unit:GetPosition(), rect) then + cnt = cnt + 1 end end - if cnt ~= Target.Count then - Target.Count = cnt - local progress = string.format('(%s/%s)', Target.Count, numRequired) - objective:OnProgress(Target.Count, numRequired) - if Target.ShowProgress then - UpdateObjective(Title, 'Progress', progress, objective.Tag) - end + if cnt ~= current then + current = cnt + objective:OnProgress(current, required) end - if cnt >= numRequired then - objective.Active = false + if cnt >= required then objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) return end WaitTicks(5) @@ -844,23 +1472,15 @@ function SpecificUnitsInArea(Type, Complete, Title, Description, Target) end local rect = ScenarioUtils.AreaToRect(Target.Area) - local w = rect.x1 - rect.x0 - local h = rect.y1 - rect.y0 - local x = rect.x0 + (w / 2.0) - local z = rect.y0 + (h / 2.0) - - if Target.MarkArea then - objective.Decals[Target.Area] = CreateObjectiveDecal(x, z, w, h) - end - local watchThread = ForkThread(WatchArea, Target.Units, rect) objective.OnUnitKilled = function(unit) + if not objective.Active then return end + total = total - 1 - if objective.Active and total < numRequired then - objective.Active = false + + if total < required then objective:OnResult(false, unit) - UpdateObjective(Title, 'complete', 'failed', objective.Tag) KillThread(watchThread) end end @@ -877,10 +1497,7 @@ function SpecificUnitsInArea(Type, Complete, Title, Description, Target) CreateTriggers(unit, objective, true) end - if Target.ShowProgress then - local progress = string.format('(%s/%s)', Target.Count, numRequired) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - end + objective:UpdateProgress(current, required) return objective end @@ -915,123 +1532,62 @@ function CategoriesInArea(Type, Complete, Title, Description, Action, Target) local objective = AddObjective(Type, Complete, Title, Description, image, Target) local lastReqsMet = 0 - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - - local function WatchArea(requirements) + local function WatchArea(requirements, markUnits) local totalReqs = table.getn(requirements) while objective.Active do local reqsMet = 0 - for i, requirement in requirements do - local units = GetUnitsInRect(requirement.Rect) + for _, requirement in pairs(requirements) do + local armiesSpecified = requirement.ArmiesSpecified + local armiesList = requirement.ArmiesList local cnt = 0 - local ArmiesList = CreateArmiesList(requirement.Armies) + local units = GetUnitsInRect(requirement.Rect) + if units then - for _, unit in units do - if not unit.Dead and not unit:IsBeingBuilt() then - if not (requirement.ArmyIndex or requirement.Armies) or (requirement.ArmyIndex == unit.Army) or ArmiesList[unit.Army] then - if EntityCategoryContains(requirement.Category, unit) then - if not unit.Marked and objective.MarkUnits then - unit.Marked = true - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - objective:AddUnitTarget(unit) - end - cnt = cnt + 1 - end - end + for _, unit in pairs(units) do + if unit.Dead or unit:IsBeingBuilt() or (armiesSpecified and not armiesList[unit.Army]) or not EntityCategoryContains(requirement.Category, unit) then + continue end + + if not unit.Marked and markUnits then + unit.Marked = true + objective:AddUnitTarget(unit, true) + end + cnt = cnt + 1 end end + if requirement.CompareFunc(cnt, requirement.Value) then - reqsMet = reqsMet +1 + reqsMet = reqsMet + 1 end end if lastReqsMet ~= reqsMet then - local progress = string.format('(%s/%s)', reqsMet, totalReqs) - UpdateObjective(Title, 'Progress', progress, objective.Tag) objective:OnProgress(reqsMet, totalReqs) lastReqsMet = reqsMet end if reqsMet == totalReqs then - objective.Active = false objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) return end WaitTicks(10) end end - for _, requirement in Target.Requirements do - local rect = ScenarioUtils.AreaToRect(requirement.Area) - - local w = rect.x1 - rect.x0 - local h = rect.y1 - rect.y0 - local x = rect.x0 + (w / 2.0) - local z = rect.y0 + (h / 2.0) - - if Target.MarkArea and not objective.Decals[requirement.Area] then - local decal = CreateObjectiveDecal(x, z, w, h) - objective.Decals[requirement.Area] = decal - elseif Target.FlashVisible then - FlashViz(requirement.Area) - end - - if Target.MarkUnits then - objective.MarkUnits = true - end - - local reqRef = requirement - reqRef.Rect = rect - reqRef.CompareFunc = GetCompareFunc(requirement.CompareOp) + for _, requirement in pairs(Target.Requirements) do + requirement.Rect = ScenarioUtils.AreaToRect(requirement.Area) + requirement.ArmiesList = makeArmyList(requirement) + requirement.ArmiesSpecified = not table.empty(requirement.ArmiesList) + requirement.CompareFunc = GetCompareFunc(requirement.CompareOp) end - UpdateObjective(Title, 'Progress', string.format('(0/%d)', table.getsize(Target.Requirements)), objective.Tag) - ForkThread(WatchArea, Target.Requirements) + objective:UpdateProgress(0, table.getsize(Target.Requirements)) + ForkThread(WatchArea, Target.Requirements, Target.MarkUnits) return objective end -function CreateArmiesList(armies) - if not armies then - return {} - end - - local armiesList = {} - for _, armyName in armies do - if type(armyName) ~= 'string' then - error('SimObjectives error: Armies in requirements need to be of type string, provided type: ' .. type(armyName)) - end - if armyName == 'HumanPlayers' then - local tblArmy = ListArmies() - for iArmy, strArmy in pairs(tblArmy) do - if ScenarioInfo.ArmySetup[strArmy].Human then - armiesList[ScenarioInfo.ArmySetup[strArmy].ArmyIndex] = true - end - end - elseif ScenarioInfo.ArmySetup[armyName] then - armiesList[ScenarioInfo.ArmySetup[armyName].ArmyIndex] = true - else - error('SimObjectives error: Army doesnt exist: ' .. armyName) - end - end - - return armiesList -end - --- Adds an army stat objective, used to compare number of total units, resources, etc --- | Objective data | Description | --- | --------------------- | ------------- | @@ -1051,36 +1607,34 @@ end function ArmyStatCompare(Type, Complete, Title, Description, Action, Target) local image = GetActionIcon(Action) local objective = AddObjective(Type, Complete, Title, Description, image, Target) - local armyBrainsList = MakeListFromTarget(Target) + local armyBrainsList = makeArmyList(Target) + ---@param statName ArmyStatistic + ---@param aibrains table + ---@param compareFunc fun(a, b): boolean + ---@param value number + ---@param category EntityCategory local function WatchStat(statName, aibrains, compareFunc, value, category) local oldVal while objective.Active do - local result = false local testVal = 0 - for brain, _ in aibrains do + for _, brain in pairs(aibrains) do if category then testVal = testVal + brain:GetBlueprintStat(statName, category) else - testVal = testVal + brain:GetArmyStat(statName, value).Value + testVal = testVal + brain:GetArmyStat(statName--[[@as AIBrainBlueprintStatEconomy]], value).Value end end - if Target.ShowProgress then - if testVal ~= oldVal then - local progress = string.format('(%s/%s)', testVal, value) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - oldVal = testVal - end + if testVal ~= oldVal then + oldVal = testVal + objective:OnProgress(testVal, value) end - result = compareFunc(testVal, value) - if result then - objective.Active = false + if compareFunc(testVal, value) then objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) return end WaitTicks(5) @@ -1092,50 +1646,9 @@ function ArmyStatCompare(Type, Complete, Title, Description, Action, Target) ForkThread(WatchStat, Target.StatName, armyBrainsList, op, Target.Value, Target.Category) end - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - return objective end -function MakeListFromTarget(Target) - local resultList = {} - if Target.Army then - resultList[GetArmyBrain(Target.Army)] = true - end - - if Target.Armies then - local tblArmy = ListArmies() - for _, armyName in Target.Armies do - if armyName == "HumanPlayers" then - for iArmy, strArmy in pairs(tblArmy) do - --In campaign, if an AI mod is being used to provide an AI 'player', then this should also be included otherwise it can render some missions impossible to complete - if ScenarioInfo.ArmySetup[strArmy].Human or (ScenarioInfo.type == 'campaign_coop' and string.sub(strArmy, 1, 6) == 'Player') then - resultList[GetArmyBrain(iArmy)] = true - end - end - else - for iArmy, strArmy in pairs(tblArmy) do - if strArmy == armyName then - resultList[GetArmyBrain(iArmy)] = true - end - end - end - - end - end - return resultList -end - --- Adds an unit stat objective, used to compare number statistics of a given unit --- | Objective data | Description | --- | --------------------- | ------------- | @@ -1158,9 +1671,7 @@ function UnitStatCompare(Type, Complete, Title, Description, Action, Target) local function WatchStat(statName, unit, compareFunc, value) while objective.Active do if compareFunc(unit:GetStat(statName, value).Value, value) then - objective.Active = false objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) return end WaitTicks(5) @@ -1194,18 +1705,21 @@ end function CategoryStatCompare(Type, Complete, Title, Description, Action, Target) local image = GetActionIcon(Action) local objective = AddObjective(Type, Complete, Title, Description, image, Target) - local armyBrainsList = MakeListFromTarget(Target) + local armyBrainsList = makeArmyList(Target) + ---@param statName string + ---@param aibrains table + ---@param category EntityCategory + ---@param compareFunc fun(a, b): boolean + ---@param value number local function WatchStat(statName, aibrains, category, compareFunc, value) while objective.Active do - for brain, _ in aibrains do + for _, brain in pairs(aibrains) do local unitsInCategory = brain:GetListOfUnits(category, false) if unitsInCategory then - for _, unit in unitsInCategory do + for _, unit in pairs(unitsInCategory) do if compareFunc(unit:GetStat(statName, value).Value, value) then - objective.Active = false objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) return end end @@ -1237,41 +1751,29 @@ end ---@param Complete ObjectiveStatus # Completion status, usually this is 'incomplete' unless the player already completed it by chance ---@param Title string # Title of the objective, supports strings with LOC ---@param Description string # Description of the objective, supports strings with LOC ----@param Target table # Objective data, see the description +---@param Target ObjectiveTarget # Objective data, see the description ---@return Objective function Protect(Type, Complete, Title, Description, Target) - - local image = GetActionIcon("protect") + local image = GetActionIcon("Protect") local objective = AddObjective(Type, Complete, Title, Description, image, Target) - local total = table.getn(Target.Units) - local max = total - local numRequired = Target.NumRequired or total - local timer = nil + local current = table.getn(Target.Units) + local total = current + local required = Target.NumRequired or current + + if not Target.ExpireResult then + Target.ExpireResult = "complete" + end objective.OnUnitKilled = function(unit) if not objective.Active then return end - total = total - 1 - objective:OnProgress(total, numRequired) - - if Target.ShowProgress then - local progress = string.format('(%s/%s)', total, numRequired) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - elseif Target.PercentProgress then - local progress = string.format('(%s%%)', math.ceil(total / max * 100)) - UpdateObjective(Title, 'Progress', progress, objective.Tag) - end + current = current - 1 + objective:OnProgress(current, required, total) - if objective.Active and total < numRequired then - objective.Active = false + if current < required then objective:OnResult(false, unit) - UpdateObjective(Title, 'complete', 'failed', objective.Tag) - Sync.ObjectiveTimer = 0 - if timer then - KillThread(timer) - end end end @@ -1283,42 +1785,7 @@ function Protect(Type, Complete, Title, Description, Target) CreateTriggers(newUnit, objective, true) end - local function onTick(newTime) - UpdateObjective(Title, 'timer', {Time = newTime}, objective.Tag) - end - - local function OnExpired() - if objective.Active then - objective.Active = false - objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) - end - Sync.ObjectiveTimer = 0 - end - - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - - if Target.Timer then - timer = import("/lua/scenariotriggers.lua").CreateTimerTrigger( - OnExpired, - Target.Timer, - true, - true, - onTick - ) - end - - for _, unit in Target.Units do + for _, unit in pairs(Target.Units) do if not unit.Dead then CreateTriggers(unit, objective, true) else @@ -1327,75 +1794,33 @@ function Protect(Type, Complete, Title, Description, Target) end if Target.ShowProgress then - local progress = string.format('(%s/%s)', total, numRequired) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required) elseif Target.PercentProgress then - local progress = string.format('(%s%%)', math.ceil(total / max * 100)) - UpdateObjective(Title, 'Progress', progress, objective.Tag) + objective:UpdateProgress(current, required, total) end return objective end ---- Adds a timer objective ---- | Objective data | Description | ---- | --------------------- | ------------- | ---- | ShowProgress | Flag to update the description of the objective in the UI ---- | Timer | Time to indicate how long you need to protect the units ---- | ExpireResult | Either 'complete' or 'failed' ----@param Type ObjectiveType # Type of objective, used for the strategic icon in the UI ----@param Complete ObjectiveStatus # Completion status, usually this is 'incomplete' unless the player already completed it by chance ----@param Title string # Title of the objective, supports strings with LOC ----@param Description string # Description of the objective, supports strings with LOC ----@param Target table # Objective data, see the description +---@class ObjectiveTimerTarget +---@field Timer integer In seconds, after which the objective ends with specified `ExpireResult` +---@field ExpireResult? "complete" | "failed" How should the objective end when the timer expires. Defaults to `"failed"` + +--- Adds a timer objective that finishes when the time runs out. +---@param Type ObjectiveType # Type of objective, used for the strategic icon in the UI +---@param Complete ObjectiveStatus # Completion status, usually this is 'incomplete' unless the player already completed it by chance +---@param Title string # Title of the objective, supports strings with LOC +---@param Description string # Description of the objective, supports strings with LOC +---@param Target ObjectiveTimerTarget # Objective data, see the description ---@return Objective function Timer(Type, Complete, Title, Description, Target) - local image = GetActionIcon("timer") + local image = GetActionIcon("Timer") local objective = AddObjective(Type, Complete, Title, Description, image, Target) - local timer = nil - - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - Sync.ObjectiveTimer = 0 - KillThread(timer) - end - - local function onTick(newTime) - UpdateObjective(Title, 'timer', {Time = newTime}, objective.Tag) - end - - local function OnExpired() - objective.Active = false - if Target.ExpireResult == 'complete' then - objective:OnResult(true) - UpdateObjective(Title, 'complete', 'complete', objective.Tag) - else - objective:OnResult(false) - UpdateObjective(Title, 'complete', 'failed', objective.Tag) - end - Sync.ObjectiveTimer = 0 - end - - timer = import("/lua/scenariotriggers.lua").CreateTimerTrigger( - OnExpired, - Target.Timer, - false, - true, - onTick - ) return objective end ---- Adds an unknown objective +--- Adds an unknown objective that can be specified later by adding target ---@param Type ObjectiveType # Type of objective, used for the strategic icon in the UI ---@param Complete ObjectiveStatus # Completion status, usually this is 'incomplete' unless the player already completed it by chance ---@param Title string # Title of the objective, supports strings with LOC @@ -1404,18 +1829,6 @@ end function Unknown(Type, Complete, Title, Description) local objective = AddObjective(Type, Complete, Title, Description) - objective.ManualResult = function(self, result) - self.Active = false - self:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, self.Tag) - end - return objective end @@ -1435,733 +1848,14 @@ end function Basic(Type, Complete, Title, Description, Image, Target) local objective = AddObjective(Type, Complete, Title, Description, Image, Target) - objective.ManualResult = function(self, result) - objective.Active = false - objective:OnResult(result) - local resultStr - if result then - resultStr = 'complete' - else - resultStr = 'failed' - end - UpdateObjective(Title, 'complete', resultStr, objective.Tag) - end - - objective.AddBasicUnitTarget = function(self, unit) - objective:AddUnitTarget(unit) - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - table.insert(objective.UnitMarkers, arrow) - end - - objective.AddTarget = function(self, target) - if target.Area then - local rect = ScenarioUtils.AreaToRect(target.Area) - - local w = rect.x1 - rect.x0 - local h = rect.y1 - rect.y0 - local x = rect.x0 + (w / 2.0) - local z = rect.y0 + (h / 2.0) - - if target.MarkArea then - objective.Decals[target.Area] = CreateObjectiveDecal(x, z, w, h) - end - if Target.FlashVisible then - FlashViz(target.Area) - end - end - if target.Areas and target.MarkArea then - for _, v in target.Areas do - local rect = ScenarioUtils.AreaToRect(v) - - local w = rect.x1 - rect.x0 - local h = rect.y1 - rect.y0 - local x = rect.x0 + (w / 2.0) - local z = rect.y0 + (h / 2.0) - - objective.Decals[v] = CreateObjectiveDecal(x, z, w, h) - if Target.FlashVisible then - FlashViz(v) - end - end - end - if target.Units then - if target.MarkUnits then - for _, unit in target.Units do - if not unit.Dead then - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - table.insert(objective.UnitMarkers, arrow) - if target.AlwaysVisible then - SetupVizMarker(self, unit) - end - if Target.FlashVisible then - FlashViz(unit) - end - end - end - end - end - end - - if Target then - objective:AddTarget(Target) - end - - return objective -end - - --- Adds and tracks an objective, should not be used directly ----@param Type ObjectiveType ----@param Complete Objective ----@param Title string ----@param Description string ----@param ActionImage string ----@param Target table ----@param IsLoading boolean ----@param loadedTag string ----@return Objective -function AddObjective(Type, -- 'primary', 'bonus', etc - Complete, -- 'complete', 'incomplete' - Title, -- e.g. "Destroy Radar Stations" - Description, -- e.g. "A reason why you need to destroy the radar stations" - ActionImage, -- '/textures/ui/common/missions/mission1.dds' - Target, -- Can be one of: - -- Units = {unit1, unit2, ...} - -- Areas = {'areaName1', 'areaName2', ...} - IsLoading, -- Are we loading a saved game? - loadedTag -- If IsLoading is specified, whats the tag? - ) - - if not Sync.ObjectivesTable then - Sync.ObjectivesTable = {} - end - - local tag - - if IsLoading then - tag = loadedTag - else - tag = 'Objective' .. objNum - objNum = objNum + 1 - table.insert(SavedList, {AddArgs = {Type, Complete, Title, Description, ActionImage, Target, true, tag, n=8}, Tag=tag}) - end - - ---@type Objective - local objective = { - -- Used to synchronize sim objectives with user side objectives - Tag = tag, - - -- Whether the objective is in progress or not and does not indicate success or failure. - Active = true, - - -- success or failure. - Complete = false, - - -- Hide the objective from the screen - Hidden = Target.Hidden, - - -- Decal table, keyd by area names - Decals = {}, - - -- Unit arrow table - UnitMarkers = {}, - - -- Visibility markers that we manage - VizMarkers = {}, - - -- Single decal - Decal = false, - - -- Strategic icon overrides - IconOverrides = {}, - - -- For tracking targets - NextTargetTag = 0, - PositionUpdateThreads = {}, - - Title = Title, - Description = Description, - - SimStartTime = GetGameTimeSeconds(), - - -- Called on success or failure - ResultCallbacks = {}, - AddResultCallback = function(self, cb) - table.insert(self.ResultCallbacks, cb) - end, - - -- Some objective types can provide progress updates (not success/fail) - ProgressCallbacks = {}, - AddProgressCallback = function(self, cb) - table.insert(self.ProgressCallbacks, cb) - end, - - -- Dont override these if you want notification. Call Add???Callback - -- intead - OnResult = function(self, success, data) - self.Complete = success - - -- Destroy decals - for _, v in self.Decals do v:Destroy() end - - -- Destroy unit marker things - for _, v in self.UnitMarkers do - v:Destroy() - end - - -- Revert strategic icons - for _, v in self.IconOverrides do - if not v:BeenDestroyed() then - v:SetStrategicUnderlay("") - end - end - - -- Destroy visibility markers - for _, v in self.VizMarkers do - v:Destroy() - end - - if self.PositionUpdateThreads then - for k, v in self.PositionUpdateThreads do - if v then - KillThread(self.PositionUpdateThreads[k]) - self.PositionUpdateThreads[k] = false - end - end - end - - for _, v in self.ResultCallbacks do v(success, data) end - end, - - OnProgress = function(self, current, total) - for _, v in self.ProgressCallbacks do v(current, total) end - end, - - -- Call this to manually fail the objective - Fail = function(self) - self.Active = false - self:OnResult(false) - UpdateObjective(self.Title, 'complete', 'failed', self.Tag) - end, - - AddUnitTarget = function(self, unit) end, -- defined below - AddAreaTarget = function(self, area) end, -- defined below - } - - -- Takes a unit that is an objective target and uses its recon detect - -- event to notify the objectives that we have a blip for the unit. - local function SetupNotify(obj, unit, targetTag) - -- Add a detectedBy callback to notify the user layer when our recon - -- on the target comes in and out. - local detectedByCB = function(cbunit, armyindex) - if not obj.Active then - return - end - - -- now if weve been detected by the focus army ... - if armyindex == GetPlayerArmy() then - -- get the blip that is associated with the unit - local blip = cbunit:GetBlip(armyindex) - - -- Only provide the target position to the user layer if - -- the blip IsSeenEver() (i.e. has been identified). - obj.PositionUpdateThreads[targetTag] = ForkThread( - function() - while obj.Active do - WaitTicks(10) - if blip:BeenDestroyed() then - return - end - - if blip:IsSeenEver(armyindex) then - UpdateObjective(Title, - 'Target', - { - Type = 'Position', - Value = blip:GetPosition(), - BlueprintId = blip:GetBlueprint().BlueprintId, - TargetTag=targetTag - }, - obj.Tag) - - -- If it's not mobile we can exit the thread since - -- the blip won't move. - if not unit.Dead and not unit:BeenDestroyed() and not EntityCategoryContains(categories.MOBILE, unit) then - return - end - end - end - end - ) - - local destroyCB = function(cbblip) - if not obj.Active then - return - end - - if obj.PositionUpdateThreads[targetTag] then - KillThread(obj.PositionUpdateThreads[targetTag]) - obj.PositionUpdateThreads[targetTag] = false - end - - -- When the blip is destroyed, tell objectives we dont - -- have a blip anymore. This doesnt necessarily mean the - -- unit is killed, we simply lost the blip. - UpdateObjective(Title, - 'Target', - { - Type = 'Position', - Value = nil, - BlueprintId = nil, - TargetTag=targetTag, - }, - obj.Tag) - end - -- When the blip is destroyed, have it call this callback - -- function (defined above) - blip:AddDestroyHook(destroyCB) - end - end - -- When the unit is detected by an army, have it call this callback - -- function (defined above) - unit:AddDetectedByHook(detectedByCB) - - -- See if we can detect the unit right now - local blip = unit:GetBlip(GetPlayerArmy()) - if blip then - detectedByCB(unit, GetPlayerArmy()) - end - end - - -- Take an objective target unit that is owned by the focus army - -- Info passed to user layer to handle zoom to button and chiclet image - function SetupFocusNotify(obj, unit, targetTag) - obj.PositionUpdateThreads[targetTag] = ForkThread( - function() - while obj.Active do - if unit:BeenDestroyed() then - return - end - - UpdateObjective(Title, 'Target', - { - Type = 'Position', - Value = unit:GetPosition(), - BlueprintId = unit:GetBlueprint().BlueprintId, - TargetTag=targetTag - }, - obj.Tag) - - -- If it's not mobile we can exit the thread since the unit won't move. - if not unit.Dead and not unit:BeenDestroyed() and not EntityCategoryContains(categories.MOBILE, unit) then - return - end - - WaitTicks(10) - end - end - ) - - local destroyCB = function() - if not obj.Active then - return - end - - if obj.PositionUpdateThreads[targetTag] then - KillThread(obj.PositionUpdateThreads[targetTag]) - obj.PositionUpdateThreads[targetTag] = false - end - - -- when the blip is destroyed, tell objectives we dont - -- have a blip anymore. This doesnt necessarily mean the - -- unit is killed, we simply lost the blip. - UpdateObjective(Title, 'Target', - { - Type = 'Position', - Value = nil, - BlueprintId = nil, - TargetTag=targetTag, - }, - obj.Tag) - end - -- When the unit is destroyed have it call this callback - -- function (defined above) - Triggers.CreateUnitDeathTrigger(destroyCB, unit) - end - - function SetupVizMarker(objective, object) - if IsEntity(object) then - local pos = object:GetPosition() - local spec = { - X = pos[1], - Z = pos[2], - Radius = 8, - LifeTime = -1, - Omni = false, - Vision = true, - Army = GetPlayerArmy(), - } - local vizmarker = VizMarker(spec) - object.Trash:Add(vizmarker) - vizmarker:AttachBoneTo(-1, object, -1) - else - local rect = ScenarioUtils.AreaToRect(Target.Area) - local width = rect.x1 - rect.x0 - local height = rect.y1 - rect.y0 - local spec = { - X = rect.x0 + width/2, - Z = rect.y0 + height/2, - Radius = math.max(width, height), - LifeTime = -1, - Omni = false, - Vision = true, - Army = GetPlayerArmy(), - } - local vizmarker = VizMarker(spec) - table.insert(objective.VizMarkers, vizmarker); - end - end - - function FlashViz (object) - if IsEntity(object) then - local pos = object:GetPosition() - local spec = { - X = pos[1], - Z = pos[2], - Radius = 2, - LifeTime = 1.00, - Omni = false, - Vision = true, - Radar = false, - Army = GetPlayerArmy(), - } - local vizmarker = VizMarker(spec) - object.Trash:Add(vizmarker) - vizmarker:AttachBoneTo(-1, object, -1) - else - local rect = ScenarioUtils.AreaToRect(object) - local width = rect.x1 - rect.x0 - local height = rect.y1 - rect.y0 - local spec = { - X = rect.x0 + width/2, - Z = rect.y0 + height/2, - Radius = math.max(width, height), - LifeTime = 0.01, - Omni = false, - Vision = true, - Radar = false, - Army = GetPlayerArmy(), - } - local vizmarker = VizMarker(spec) - end - end - - local userTargets = {} - if Target.ShowFaction then - if Target.ShowFaction == 'Cybran' then - Target.Image = '/textures/ui/common/faction_icon-lg/cybran_ico.dds' - elseif Target.ShowFaction == 'Aeon' then - Target.Image = '/textures/ui/common/faction_icon-lg/aeon_ico.dds' - elseif Target.ShowFaction == 'UEF' then - Target.Image = '/textures/ui/common/faction_icon-lg/uef_ico.dds' - elseif Target.ShowFaction == 'Seraphim' then - Target.Image = '/textures/ui/common/faction_icon-lg/seraphim_ico.dds' - end - end - - if Target and Target.Requirements then - for _, req in Target.Requirements do - if req.Area then - table.insert(userTargets, {Type = 'Area', Value = ScenarioUtils.AreaToRect(req.Area)}) - end - end - elseif Target and Target.Timer then - userTargets = {Type = 'Timer', Time = Target.Timer} - end - - if Target.Category then - local bps = EntityCategoryGetUnitList(Target.Category) - if not table.empty(bps) then - table.insert(userTargets, {Type = 'Blueprint', BlueprintId = bps[1]}) - end - end - - local userObjectiveData = { - tag = tag, - type = Type, - complete = Complete, - hidden = Target.Hidden, - title = Title, - description = Description, - actionImage = ActionImage, - targetImage = Target.Image, - progress = "", - targets = userTargets, - loading = IsLoading, - StartTime = objective.SimStartTime, - } - - Sync.ObjectivesTable[tag] = userObjectiveData - - objective.AddUnitTarget = function(self, unit) - self.NextTargetTag = self.NextTargetTag + 1 - if unit.Army == GetPlayerArmy() then - SetupFocusNotify(self, unit, self.NextTargetTag) - else - SetupNotify(self, unit, self.NextTargetTag) - end - if Target.AlwaysVisible then - SetupVizMarker(self, unit) - end - - -- Mark the units unless MarkUnits == false - if Target.MarkUnits == nil or Target.MarkUnits then - if Type == 'primary' then - unit:SetStrategicUnderlay('icon_objective_primary') - elseif Type == 'secondary' then - unit:SetStrategicUnderlay('icon_objective_secondary') - elseif Type == 'bonus' then - unit:SetStrategicUnderlay('icon_objective_bonus') - end - table.insert(self.IconOverrides, unit) - end - end - - objective.AddAreaTarget = function(self, area) - self.NextTargetTag = self.NextTargetTag + 1 - UpdateObjective(Title, - 'Target', - { - Type = 'Area', - Value = ScenarioUtils.AreaToRect(area), - TargetTag=self.NextTargetTag - }, - self.Tag) - - if Target.AlwaysVisible then - SetupVizMarker(self, area) - end - end - - if Target then - if Target.Units then - for _, v in Target.Units do - if v and v.IsDead and not v.Dead then - objective:AddUnitTarget(v) - end - end - end - - if Target.Unit and not Target.Unit.Dead then - objective:AddUnitTarget(Target.Unit) - end - - if Target.Areas then - for _, v in Target.Areas do - objective:AddAreaTarget(v) - end - end - - if Target.Area then - objective:AddAreaTarget(Target.Area) - end - end - return objective end -function DeleteObjective(Objective, IsLoading) - local userObjectiveUpdate = { - tag = Objective.Tag, - updateField = 'delete', - } - if not IsLoading then - table.insert(SavedList, {DeleteArgs = userObjectiveUpdate}) - end - table.insert(Sync.ObjectivesUpdateTable, userObjectiveUpdate) -end - --- Update legacy style objective using correct syntax -function UpdateBasicObjective(Objective, UpdateField, NewData) - UpdateObjective(Objective.Title, UpdateField, NewData, Objective.Tag) -end - --- Updates an objective, referencing it by objective title -function UpdateObjective(Title, UpdateField, NewData, objTag, IsLoading, InTime) - - if objTag == 'Invalid' then - return - end - - if not Sync.ObjectivesUpdateTable then - Sync.ObjectivesUpdateTable = {} - end - - if type(objTag) ~= 'string' then - error('SimObjectives error: Invalid type for objTag in UpdateObjective. String expected but got ' - .. type(objTag), 2) - end - if type(UpdateField) ~= 'string' then - error('SimObjectives error: Invalid type for UpdateField in UpdateObjective. String expected but got ' .. type(UpdateField), 2) - end - - if not IsLoading then - table.insert(SavedList, {UpdateArgs = {Title, UpdateField, NewData, objTag, true, GetGameTimeSeconds(), n=6}, Tag=objTag}) - end - - -- All fields are stored with lowercase names - UpdateField = string.lower(UpdateField) - if not ( - (UpdateField == 'type') or - (UpdateField == 'complete') or - (UpdateField == 'title') or - (UpdateField == 'description') or - (UpdateField == 'image') or - (UpdateField == 'progress') or - (UpdateField == 'target') or - (UpdateField == 'timer') or - (UpdateField == 'delete') - ) - then - error('Unknown UpdateField: ' .. UpdateField .. '. Cannot process UpdateObjective request.', 2) - else - local userObjectiveUpdate = { - title = Title, - updateField = UpdateField, - updateData = NewData, - tag = objTag, - loading = IsLoading, - } - if UpdateField == 'complete' then - userObjectiveUpdate['time'] = InTime or GetGameTimeSeconds() - end - table.insert(Sync.ObjectivesUpdateTable, userObjectiveUpdate) - end -end - -function GetCompareFunc(op) - function gt(a, b) return a > b end - function lt(a, b) return a < b end - function gte(a, b) return a >= b end - function lte(a, b) return a <= b end - function eq(a, b) return a == b end - - if op == '<=' then return lte end - if op == '>=' then return gte end - if op == '<' then return lt end - if op == '>' then return gt end - if op == '==' then return eq end - - WARN("Unsupported CompareOp '", op, "'") -end - -function GetActionIcon(actionString) - local action = string.lower(actionString) - if action == "kill" then return "/game/orders/attack_btn_up.dds" end - if action == "capture" then return "/game/orders/convert_btn_up.dds" end - if action == "build" then return "/game/orders/production_btn_up.dds" end - if action == "protect" then return "/game/orders/guard_btn_up.dds" end - if action == "timer" then return "/game/orders/guard_btn_up.dds" end - if action == "move" then return "/game/orders/move_btn_up.dds" end - if action == "reclaim" then return "/game/orders/reclaim_btn_up.dds" end - if action == "repair" then return "/game/orders/repair_btn_up.dds" end - if action == "locate" then return "/game/orders/omni_btn_up.dds" end - if action == "group" then return "/game/orders/move_btn_up.dds" end - if action == "killorcapture" then return "/game/orders/attack_capture_btn_up.dds" end - - return "" -end - -function IsComplete(obj) - if obj then - if obj.Complete then - return true - end - end - - return false -end - -function OnPostLoad() - for _, v in SavedList do - if v.AddArgs then - AddObjective(unpack(v.AddArgs)) - elseif v.UpdateArgs then - UpdateObjective(unpack(v.UpdateArgs)) - elseif v.DeleteArgs then - DeleteObjective(v.DeleteArgs, true) - end - end -end - -function CreateObjectiveDecal(x, z, w, h) - return CreateDecal(Vector(x, 0, z), 0, objectiveDecal, '', 'Water Albedo', w, h, DecalLOD, 0, 1, 0) -end - -function OnUnitGivenBase(objective, target, unit, newUnit, markUnits) - local index = -1 - if unit then - for i, v in target.Units do - if v == unit then - index = i - break - end - end - end - if index > 0 then - table.remove(target.Units, index) - end - table.insert(target.Units, newUnit) - BasicUnitTarget(objective, newUnit, markUnits) -end - -function BasicUnitTarget(objective, unit, markUnits) - objective:AddUnitTarget(unit) - if markUnits then - local ObjectiveArrow = import("/lua/objectivearrow.lua").ObjectiveArrow - local arrow = ObjectiveArrow {AttachTo = unit} - table.insert(objective.UnitMarkers, arrow) - end -end - -function CreateTriggers(unit, objective, useOnKilledWhenReclaimed) - if objective.OnUnitGiven then - Triggers.CreateUnitGivenTrigger(objective.OnUnitGiven, unit) - end - if objective.OnUnitCaptured then - Triggers.CreateUnitCapturedTrigger(nil, objective.OnUnitCaptured, unit) - end - if objective.OnUnitKilled then - Triggers.CreateUnitDeathTrigger(objective.OnUnitKilled, unit) - end - if objective.OnUnitReclaimed then - Triggers.CreateUnitReclaimedTrigger(objective.OnUnitReclaimed, unit) - end - if useOnKilledWhenReclaimed then - Triggers.CreateUnitReclaimedTrigger(objective.OnUnitKilled, unit) - end -end - -function CreateIntelTriggers(unit, objective, isAlreadyLocated) - local IntelTrigger = import("/lua/scenariotriggers.lua").CreateArmyIntelTrigger - if objective.OnUnitGiven then - Triggers.CreateUnitGivenTrigger(objective.OnUnitGiven, unit) - end - if objective.OnUnitLocated then - if isAlreadyLocated then - return true - else - IntelTrigger(objective.OnUnitLocated, - GetArmyBrain(GetPlayerArmy()), - 'LOSNow', - unit, - true, - categories.ALLUNITS, - true, - unit:GetAIBrain()) - return false - end - end +-- Tracks completion of any objective added to it. When all or `numRequired` of objectives complete, `callback` is fired. +---@param name? string Defaults to `"ObjectiveGroupX"` where X is the number of unnamed objective groups created so far. +---@param callback? function Function to call when the group is completed. +---@param numRequired? integer Number of objective completed to complete the group. If not provided, all objectives have to be completed. +---@return ObjectiveGroup +function CreateGroup(name, callback, numRequired) + return ObjectiveGroup(name, callback, numRequired) end diff --git a/lua/sim/objectives/ObjectiveGroup.lua b/lua/sim/objectives/ObjectiveGroup.lua new file mode 100644 index 0000000000..f67c729b63 --- /dev/null +++ b/lua/sim/objectives/ObjectiveGroup.lua @@ -0,0 +1,144 @@ +local pairs = pairs +local tableGetSize = table.getsize +local ForkThread = ForkThread +local WaitSeconds, WaitTicks = WaitSeconds, WaitTicks + +local nextId = 1 + +---@alias ObjectiveGroupResultCallback fun(result: boolean) + +---Tracks a group of objectives and calls the callback when the group has completed successfully or failed. +---@class ObjectiveGroup +---@field Name string +---@field Objectives table +---@field NumRequired? integer +---@field OnComplete? ObjectiveGroupResultCallback +---@overload fun(name?: string, callback?: ObjectiveGroupResultCallback, numRequired?: integer): ObjectiveGroup +local OG = { + ---Flag to indicate the objective group is in progress + Active = true, + ---Number of objectives that have completed successfully. + NumCompleted = 0, + ---Number of objectives that have failed. + NumFailed = 0, + ---Delay in seconds before the OnComplete callback is called. + ResultDelay = 0, +} +---@param name string +---@param callback? ObjectiveGroupResultCallback +---@param numRequired? integer +---@protected +function OG:__init(name, callback, numRequired) + if not name then + name = "ObjectiveGroup" .. nextId + nextId = nextId + 1 + end + + self.Name = name + if callback then + self.OnComplete = callback + end + if numRequired then + self.NumRequired = numRequired + end + self.Objectives = {} +end +---Throws error if the objective group isnt active anymore. +---@protected +function OG:CheckActive() + if not self.Active then + error(string.format("ObjectiveGroup: `%s` is no longer active.", self.Name), 3) + end +end +---@param objective Objective +---@param result boolean Result of the finished objective +---@param data any Any data passed from the objective result +---@protected +function OG:OnObjectiveResult(objective, result, data) + if not (self.Active and self.Objectives[objective.Tag]) then + return + end + + if result then + self.NumCompleted = self.NumCompleted + 1 + else + self.NumFailed = self.NumFailed + 1 + end + + local total = tableGetSize(self.Objectives) + local required = self.NumRequired or total + + LOG(string.format("ObjectiveGroup: `%s` OnProgress (%d/%d)", self.Name, self.NumCompleted, required)) + + -- Check if we've finished enough objectives + if self.NumCompleted >= required then + self:OnResult(true) + -- Not all objectives have not been added yet. + elseif total < required then + return + -- Check if there's enough objectives left. + elseif total - self.NumFailed < required then + self:OnResult(false) + end +end +---Adds a new objective to track to this group. It does NOT change the number of required objective (if it was specified). +--- +---It is possible to add objective that is no longer active. +---@param objective Objective +function OG:AddObjective(objective) + self:CheckActive() + + if self.Objectives[objective.Tag] then + error(string.format("ObjectiveGroup: `%s` trying to add duplicate objective: %s - %s", self.Name, objective.Tag, objective.Title), 2) + end + + self.Objectives[objective.Tag] = objective + + if objective.Active then + objective:AddResultCallback(function(result, data) + self:OnObjectiveResult(objective, result, data) + end) + else + self:OnObjectiveResult(objective, objective.Complete) + end +end +---Removes the objective from this group. It does NOT change the number of required objective (if it was specified). +---@param objective Objective +function OG:RemoveObjective(objective) + if not self.Objectives[objective.Tag] or not objective.Active then + WARN(string.format("ObjectiveGroup: `%s`: Failed to remove untracked or inactive objective: %s - %s", self.Name, objective.Tag, objective.Title)) + return + end + + self.Objectives[objective.Tag] = nil +end +---@param result boolean +---@protected +function OG:OnResult(result) + if not self.Active then return end + + self.Active = false + if not self.OnComplete then return end + -- Delay the callback by `ResultDelay` or at least one tick, this ensures that the individual objective's callbacks are always fired first + ForkThread(function() + if self.ResultDelay > 0 then + WaitSeconds(self.ResultDelay) + else + WaitTicks(1) + end + self.OnComplete(result) + end) +end +---Manually ends all objectives in the group +---@param success boolean +function OG:ManualResult(success) + self:CheckActive() + + for _, objective in pairs(self.Objectives) do + if objective.Active then + objective:ManualResult(success) + end + end +end + +ObjectiveGroup = ClassSimple(OG)