------------------------------------------------------------ ------------------- AMBIENT LIFE --------------------------- ------------------------------------------------------------ -- Ambient Life units are spawned from AmbientZoneMarker. It has an area where they operate, -- a few SpawnDefs defining a range(min-max count) for each unit appearance to spawn -- and some other parameters. Once spawned their routine is set to "Ambient" around their zone. -- The ambient routine of an unit(during Idle command) looks for a visitible in its zone and -- goes to execute some actions there. This can be sitting on a chair, leaning a wall, paiting a wall, -- digging with a shovel, etc. Visitables on a map are declared as AmbientLifeMarker obecst. -- It specifies position and orientation for the unit to take when there and also what animation to -- play while visiting, e.g. sitting, painting, leaning. It can also specify a tool or weapon -- to attach to the unit while there. -- Ambient Life is spawned OnMsg.AmbientLifeSpawn and despawned OnMsg.AmbientLifeDespawn :) -- When spawning it goes through all AmbienZoneMarker objects and calls their :Spawn() method to -- populate the map with AL units. It also goes through all AmbineLifeMarker objects with perpetual -- units and steals them. -- Some of the important properties of the AmbientLifeMarker are the Teleport, AllowAL, ChanceSpawn -- along with the Conditions and GameStatesFilter. The later two are defining whether the marker -- is currently available for visit or not. ChanceSpawn is rolled out and when succeeds the spawned -- unit becomes perpetual unit for this marker(no other unit can visit it anymore). For a unit to -- become perpetual it also requires a Groups property to be defined so the marker to know from where -- "to steal" its unit from(either AmbientZoneMarker or UnitMarker). Perpetual units usually go along -- with the Teleport property which instructs the unit to teleport straight at the marker instead of -- walking to there. AllowAL=false means units from AmbientZoneMarker can't use this spot(only the -- ones spawned from UnitMarker can). -- All the visitables are stored in g_Visitable. While visiting a marker the unit reserves the spot -- so no other units can visit it(.reserved member in g_Visitables entry). During combat covers are also -- considered visitable spots but their reservation system is implemented in g_CoversReserved. ------------------------------------------------------------ ------------------- AMBIENT LIFE REPULSORS ----------------- ------------------------------------------------------------ -- There is AmbientLifeRepulsor marker which tries to not let AL units inside its area. Units are not -- spawned there and visitable markers are not picked inside the area. The path finding -- also should avoid such zones while walking. Hint: use Ctrl-9 and such repulsor zone will be painted green. ---------------------------------------------------------------- --------------- DESIGNING NEW AmbientLifeMarker ---------------- ---------------------------------------------------------------- -- ClassDef editor is usually used to define new AL marker behavior. The simple ones are -- copy-pasted/duplicated from exisiting markers, e.g. AL_PlayAnimVariation. Only new properties have to -- be changed for the new marker to work - its VisitIdle animation which says what the unit should play -- when the marker is reached. In addition there are VisitEnter/VisitExit properties for animations to be -- played once when entering/exiting the marker. VisitIdle can be played as long as required(indefinitely -- for perpetual units). All this logic is implemented in AmbientLifeMarker:Visit() method and should work -- for most markers taking care of playing animations, spawning/despawning tools and weapons, going to -- destination spot, etc. If some more sophistaced logic is required a new :Visit() method should be coded -- via the ClassDef editor. The most complex marker as of now should be AL_Football which spawns a secondary -- unit(the partner), plays some logic about passing the ball and *persisting* him in the savegame. ---------------------------------------------------------------- ----------------------- EFFECTS -------------------------------- ---------------------------------------------------------------- -- There are some effects which can be used from the Quests and Campaigns which operate on the AL: -- -- * ResetAmbientLife forces all Visit commands of the AL units present on the map to be restarted and -- a new spot to be picked up -- * ForceResetAmbientLife resets the whole AL on the map(as if using Alt-Shift-A(to toggle it twice) -- * ScatterAmbientLife forces scripted conflict -- * UnitsDespawnAmbientLife does as it sounds -- * UnitsAddToAmbientLife adds a group of units to AmbientZoneMarker as if they were spawned from it function AmbientLife_Random(self, max) return InteractionRand(max, "AmbientLife") end MapVar("g_Visitables", {}) MapVar("g_VisitRepulsors", {}) MapVar("g_RebuildRepulsors", false) DefineClass.AmbientLifeRepulsor = { __parents = {"GridMarker"}, properties = { { category = "Marker", id = "Reachable", no_edit = true, default = false, }, }, apply_pass = false, recalc_area_on_pass_rebuild = false, } function AmbientLifeRepulsor:Init() table.insert(g_VisitRepulsors, self) end function AmbientLifeRepulsor:Done() table.remove_entry(g_VisitRepulsors, self) if self.apply_pass then g_RebuildRepulsors = true end end function AmbientLifeRepulsor:GetEditorTypeText() return Untranslated("[AL Repulsor]") end function IsInAmbientLifeRepulsionZone(pos_or_obj, is_packed_pos) for _, repulsor in ipairs(g_VisitRepulsors) do if repulsor.apply_pass and repulsor:IsMarkerEnabled() then local area = repulsor:GetAreaBox() if is_packed_pos then if area:Point2DInside(point_unpack(pos_or_obj)) then return true end elseif area:Point2DInside(pos_or_obj) then return true end end end end function AmbientLifeRepulsor:RecalcAreaPositions() local prev_area_positions = self:GetAreaPositions() GridMarker.RecalcAreaPositions(self) if not table.iequal(self:GetAreaPositions(), prev_area_positions) then if self.apply_pass then g_RebuildRepulsors = true end DelayedCall(0, UpdateRepulsorsPass) end end function FilterPackedPositionsRepulsionZone(positions) return table.ifilter(positions, function(_, packed_pos) return not IsInAmbientLifeRepulsionZone(packed_pos, true) end) end function UpdateRepulsorsPass() for _, marker in ipairs(g_VisitRepulsors) do local apply_pass = marker:GetAreaPositions() and marker:IsMarkerEnabled() or false if marker.apply_pass ~= apply_pass then marker.apply_pass = apply_pass g_RebuildRepulsors = true end end if g_RebuildRepulsors then g_RebuildRepulsors = false NetUpdateHash("UpdateRepulsorsPass:UpdatePassType") UpdatePassType() MapForEachMarker("GridMarker", nil, GridMarker.ResetRepulseAreaPositions) end end MapGameTimeRepeat("AmbientLifeRepulsor", 1000, UpdateRepulsorsPass) -- a Visitable is a table of the form { id, ... } -- id is one of the Visit prgs; the rest of the table is parameters needed for it to be completed -- the parameters must be serializable via the StoreBehaviorParamTbl/RestoreBehaviorParamTbl functions, if necessary - extend them -- in particular, any objects need to be serialized as handles DefineClass.AmbientLifeMarker = { __parents = {"EditorMarker", "AppearanceObject", "GameDynamicDataObject", "EditorTextObject", "EditorSelectedObject", "EditorCallbackObject"}, properties = { {category = "Ambient Life", id = "Teleport", name = "Teleport", editor = "bool", default = true, help = "If true the unit teleports to the spot, otherwise it walks to there", }, {category = "Ambient Life", id = "AllowAL", name = "Allow AL", editor = "bool", default = true, help = "If false normal AL units(from Ambient Zones) can't use this spot. However if the marker manages to steal perpetual unit this flag is ignored!", }, {category = "Ambient Life", id = "VisitEnter", name = "Entering Visit", editor = "combo", default = "", items = function(obj) return obj:GetStatesTextTable() end, }, {category = "Ambient Life", id = "VisitIdle", name = "During Visit", editor = "dropdownlist", default = "idle", items = function(obj) return obj:GetStatesTextTable() end, }, {category = "Ambient Life", id = "VisitVariation", name = "During Visit Variation", editor = "bool", default = false, }, {category = "Ambient Life", id = "VisitExit", name = "Exiting Visit", editor = "combo", default = "", items = function(obj) return obj:GetStatesTextTable() end, }, {category = "Ambient Life", id = "VisitMinDuration", name = "Visit Min Duration", editor = "number", default = false, scale = "sec", help = "Spends at least that much at the marker by looping the VisitIdle animation. Can be greater if animations are longer.", }, {category = "Ambient Life", id = "VisitAlternateChance", name = "Visit Alternate Chance", editor = "number", default = 0, min = 0, max = 100, slider = true, }, {category = "Ambient Life", id = "VisitAlternate", name = "During Visit Alternate", editor = "dropdownlist", default = "idle", items = function(obj) return obj:GetStatesTextTable() end, no_edit = function(self) return self.VisitAlternateChance == 0 end, }, {category = "Ambient Life", id = "VisitAlternateVariation", name = "During Visit Alternate Variation", editor = "bool", default = false, no_edit = function(self) return self.VisitAlternateChance == 0 end, }, {category = "Ambient Life", id = "EmotionChance", name = "Chance for Emotion", editor = "number", default = 0, min = 0, max = 100, slider = true, no_edit = function(self) return self.VisitAlternateChance == 0 end, }, {category = "Ambient Life", id = "EmotionAnimation", name = "Emotion", editor = "combo", default = "civ_Ambient_Angry", items = function(obj) return {"civ_Ambient_Angry", "civ_Ambient_Cheering", "civ_Ambient_SadCrying"} end, no_edit = function(self) return self.EmotionChance == 0 end, }, {category = "Ambient Life", id = "EmotionVariation", default = false, name = "During Visit Alternate Variation", editor = "bool", no_edit = function(self) return self.EmotionChance == 0 end, }, {category = "Ambient Life", id = "Conditions", name = "Conditions", editor = "nested_list", base_class = "Condition", default = false, help = "Conditions to check periodically" }, {category = "Ambient Life", id = "GameStatesFilter", name = "States Required for Activation", editor = "set", three_state = true, default = set_neg("Conflict", "DustStorm", "FireStorm", "RainHeavy"), items = function() return GetGameStateFilter() end, help = "Map states requirements for the AL marker to be active.", }, {category = "Ambient Life", id = "ToolEntity", name = "Tool Entity", editor = "dropdownlist", items = function() return GetAllEntitiesComboItems() end, default = "", help = "Tool to be attached during the visit", }, {category = "Ambient Life", id = "ToolAutoAttachMode", name = "Tool Auto Attach Mode", editor = "dropdownlist", items = function(obj) return GetEntityAutoAttachModes(nil, obj.ToolEntity) or {} end, default = false, }, {category = "Ambient Life", id = "ToolSpot", name = "Tool Spot", editor = "combo", items = {"Weaponr", "Weaponl", "Wristr", "Wristl", "Origin"}, default = "Weaponr", help = "Where the tool should be attached to", }, {category = "Ambient Life", id = "ToolAttachOffset", name = "Tool Attach Offset", editor = "point", default = false, help = "An offset from the specified spot to attach the tool to.", }, { category = "Ambient Life", id = "ToolColors", name = "Tool Colors", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, {category = "Ambient Life", id = "Weapon", name = "Weapon", editor = "preset_id", default = "", preset_class = "InventoryItemCompositeDef", preset_filter = function (preset, obj) return preset.group and preset.group:starts_with("Firearm") end, }, {category = "Ambient Life", id = "ChanceSpawn", name = "Chance of Spawning", editor = "number", default = 0, min = 0, max = 100, slider = true, }, {category = "Ambient Life", id = "Groups", name = "Groups", editor = "string_list", default = false, items = function() local items = table.keys2(Groups or empty_table, "sorted") table.insert(items, 1, "Closest AmbientZoneMarker") return items end, }, {category = "Ambient Life", id = "Ephemeral", name = "Ephemeral", editor = "bool", default = true, no_edit = function(self) return self.ChanceSpawn == 0 end, help = "When the time to re-spawm the AL on the map perpetual units are kicked out if this flag is set and new ones are tried to be stolen. Otherwise the units are kept there", }, {category = "Ambient Life", id = "AttractGender", name = "Attract Gender", editor = "dropdownlist", default = "Both", items = {"Both", "Male", "Female"}, }, {category = "Ambient Life", id = "IgnoreGroupsMatch", name = "Ignore Groups Match", editor = "bool", default = false, help = "If checked matching AL_ prefix group match between the unit and marker is skipped", }, {category = "Ambient Life", id = "VisitSupportCollection", name = "Visit Support Set", editor = "objects", base_class = "Object", default = false, help = "At least ONE Object in the set must be intact for the marker to be visitable", }, -- editor & debug properties {category = "Ambient Life Editor", id = "IgnoreVisitSupportVME", name = "Ignore Visit support VME", editor = "bool", default = false, help = "If you are sure the support collection can't be destroyed and is properly position you can turn off the VME for this class", }, {category = "Ambient Life Editor", id = "EditorMarkerVisitAnim", name = "Editor Marker Visit Anim", editor = "dropdownlist", default = false, items = function(obj) return obj:GetStatesTextTable() end, }, {category = "Ambient Life Editor", id = "VisitPose", name = "Visit Pose", editor = "number", default = 0, slider = true, min = 0, max = function(obj) return GetAnimDuration(obj:GetEntity(), obj.EditorMarkerVisitAnim or obj.VisitIdle) - 1 end, help = "This is just for edit/debug purposed only for easier distinguishing which VisitIdle animation will be played at the marker", }, {category = "Ambient Life Editor", id = "ViewPerpetual", editor = "buttons", default = false, no_edit = function(self) return not self.perpetual_unit end, buttons = { { name = "View Perpetual Unit", func = function(self) ViewObject(self.perpetual_unit) end}, { name = "Select Perpetual Unit", func = function(self) editor.ClearSel() editor.AddObjToSel(self.perpetual_unit) end}, }, }, -- hidden overridden properties {id = "StateCategory"}, {id = "StateText"}, {id = "animWeight"}, {id = "animBlendTime"}, {id = "anim2"}, {id = "anim2BlendTime"}, }, editor_text_offset = point(0, 0, 250 * guic), editor_text_style = "AmbientLifeMarker", tool_attached = false, perpetual_unit = false, steal_activated = false, destlock = false, Random = AmbientLife_Random, VisitSupportCollectionVME = false, } function AmbientLifeMarker:Init() if self.Weapon ~= "" and self.ToolEntity ~= "" then StoreErrorSource(self, "AmbientLifeMarker can't specify both Tool and Weapon to attach!") end local appearance = self.Appearance ~= "" and self.Appearance or "Legion_Jose" self:ApplyAppearance(appearance) self:SetAnimPose(self.EditorMarkerVisitAnim or self.VisitIdle, self.VisitPose) end function AmbientLifeMarker:GetGroupsText() if not self.Groups then return "" end return table.concat(self.Groups, ",") end function AmbientLifeMarker:GetEditorText() local text = T{Untranslated(" "), self} if self.Teleport then text = text .. Untranslated("\n\tTeleport") end if self.VisitSupportCollection then local count = #self.VisitSupportCollection if count == 1 then text = text .. Untranslated("\n\t1 CombatObject in Visit Support Collection") else text = text .. T{Untranslated("\n\t CombatObjects in Visit Support Collection"), count = count} end end return text end function AmbientLifeMarker:GetVisitable() local visitable, idx = table.find_value(g_Visitables, 1, self) return visitable, idx end function AmbientLifeMarker:EditorCallbackPlace() table.insert(g_Visitables, self:GenerateVisitable()) end function AmbientLifeMarker:EditorCallbackDelete() local visitable, idx = self:GetVisitable() if visitable then table.remove(g_Visitables, idx) if visitable.reserved then local unit = HandleToObject[visitable.reserved] if IsValid(unit) and not unit:IsDead() then unit:SetCommand(false) end end end end AmbientLifeMarker.EditorCallbackMove = AmbientLifeMarker.RebuildVisitable AmbientLifeMarker.EditorCallbackRotate = AmbientLifeMarker.RebuildVisitable AmbientLifeMarker.EditorCallbackScale = AmbientLifeMarker.RebuildVisitable function AmbientLifeMarker:OnEditorSetProperty(prop_id, old_value, ged) if prop_id == "VisitPose" or prop_id == "VisitIdle" or prop_id == "EditorMarkerVisitAnim" then self:SetAnimPose(self.EditorMarkerVisitAnim or self.VisitIdle, self.VisitPose) elseif prop_id == "ChanceSpawn" then if self.ChanceSpawn == 100 then self:SetProperty("AllowAL", false) end elseif prop_id == "VisitAlternateChance" then if self.VisitAlternateChance == 0 then local prop_meta = self:GetPropertyMetadata("VisitAlternate") self:SetProperty("VisitAlternate", prop_meta.default) end elseif prop_id == "EmotionChance" then if self.EmotionChance == 0 then local prop_meta = self:GetPropertyMetadata("EmotionAnimation") self:SetProperty("EmotionAnimation", prop_meta.default) end else AppearanceObject.OnEditorSetProperty(self, prop_id) end end function AmbientLifeMarker:EditorEnter() EditorMarker.EditorEnter(self) self:SetAnimPose(self.EditorMarkerVisitAnim or self.VisitIdle, self.VisitPose) end function AmbientLifeMarker:EditorSelect(selected) if selected then self.anim_speed = 1000 self:ValidateVisitSupportCollection() if self.VisitSupportCollection then editor.AddToSel(self.VisitSupportCollection, "dont_notify") end else if IsValid(self) then self:SetAnimPose(self.EditorMarkerVisitAnim or self.VisitIdle, self.VisitPose) end end end function AmbientLifeMarker:SpawnTool(unit, tool_orient_time) if not unit:HasSpot(self.ToolSpot) then return end local spot = unit:GetSpotBeginIndex(self.ToolSpot) local attach_angle = 0 if (tool_orient_time or 0) > 0 then if IsValid(self.tool_attached) then local spot_pos, spot_angle, spot_axis = unit:GetSpotLoc(unit:GetState(), unit:GetAnimPhase(1) + tool_orient_time, spot) -- this is a solution for the only carry object we have. -- a general solution could select from object Carry spots and align with it (choose closest spot angle) if self.tool_attached:AngleToObject(unit) < 0 then attach_angle = 180*60 end self.tool_attached:SetAxis(spot_axis, tool_orient_time) self.tool_attached:SetAngle(spot_angle + attach_angle, tool_orient_time) end Sleep(tool_orient_time) end if not IsValid(self.tool_attached) then self.tool_attached = false if self.ToolEntity == "" and self.Weapon == "" then return end if self.ToolEntity ~= "" then self.tool_attached = PlaceObject(self.ToolEntity) if IsKindOf(self.tool_attached, "CombatObject") then self.tool_attached:InitFromMaterial() -- otherwise .HitPoints=-1 and tool is considered dead end if self.ToolColors then self.tool_attached:SetColorization(self.ToolColors) end if self.ToolAutoAttachMode then self.tool_attached:SetAutoAttachMode(self.ToolAutoAttachMode) end else local weapon_item = PlaceInventoryItem(self.Weapon) if weapon_item then self.tool_attached = weapon_item:CreateVisualObj() end end end self.tool_attached:SetApplyToGrids(false) self.tool_attached:ClearEnumFlags(const.efCollision) unit:Attach(self.tool_attached, spot) self.tool_attached:SetAttachAngle(attach_angle) if self.ToolAttachOffset then self.tool_attached:SetAttachOffset(self.ToolAttachOffset) end end function AmbientLifeMarker:DespawnTool() if not IsValid(self.tool_attached) then return end self.tool_attached:Detach() self.tool_attached:SetApplyToGrids(true) if self:IsKindOf("AL_Carry") then self.tool_attached:SetPos(self:GetPos()) self.tool_attached:SetAxisAngle(axis_z, 0) self.tool_attached:SetObjectMarking(-1) self.tool_attached:ClearHierarchyGameFlags(const.gofObjectMarking) else DoneObject(self.tool_attached) self.tool_attached = false end end function AmbientLifeMarker:GenerateVisitable() return {self} end function AmbientLifeMarker:MatchConditionsAndGameStates() return EvalConditionList(self.Conditions) and MatchGameState(self.GameStatesFilter) end function AmbientLifeMarker:CanVisit(unit, for_perpetual, dont_check_dist) if not self:IsVisitSupportCollectionAlive() then return false -- any of the objects we care about is destroyed end if self.AttractGender ~= "Both" then if unit.gender ~= self.AttractGender then return false end end if not g_Combat and IsOccupiedExploration(unit, self:GetPosXYZ()) then local occupied_by = MapGetFirst(self, 0, "Unit", function(u, unit) return u ~= unit and select(3, u:GetPosXYZ()) == select(3, unit:GetPosXYZ()) end, unit) if occupied_by then return false end end for_perpetual = for_perpetual or self.perpetual_unit == unit if for_perpetual or self.AllowAL or (not self.AllowAL and not unit:IsAmbientUnit()) then if not self:MatchConditionsAndGameStates() then return false end if not for_perpetual then local check_ignore_dist = not dont_check_dist and (not unit.zone or unit.zone.MinRoamDist >= 0) if check_ignore_dist and IsCloser2D(self, unit, const.AmbientLife.VisitIgnoreRange) then return false end end if not unit.visit_test and not (self.IgnoreGroupsMatch or unit:GroupsMatch(self)) then return false end if not IsValidAnim(unit, self.VisitIdle) or unit:GetAnimDuration(self.VisitIdle) == 0 then return false end return true end end function AmbientLifeMarker:GotoEnterSpot(unit, dest) local distance = 0 if self:IsKindOf("AL_Carry") then local phase = unit:GetAnimMoment(self.VisitEnter, "hit") if (phase or 0) > 0 then distance = unit:GetVisualDist2D(unit:GetSpotLocPosXYZ(self.VisitEnter, phase, unit:GetSpotBeginIndex(self.ToolSpot))) end end if unit.teleport_allowed_once then unit.teleport_allowed_once = false local angle = self:GetAngle() unit:SetPos(RotateRadius(distance, angle, dest, true)) unit:SetAngle(angle) self:SpawnTool(unit) else local remove_pfflags = unit:GetPathFlags(const.pfmVoxelAligned | const.pfmDestlock | const.pfmDestlockSmart) unit:ChangePathFlags(0, remove_pfflags) unit:PushDestructor(function(self) if IsValid(self) then self:ChangePathFlags(remove_pfflags) end end) local finished if distance == 0 then finished = unit:GotoSlab(dest) else finished = unit:GotoSlab(dest, distance) end unit:PopAndCallDestructor() if not finished then return end end return unit.visit_test or self:MatchConditionsAndGameStates() end function AmbientLifeMarker:ApplyVisitEnterStepVectorAngle(unit, dest, lookat, angle) if (self.VisitEnter or "") == "" then return end angle = angle or (lookat and CalcOrientation(unit, lookat) or self:GetAngle()) unit:SetPos(dest + unit:GetStepVector(self.VisitEnter, angle)) unit:SetAngle(angle + unit:GetStepAngle(self.VisitEnter)) end function AmbientLifeMarker:Enter(unit, dest, lookat) local angle = lookat and CalcOrientation(unit, lookat) or self:GetAngle() if (self.VisitEnter or "") == "" then unit:SetPos(dest) if not IsKindOf(self, "AL_Roam") or not self.DontReorient then local adiff = AngleDiff(unit:GetVisualAngle(), angle) if abs(adiff) > 5 * 60 then unit:AnimatedRotation(angle) end end return end if unit.perpetual_marker and unit.teleport_allowed_once then unit.teleport_allowed_once = false self:ApplyVisitEnterStepVectorAngle(unit, dest, lookat, angle) return end self.destlock = PlaceObject("Destlock") self.destlock:SetPos(self:IsKindOf("AL_Carry") and self.CarryDestination or dest) pf.SetDestlockRadius(self.destlock, unit:GetDestlockRadius()) unit:SetState(self.VisitEnter) PlayFX(string.format("Anim:%s", self:GetStateText()), "start", unit) if self:IsKindOf("AL_Carry") then local time = unit:TimeToMoment(1, "hit") or 0 unit:SetAngle(angle, Min(200, time)) local tool_orient_time = Min(200, time) Sleep(time - tool_orient_time) self:SpawnTool(unit, tool_orient_time) Sleep(unit:TimeToAnimEnd()) return end self:SpawnTool(unit) unit:AnimatedRotation(angle) unit:SetState(self.VisitEnter) unit:SetTargetDummyFromPos() local step_angle = unit:GetStepAngle() local duration = unit:TimeToAnimEnd() unit:SetPos(dest + unit:GetStepVector(self.VisitEnter, angle), duration) local steps = 2 for i = 1, steps do local t = duration * i / steps - duration * (i - 1) / steps local a = angle + step_angle * i / steps unit:SetAngle(a, t) Sleep(t) end end function AmbientLifeMarker:OnVisitAnimEnded(unit) -- you can hook here to do some stuff, e.g. steal items from corpse's inventory - see AL_Maraud end function AmbientLifeMarker:StartVisit(unit, visit_duration) local randomize_phase = unit.perpetual_marker and "randomize phase" repeat local start_time = GameTime() self:SetVisitAnimation(unit, randomize_phase) randomize_phase = false self:SpawnTool(unit) Sleep(unit:TimeToAnimEnd()) self:OnVisitAnimEnded(unit) visit_duration = visit_duration + GameTime() - start_time local visit_finished = not self.VisitMinDuration or visit_duration >= self.VisitMinDuration until (not self.perpetual_unit) and visit_finished or (not self:CanVisit(unit, nil, "don't check dist")) end function AmbientLifeMarker:ExitVisit(unit) if IsValid(unit) then unit:SetCommandParamValue(unit.command, "move_style", nil) if (self.VisitExit or "") ~= "" and IsValidAnim(unit, self.VisitExit) then unit:SetState(self.VisitExit) local combat_anim_speed = 2000 if unit.command == "EnterCombat" then unit:SetAnimSpeed(1, combat_anim_speed) end local time = unit:TimeToAnimEnd(1) unit:SetPos(unit:GetPos() + unit:GetStepVector(), time) unit:SetAngle(unit:GetAngle() + unit:GetStepAngle(), time) local wait_time = IsMerc(unit) and (unit:TimeToMoment(1, "end") or Max(0, time - 300)) or time if unit.command == "EnterCombat" then Sleep(wait_time) elseif WaitMsg("CombatStarting", wait_time) or unit.command == "EnterCombat" then if IsValid(unit) then -- WaitMsg above unit:SetAnimSpeed(1, combat_anim_speed) time = unit:TimeToAnimEnd(1) wait_time = IsMerc(unit) and (unit:TimeToMoment(1, "end") or Max(0, time - 300)) or time unit:SetPos(unit:GetPos(), time) unit:SetAngle(unit:GetAngle(), time) Sleep(wait_time) end end if self.tool_attached then self:DespawnTool() end if wait_time < time then Sleep(unit:TimeToAnimEnd()) end end end if self.tool_attached then self:DespawnTool() end if self.destlock then if IsValid(unit) then if (GameState.Combat or GameState.Conflict) and not self:IsKindOf("AL_Carry") then unit:SetPos(self.destlock:GetPos()) end end DoneObject(self.destlock) self.destlock = false end end function AmbientLifeMarker:Visit(unit, dest, lookat, already_in_perpetual) dest = dest or self:GetPos() -- going to the marker spot unit.visit_reached = false unit:ReserveVisitable(self:GetVisitable()) local start_time = GameTime() unit:PushDestructor(function() self.perpetual_unit = false unit:FreeVisitable() end) unit:SetCommandParamValue("Visit", "move_style", nil) if not already_in_perpetual and not self:GotoEnterSpot(unit, dest) then if start_time == GameTime() then unit:IdleRoutine_StandStill(3000) end unit:PopAndCallDestructor() return end if not self:CanVisit(unit, nil, "don't check dist") then -- meanwhile can become not visitable, e.g. support collection destroyed unit:PopAndCallDestructor() return end unit:PopDestructor() -- prepare the unbreakable exit from the marker local is_carry_marker = self:IsKindOf("AL_Carry") unit:PushDestructor(function() unit:SetTargetDummy(false) self.perpetual_unit = false PlayFX(string.format("Anim:%s", self:GetStateText()), "end", unit) if IsValid(unit) and not unit:IsDead() then self:ExitVisit(unit) end unit:FreeVisitable() if is_carry_marker then unit:SetCommandParamValue("Visit", "move_style", nil) end end) start_time = GameTime() unit.visit_reached = not is_carry_marker if already_in_perpetual then if not is_carry_marker then self:ApplyVisitEnterStepVectorAngle(unit, dest, lookat, not IsKindOf(self, "AL_SitChair") and self:GetAngle() or nil) end else self:Enter(unit, dest, lookat) end if not self:CanVisit(unit, nil, "don't check dist") then -- meanwhile can become not visitable, e.g. support collection destroyed unit:PopAndCallDestructor() return end -- actual visit if is_carry_marker then local move_style = GetAnimationStyle(unit, self.MoveStyle) or GetAnimationStyle(unit, "Walk_Carry") if move_style then unit:SetCommandParamValue(unit.command, "move_style", move_style.Name) end else if not already_in_perpetual then self:StartVisit(unit, GameTime() - start_time) end end if unit.perpetual_marker then if is_carry_marker then -- carry only once and forget about being perpetual unit:GotoSlab(self.CarryDestination) unit.perpetual_marker = false self.perpetual_unit = false else while unit.perpetual_marker == self and self:CanVisit(unit) do self:SetVisitAnimation(unit) Sleep(unit:TimeToAnimEnd()) self:OnVisitAnimEnded(unit) end end else if is_carry_marker then unit:GotoSlab(self.CarryDestination) end end if start_time == GameTime() then unit:IdleRoutine_StandStill(3000) end -- unbreakable exit from the marker unit:PopAndCallDestructor() end function AmbientLifeMarker:IsLucky(chance) return (chance > 0) and self:Random(100) < chance end function AmbientLifeMarker:GetBaseAnimVariation() local original = not self:IsLucky(self.VisitAlternateChance) local emotion = not original and self:IsLucky(self.EmotionChance) local base_anim = original and self.VisitIdle or (emotion and self.EmotionAnimation or self.VisitAlternate) local variation = original and self.VisitVariation or (emotion and self.EmotionVariation or self.VisitAlternateVariation) return base_anim, variation end function AmbientLifeMarker:SetVisitAnimation(unit, randomize_phase) local base_anim, variation = self:GetBaseAnimVariation() local anim, phase if variation then anim, phase = unit:GetNearbyUniqueRandomAnim(base_anim) if not randomize_phase then phase = 0 end else anim, phase = base_anim, 0 end local same_anim = unit:GetStateText() == anim local crossfade = IsKindOf(self, "AL_Roam") and -1 or 0 unit:SetState(anim, 0, crossfade) if same_anim then if unit:GetAnimMomentsCount(anim, "start") > 0 then unit:OnAnimMoment("start", anim) end end if phase > 0 then unit:SetAnimPhase(1, phase) end unit:SetTargetDummyFromPos() end function AmbientLifeMarker:CanSpawn() return self:IsPerpetual() and self:MatchConditionsAndGameStates() end function AmbientLifeMarker:GetClosestClassFromGroup(classes, group) local objects = Groups[group] local closest, closest_dist for _, obj in ipairs(objects) do if IsKindOfClasses(obj, classes) then local is_zone = IsKindOf(obj, "AmbientZoneMarker") if (not is_zone and not obj.perpetual_marker and not obj:IsDead() and not obj:IsDefeatedVillain() and self:CanVisit(obj, "for perpetual") and not IsSetpieceActor(obj)) or (is_zone and obj:CanSpawn()) then if not closest then closest, closest_dist = obj, obj:GetDist(self) else local dist = obj:GetDist(self) if dist < closest_dist then closest, closest_dist = obj, dist end end end end end return closest end local function filter_can_spawn_zone(zone) return zone:CanSpawn() end function AmbientLifeMarker:GetSpawnedUnit() local group for _, grp in ipairs(self.Groups) do if not grp:starts_with("AL_") then group = grp break end end local zone if group == "Closest AmbientZoneMarker" then local pos = self:GetPos() local x, y = terrain.GetMapSize() local radius = (x > y) and x or y zone = MapFindNearest(pos, pos, radius, "AmbientZoneMarker", filter_can_spawn_zone) if not zone then StoreErrorSource(self, "Can't find AmbientZoneMarker around which can spawn to steal from") end else local obj = self:GetClosestClassFromGroup({"Unit", "AmbientZoneMarker"}, group or self.Groups[1]) if IsKindOf(obj, "Unit") then return obj end zone = obj end return zone and zone:GetUnitForMarker(self) end function AmbientLifeMarker:StealSpawnedUnit() if self.perpetual_unit then return end self.steal_activated = true self.perpetual_unit = self:GetSpawnedUnit() or false if not self.perpetual_unit then return end self.perpetual_unit.teleport_allowed_once = self.Teleport self.perpetual_unit.perpetual_marker = self local visitable = self:GetVisitable() local old_visitable = self.perpetual_unit:GetVisitable() if old_visitable == visitable then return end if old_visitable then self.perpetual_unit:FreeVisitable(old_visitable) end if visitable.reserved then local unit = HandleToObject[visitable.reserved] if unit then unit:FreeVisitable(visitable) unit:SetBehavior() unit:SetCommand(false) end end self.perpetual_unit:ReserveVisitable(visitable) if g_Combat then -- only set the behavior, the unit is currently busy being afraid from the ongoing combat self.perpetual_unit:SetBehavior("Visit", {visitable}) else self.perpetual_unit:SetCommand("Visit", visitable) end end function AmbientLifeMarker:Spawn() if self.Ephemeral then if self.perpetual_unit then self.perpetual_unit:SetBehavior() self.perpetual_unit:SetCommand(false) self.perpetual_unit = false end end self:StealSpawnedUnit() end function AmbientLifeMarker:Despawn() if self.perpetual_unit then if IsValid(self.perpetual_unit) and not IsBeingDestructed(self.perpetual_unit) then self.perpetual_unit:FreeVisitable() self.perpetual_unit:SetBehavior() self.perpetual_unit:SetCommand("Idle") end self.perpetual_unit.perpetual_marker = false self.perpetual_unit = false end end function AmbientLifeMarker:GetDynamicData(data) data.perpetual_unit = self.perpetual_unit and self.perpetual_unit.handle or nil data.steal_activated = self.steal_activated or nil data.tool_attached = self.tool_attached and true or nil -- NOTE: not permament object so needs respawning end function AmbientLifeMarker:SetDynamicData(data) self.perpetual_unit = data.perpetual_unit and HandleToObject[data.perpetual_unit] or false self.steal_activated = data.steal_activated or false self.tool_attached = data.tool_attached or false -- NOTE: not permament object so needs respawning end function AmbientLifeMarker:IsPerpetual() return self.Groups and self.ChanceSpawn > 0 end function AmbientLifeMarker:IsToolDestroyed() if self.ToolEntity == "" then return end local tool = self.tool_attached return IsValid(tool) and IsKindOf(tool, "CombatObject") and tool:IsDead() end function AmbientLifeMarker:EditorGetText() local sup_col_dead if not self:IsVisitSupportCollectionAlive("all") then sup_col_dead = string.format("All associated combat object(s) are destroyed!") end if not self:IsVisitSupportCollectionAlive() then sup_col_dead = string.format("Some associated combat object(s) are destroyed!") end local cond_text local context = {} for i, condition in ipairs(self.Conditions) do if not condition:Evaluate(self, context) then cond_text = string.format("%s: false", TDevModeGetEnglishText(condition:GetEditorView())) end end local avoid_text = IsInAmbientLifeRepulsionZone(self) and "In Repulsion Zone" if sup_col_dead or cond_text or avoid_text then local pre_conditions = {} if sup_col_dead then table.insert(pre_conditions, sup_col_dead) end if cond_text then table.insert(pre_conditions, cond_text) end if avoid_text then table.insert(pre_conditions, avoid_text) end return table.concat(pre_conditions, "\n") end local perpetual = self:IsPerpetual() and string.format("(Perpetual: %d%%)", self.ChanceSpawn) or "" local text = string.format("AL %s Visit%s", self.AllowAL and "CAN" or "CAN'T", perpetual) local game_states = MatchGameState(self.GameStatesFilter) local conditions = EvalConditionList(self.Conditions) if not game_states or not conditions then if not game_states then local mismatch_states = {"Mismatch States:"} for state, active in pairs(self.GameStatesFilter) do local game_state_active = not not GameState[state] if active ~= game_state_active then table.insert(mismatch_states, state) end end text = string.format("%s\n%s", text, table.concat(mismatch_states, " ")) end if not conditions then local mismatch_conditions = {"Mismatch Conditions:"} for _, condition in ipairs(self.Conditions) do local ok, result = procall(condition.__eval, condition) if not ok then table.insert(mismatch_conditions, condition:GetEditorView()) end if condition.Negate then result = not result end if not result then table.insert(mismatch_conditions, "NOT " .. condition:GetEditorView()) end end text = string.format("%s\n%s", text, table.concat(mismatch_conditions, " ")) end else if self:IsPerpetual() then local action_text = self.perpetual_unit and "Stolen from:" or "No Free Units to Steal From:" local unit = self:GetSpawnedUnit() text = string.format("%s\n%s %s[%s](for %s anim)", text, action_text, self.Groups[1], unit and unit.class or "???", self.VisitIdle) end end return text end function AmbientLifeMarker:EditorGetTextColor() local pre_conditions_ok = self:IsVisitSupportCollectionAlive() and not IsInAmbientLifeRepulsionZone(self) if pre_conditions_ok then local context = {} for i, condition in ipairs(self.Conditions) do if not condition:Evaluate(self, context) then pre_conditions_ok = false break end end end local perpetual_ok = self:IsPerpetual() == not not self.perpetual_unit local match = self:MatchConditionsAndGameStates() return (pre_conditions_ok and self.AllowAL and match and perpetual_ok) and const.clrGreen or const.clrRed end function AmbientLifeMarker:GetRootColIndex() local root_collection = self:GetRootCollection() return root_collection and root_collection.Index or 0 end function AmbientLifeMarker:GetCollectionLeader() local col_idx = self:GetRootColIndex() if col_idx == 0 then return self end local leader = self MapForEach("map", "collection", col_idx, true, "AmbientLifeMarker", function(marker) leader = (marker.handle < leader.handle) and marker or leader end) return leader end function AmbientLifeMarker:IsCollectionLeader() return self:GetCollectionLeader() == self end function AmbientLifeMarker:SpawnCollection() if self:Random(100) >= self.ChanceSpawn then return end -- one chance for the whole collection self:Spawn() local col_idx = self:GetRootColIndex() if col_idx == 0 then return end local markers = MapGet("map", "collection", col_idx, true, "AmbientLifeMarker", function(marker) return marker ~= self end) for _, marker in ipairs(markers) do marker.steal_activated = self.steal_activated if marker.ChanceSpawn ~= self.ChanceSpawn then StoreErrorSource(self, "AL markers in collection should have the same ChanceSpawn!") end end if not self.perpetual_unit then return end -- the rest of the collection don't get unit too for _, marker in ipairs(markers) do marker:Spawn() end end function AmbientLifeMarker:CreateVisitSupportCollection() self.VisitSupportCollection = {} for _, obj in ipairs(editor.GetSel() or empty_table) do if IsKindOf(obj, "Object") and IsValid(obj) and not IsKindOf(obj, "AmbientLifeMarker") then table.insert(self.VisitSupportCollection, obj) end end end function AmbientLifeMarker:RemoveVisitSupportCollection() if self.VisitSupportCollection then editor.RemoveFromSel(self.VisitSupportCollection) self.VisitSupportCollection = false end end function AmbientLifeMarker:ValidateVisitSupportCollection() local collection = self.VisitSupportCollection if not collection then return end for i = #collection, 1, -1 do local obj = collection[i] if not IsValid(obj) or IsKindOf(obj, "AmbientLifeMarker") then table.remove(collection, i) end end if #collection == 0 then self.VisitSupportCollection = false end end -- if any/all of objects is dead then is not visitable function AmbientLifeMarker:IsVisitSupportCollectionAlive(bAll) self:ValidateVisitSupportCollection() if not self.VisitSupportCollection then return true end for _, obj in ipairs(self.VisitSupportCollection) do local is_dead = IsKindOf(obj, "CombatObject") and obj:IsDead() if bAll and not is_dead then return true end if not bAll and is_dead then return false end end return not bAll end function AmbientLifeMarker:IsInVisitSupportCollection(obj) if self.VisitSupportCollection then return not not table.find(self.VisitSupportCollection, obj) end end function AmbientLifeMarker:VME_CheckImpassable(pt) if self:IsPerpetual() or self.Teleport or GetPassSlab(pt or self) then return end StoreErrorSource(self, "AmbientLifeMarker Goto position is on impassable!") end function AmbientLifeMarker:VME_CheckWalkableZ(pt) if self:IsPerpetual() or self.Teleport then return end local z = pt and pt:z() or not pt and select(3, self:GetPosXYZ()) if z and z < terrain.GetHeight(pt or self) then StoreErrorSource(self, "AmbientLifeMarker Goto position is below walkable Z!") end end function AmbientLifeMarker:VME_CheckProperties() if self.ChanceSpawn > 0 and (not self.Groups or not next(self.Groups) or not self.Groups[1] or self.Groups[1] == "") then StoreErrorSource(self, "AmbientLifeMarker is perpetual but property 'Groups' to steal from is not specified!") end local entity_name = self:GetProperty("ToolEntity") local entity = g_Classes[entity_name] if entity and not IsKindOf(entity, "ComponentCustomData") then StoreWarningSource(self, string.format("AmbientLifeMarker has an attachable entity %s which does not inherit ComponentCustomData. Add ComponentCustomData as its parent class in the ArtSpecEditor.", entity_name)) end end function AmbientLifeMarker:VME_Checks(pt) self:VME_CheckImpassable(pt) self:VME_CheckWalkableZ(pt) self:VME_CheckProperties() if self.VisitSupportCollectionVME and not self.IgnoreVisitSupportVME then if #(self.VisitSupportCollection or empty_table) == 0 then StoreErrorSource(self, "This marker needs non-empty Visit Support Set!") end end end function AmbientLifeMarker:GetError() if self:IsPerpetual() then self:GetSpawnedUnit() end local collection_error local col_idx = self:GetRootColIndex() if col_idx ~= 0 then MapForEach("map", "collection", col_idx, true, "AmbientLifeMarker", function(other) if self.ChanceSpawn ~= other.ChanceSpawn then collection_error = "AL markers in the same collection should have the same ChanceSpawn!" return "break" end end) end if collection_error then return collection_error end end OnMsg.ValidateMap = ValidateGameObjectProperties("AmbientLifeMarker") local function GetClosestVisitable(pos, ignore_reserved) local closest_visitable, closest_dist for _, visitable in ipairs(g_Visitables) do if ignore_reserved or not visitable.reserved then local dist = visitable[1]:GetDist(pos) if not closest_visitable or dist < closest_dist then closest_visitable, closest_dist = visitable, dist end end end if not closest_visitable then StoreErrorSource(pos, "DbgTestClosestALMarker: No visitable around! Try RebuildVisitables() from the console.") end return closest_visitable end function AmbientLifeMarker:DbgTest(unit_pos, closest_visitable) closest_visitable = closest_visitable or GetClosestVisitable(self:GetPos(), "ignore reserved") assert(closest_visitable[1] == self) if not unit_pos then local radius = 10 * guim for try = 1, 100 do local dist = radius / 2 + self:Random(radius / 2) unit_pos = GetPassSlab(self:GetPos() + Rotate(point(dist, 0), self:Random(360 * 60))) if unit_pos then break end end if not unit_pos then local cx, cy = self:GetPos():xy() for y = cy - radius, cy + radius, const.SlabSizeY do for x = cx - radius, cx + radius, const.SlabSizeX do unit_pos = GetPassSlab(point(x, y)) if unit_pos then break end end if unit_pos then break end end end if not unit_pos then StoreErrorSource(self, "Can't find passable point around to test!") return end end NetSyncEvents.CheatEnable("FullVisibility", true) local unit_defs = Presets.UnitDataCompositeDef.Civilians if self.AttractGender ~= "Both" then unit_defs = table.ifilter(unit_defs, function(_, unit_def) return unit_def.gender == self.AttractGender end) end local unit_def = table.rand(unit_defs) local session_id = GenerateUniqueUnitDataId("AmbientLifeMarker:DbgTest", gv_CurrentSectorId or "A1", unit_def.id) local unit = SpawnUnit(unit_def.id, session_id, unit_pos) unit.visit_test = true CheckUniqueSessionId(unit) unit:SetSide("neutral") local visitor = HandleToObject[closest_visitable.reserved] if visitor then visitor:SetCommand(false) end closest_visitable.reserved = unit.handle unit:SetCommand("Visit", closest_visitable) end function OnMsg.ValidateMap() MapForEach("map", "AmbientLifeMarker", function(marker) marker:ValidateVisitSupportCollection() marker:VME_Checks() end) MapForEach("map", "AmbientZoneMarker", function(zone) zone:VME_Checks() end) RebuildVisitables() for _, visitable in ipairs(g_Visitables) do local marker = visitable[1] marker:VME_Checks(visitable[2]) end end DefineClass.ChairSittable = {__parents = {"Object"}} function RebuildVisitables(bbox) if not bbox then local sizex, sizey = terrain.GetMapSize() bbox = box(0, 0, 0, sizex, sizey, 100000) end local used = {} for _, visitable in ipairs(g_Visitables) do local marker = visitable[1] used[marker.handle] = true end MapForEach(bbox, "AmbientLifeMarker", function(marker) local visitable = marker:GenerateVisitable() local marker = visitable[1] if not used[marker.handle] then table.insert(g_Visitables, visitable) used[marker.handle] = true end end) end function GetRandomVisitableForMarker(unit, marker, filter, ...) if not IsValid(marker) or not g_Visitables or #g_Visitables == 0 then return end local area = marker:GetAreaBox() local CheckInside = area.Point2DInside local unit_zone = unit.zone local CheckZTolerance = unit_zone and unit_zone.CheckZTolerance local selected_visitable local total = 0 for _, visitable in random_ipairs(g_Visitables, "AmbientLife") do local visit_marker, pt = visitable[1], visitable[2] local pos_or_marker = pt or visit_marker if not visitable.reserved and CheckInside(area, pos_or_marker) and (not filter or filter(unit, visitable, ...)) and (not unit_zone or CheckZTolerance(unit_zone, pos_or_marker)) and not IsInAmbientLifeRepulsionZone(pos_or_marker) and visit_marker:CanVisit(unit) then visit_marker:VME_CheckImpassable(pos_or_marker) total = total + 1 if not selected_visitable then local pfflags = const.pfmDestlock + const.pfmImpassableSource local has_path, closest_pos = pf.HasPosPath(unit, pos_or_marker, nil, 0, 0, unit, 0, nil, pfflags) if has_path then if pt and closest_pos == pt or not pt and closest_pos:Equal(visit_marker:GetPosXYZ()) then selected_visitable = visitable end end end end end return selected_visitable, total end function OnMsg.PostNewMapLoaded() -- When in dev mode the rebuild is done in OnMsg.ValidateMap if not Platform.developer then RebuildVisitables() end end local function RebuildVisitablesResetUnitVisits(obj, bbox) RebuildVisitables(bbox) for _, unit in ipairs(g_Units) do if unit.behavior == "Visit" then local visitable = unit.behavior_params[1] local marker = visitable[1] if marker:IsInVisitSupportCollection(obj) then if not marker:IsVisitSupportCollectionAlive() then unit:ResetAmbientLife() end end end end end OnMsg.CombatObjectDied = RebuildVisitablesResetUnitVisits DefineClass.AmbientSpawnDef = { __parents = {"PropertyObject"}, properties = { {id = "UnitDef", name = "Unit Definition", editor = "preset_id", default = false, preset_class = "UnitDataCompositeDef", }, { id = "Appearance", name = "Appearance", help = "Force the spawned unit to use this appearance instead of randomly choosing from its own list of appearances", editor = "preset_id", default = false, preset_class = "AppearancePreset", }, { id = "Name", name = "Name", help = "Name for the spawned unit that will replace the one from template.", editor = "text", default = false, translate = true, lines = 1, max_lines = 1, }, {id = "Ephemeral", name = "Ephemeral", editor = "bool", default = true, help = "Permanent or Ephemeral", }, {id = "CountMin", name = "Count Min", editor = "number", default = 5}, {id = "CountMax", name = "Count Max", editor = "number", default = 20}, }, EditorView = Untranslated(" : -"), } function AmbientSpawnDef:GenSessionId() return GenerateUniqueUnitDataId("AmbientSpawnDef", gv_CurrentSectorId or "A1", self.UnitDef) end DefineClass.AmbientZoneMarker = { __parents = {"GridMarker", "GameDynamicDataObject", "EditorTextObject", "EditorMarker"}, properties = { {category = "Ambient Zone", id = "ConflictIgnore", name = "Conflict Ignore", editor = "bool", default = false, help = "Set this so units during conflict won't run, get reduced and won't repopulate the map when over" }, {category = "Ambient Zone", id = "AreaWidth", name = "Area Width", editor = "number", default = 20, help = "Defining a voxel-aligned rectangle with North-South and East-West axis" }, {category = "Ambient Zone", id = "AreaHeight", name = "Area Height", editor = "number", default = 20, help = "Defining a voxel-aligned rectangle with North-South and East-West axis" }, {category = "Ambient Zone", id = "AreaLevelZ", name = "Area Level Z", editor = "number", default = 0, help = "+/- that Z level of floors" }, {category = "Ambient Zone", id = "MinRoamDist", name = "Minimum Roaming Distance", editor = "number", default = 4 * const.SlabSizeX, scale = const.SlabSizeX, help = "Does not pick roaming markers closer than this. If negative const.AmbientLife.VisitIgnoreRange will not be checked!", }, {category = "Ambient Zone", id = "SpawnDefs", name = "Spawn Definitions", editor = "nested_list", base_class = "AmbientSpawnDef", default = false }, { category = "Ambient Zone", id = "SpecificBanters", name = "SpecificBanters", help = "SpecificBanters to play when interacted with.", editor = "preset_id_list", default = {}, preset_class = "BanterDef", item_default = "", }, {category = "Ambient Zone", id = "BanterGroups", name = "BanterGroups", help = "Banters to play when interacted with.", editor = "string_list", default = false, items = PresetGroupsCombo("BanterDef"), }, {category = "Ambient Zone", id = "ApproachBanters", name = "Approach Banters", help = "Approach Banters to play when interacted with.", editor = "dropdownlist", default = false, items = PresetGroupsCombo("BanterDef"), }, {category = "Ambient Zone", id = "EnabledConditions", name = "Enabled Conditions", default = false, editor = "nested_list", base_class = "Condition", help = "Conditions that enable or disable the marker", }, { category = "Grid Marker", id = "Type", name = "Type", editor = "string", default = "AmbientZone", read_only = true }, }, editor_text_offset = point(0, 0, 250 * guic), editor_text_style = "AmbientLifeMarker", units = false, persist_units = true, area_outside_repulse = true, Random = AmbientLife_Random, } function AmbientZoneMarker:GetDynamicData(data) if not self.persist_units or not self.units then return end data.units = {} for idx, units in ipairs(self.units) do local data_units = {} for k, unit in ipairs(units) do assert(IsKindOfClasses(unit, "Unit")) data_units[k] = unit.handle end data.units[idx] = data_units end end function AmbientZoneMarker:SetDynamicData(data) if not self.persist_units or not data.units then return end self.units = {} for idx, units in ipairs(data.units) do local real_units = {} for _, unit_handle in ipairs(units) do if unit_handle then local unit = HandleToObject[unit_handle] if unit then if IsKindOfClasses(unit, "Unit") then table.insert(real_units, unit) end end end end self.units[idx] = real_units end end function AmbientZoneMarker:CanSpawn() return self:IsMarkerEnabled() end function AmbientZoneMarker:GetSpawnDefinitions() local spawn_defs = {} for idx, def in ipairs(self.SpawnDefs) do local count = def.CountMin + self:Random(def.CountMax - def.CountMin + 1) if GameState.Conflict or GameState.ConflictScripted then count = MulDivTrunc(count, const.AmbientLife.ConflictReduction, 100) end table.insert(spawn_defs, {def_idx = idx, zone = self, count = count, unit_def = def}) end return spawn_defs end function AmbientZoneMarker:GetUnitForMarker(marker) local units = {} for _, def_units in ipairs(self.units) do for _, unit in ipairs(def_units) do local not_defeated = IsValid(unit) and not unit:IsDead() and (IsKindOf(unit, "AmbientLifeAnimal") or not unit:IsDefeatedVillain()) if not_defeated and not unit.perpetual_marker and marker:CanVisit(unit, "for perpetual") then table.insert(units, unit) end end end return (#units > 0) and units[1 + self:Random(#units)] end function AmbientZoneMarker:InitUnit(unit) unit:SetSide("neutral") unit.routine = "Ambient" unit.routine_spawner = self unit.approach_banters = table.keys2(Presets.BanterDef[self.ApproachBanters] or empty_table, "sorted") unit.approach_banters_distance = 8 unit.approach_banters_cooldown_id = self.Groups and next(self.Groups) and self.Groups[1] for _, gr in ipairs(self.Groups) do table.insert_unique(unit.Groups, gr) end unit.conflict_ignore = self.ConflictIgnore end function AmbientZoneMarker:PlaceSpawnDef(unit_def, pos) local unit = SpawnUnit(unit_def.UnitDef, unit_def:GenSessionId(), pos) unit.ephemeral = unit_def.Ephemeral CheckUniqueSessionId(unit) unit.zone = self self:InitUnit(unit) if unit_def.Name and unit_def.Name ~= "" then unit.Name = unit_def.Name end if unit_def.Appearance then unit:ApplyAppearance(unit_def.Appearance) end if GameState.Conflict or GameState.ConflictScripted then if unit:CanCower() then unit:TeleportToCower() end end unit.fx_actor_class = "AmbientUnit" return unit end function OnMsg.GetCustomFXInheritActorRules(rules) rules[#rules + 1] = "AmbientUnit" rules[#rules + 1] = "Unit" end local GetHeight = terrain.GetHeight local insert = table.insert function AmbientZoneMarker:FilterZTolerance(positions) if not self.AreaLevelZ then return positions end local mx, my, mz = self:GetPosXYZ() local level_z = mz or GetHeight(mx, my) local z_tolerance = self.AreaLevelZ * const.SlabSizeZ local filtered = {} for _, packed_pos in ipairs(positions) do local px, py, pz = point_unpack(packed_pos) if abs(level_z - (pz or GetHeight(px, py))) <= z_tolerance then insert(filtered, packed_pos) end end return filtered end function AmbientZoneMarker:CheckZTolerance(pos_or_obj) if not self.AreaLevelZ then return true end local level_z = select(3, self:GetPosXYZ()) or GetHeight(self) local check_z if IsPoint(pos_or_obj) then check_z = pos_or_obj:z() else check_z = select(3, pos_or_obj:GetPosXYZ()) end check_z = check_z or GetHeight(pos_or_obj) return abs(level_z - check_z) <= self.AreaLevelZ * const.SlabSizeZ end function AmbientZoneMarker:Spawn(refill) NetUpdateHash("AmbientZoneMarker:Spawn", self.handle) local spawn_defs = self:GetSpawnDefinitions() self.units = self.units or {} local to_enter_map = {} for _, def in ipairs(spawn_defs) do self.units[def.def_idx] = self.units[def.def_idx] or {} local spawned = self.units[def.def_idx] for idx = #spawned, 1, -1 do local unit = spawned[idx] if not IsValid(unit) or unit:IsDead() then table.remove(spawned, idx) end end while #spawned > def.count do -- despawn some units local idx = 1 + self:Random(#spawned) local unit = spawned[idx] table.remove(spawned, idx) unit:Despawn() end if #spawned < def.count then -- spawn some units to fill it up local area_positions = self:GetAreaPositions() local available_positions = self:FilterZTolerance(area_positions) local positions_required = Min(def.count - #spawned, #available_positions) local positions = self:GetRandomPositions(positions_required, nil, available_positions, nil, "avoid close pos") local spawn = not refill or self:IsKindOf("AmbientZone_Animal") for _, pos in ipairs(positions) do local unit = self:PlaceSpawnDef(def.unit_def, spawn and pos) insert(spawned, unit) if not spawn then insert(to_enter_map, {unit = unit, pos = pos}) end end end end if #to_enter_map > 0 then table.shuffle(to_enter_map, self:Random(#to_enter_map)) local refill = 100 - const.AmbientLife.ConflictReduction local wave1 = #to_enter_map * const.AmbientLife.ConflictAftermathRepopulateWave1 / refill local wave2 = #to_enter_map * const.AmbientLife.ConflictAftermathRepopulateWave2 / refill local wave_interval = const.AmbientLife.ConflictAftermathWavesInterval local wave_duration = const.AmbientLife.ConflictAftermathRepopulateWaveDuration local wait_time = wave_interval + self:Random(wave_duration) --printf("Repop %d/%d, Wave1: %d, Wave2: %d", #to_enter_map, #self.units, wave1, wave2) local wave = 1 for idx, entry in ipairs(to_enter_map) do if idx > wave1 and wave < 2 then -- increase wait time for the 2nd wave wait_time = wait_time + wave_duration + wave_interval wave = 2 --printf("wave 2 at %d/%d", idx, #to_enter_map) elseif idx > wave1 + wave2 and wave < 3 then -- increase wait time for the 3rd wave wait_time = wait_time + wave_duration + wave_interval wave = 3 --printf("wave 3 at %d/%d", idx, #to_enter_map) end local unit_wait_time = wait_time + self:Random(wave_duration) entry.unit:SetCommand("EnterMap", self, entry.pos, unit_wait_time) end end end function AmbientZoneMarker:Despawn() for idx, units_def in ipairs(self.units) do for _, unit in ipairs(units_def) do if IsValid(unit) then unit:Despawn() end end end self.units = false end function AmbientZoneMarker:RegisterUnits(units) if self.units and #units > 0 then insert(self.units, units) end end function AmbientZoneMarker:GetExitZones(pos) local markers, markers_reachable = 0, {} local pfclass = CalcPFClass("player1") MapForEachMarker("ExitZoneInteractable", nil, function(marker) markers = markers + 1 local check_pos = GetPassSlab(marker) or marker:GetPos() local pfflags = const.pfmImpassableSource local has_path, closest_pos = pf.HasPosPath(pos, check_pos, pfclass, 0, 0, nil, 0, nil, pfflags) if has_path and closest_pos == check_pos then insert(markers_reachable, marker) end end) return markers, markers_reachable end function AmbientZoneMarker:GetEntranceMarker(unit) local obj = unit and (IsVisitingUnit(unit) and unit.last_visit or (unit:IsValidPos() and unit or nil)) or self local pass_slab = GetPassSlab(obj) local pos = pass_slab or obj:GetPos() local markers, markers_reachable = self:GetExitZones(pos) if #markers_reachable == 0 then local visitable = unit and unit.behavior == "Visit" and unit.behavior_params and unit.behavior_params[1] local AL_marker = visitable and visitable[1] local suppress_VMEs = IsKindOf(AL_marker, "AmbientLifeMarker") and AL_marker.Teleport if not suppress_VMEs then local info = (markers == 0) and "(No ExitZoneInteractable Markers)" or "" StoreErrorSource(self, string.format("AL zone unreachable by any ExitZoneInteractable marker%s", info)) if unit then StoreErrorSource(unit, string.format("Unit can't reach AL zone from ExitZoneInteractable marker(%s)", info, GetMapName())) else StoreErrorSource(self, string.format("Test Dummy Unit can't reach AL zone from ExitZoneInteractable marker(%s)", info, GetMapName())) end end end local closest = ChooseClosestObject(markers_reachable, pos) return closest end function AmbientZoneMarker:ReduceUnits(reduction_percents, exit_map) local units = {} for idx, units_def in ipairs(self.units) do for _, unit in ipairs(units_def) do if IsValid(unit) and not unit:IsDead() then insert(units, {unit = unit, idx = idx}) end end end local count = reduction_percents * #units / 100 for i = #units, 1, -1 do local entry = units[i] local unit = entry.unit local valid = IsValid(unit) if not valid or unit.command == "EnterMap" or not unit:IsValidPos() then if valid then unit:Despawn() end table.remove(units, i) table.remove_entry(self.units[entry.idx], unit) end end while #units > count do local k = 1 + InteractionRand(#units, "AmbientLifeReduction") local entry = units[k] local unit = entry.unit table.remove(units, k) table.remove_entry(self.units[entry.idx], unit) if exit_map then if unit.command ~= "Die" then local marker = self:GetEntranceMarker(unit) if marker then unit:SetCommand("ExitMap", marker) unit:SetCommandParamValue("ExitMap", "move_anim", "Run") else unit:Despawn() end end else if unit.command ~= "Die" and unit.command ~= "ExitMap" then unit:Despawn() end end end end function AmbientZoneMarker:GetRoamMarkers(unit) local roam_markers = {} local area = self:GetAreaBox() local CheckInside = area.Point2DInside for _, visitable in ipairs(g_Visitables) do local marker = visitable[1] local pos_or_marker = visitable[2] or marker if CheckInside(area, pos_or_marker) then if IsKindOf(marker, "AL_Roam") and not visitable.reserved and (not unit or marker:CanVisit(unit)) then if not IsInAmbientLifeRepulsionZone(pos_or_marker) then insert(roam_markers, visitable) end end end end return roam_markers end function AmbientZoneMarker:EditorGetText() local count = #(self:GetRoamMarkers() or empty_table) if count > 0 then return string.format("Roam Markers: %d", count) end end function AmbientZoneMarker:EditorGetTextColor() return const.clrGreen end function AmbientZoneMarker:UpdateText(marker_type_item) end function AmbientZoneMarker:RecreateText() EditorTextObject.EditorTextUpdate(self, "recreate") end function AmbientZoneMarker:EditorCallbackMove() GridMarker.EditorCallbackMove(self) self:RecreateText() end function AmbientZoneMarker:EditorCallbackRotate() GridMarker.EditorCallbackRotate(self) self:RecreateText() end function AmbientZoneMarker:VME_CheckAreaPositionsExits(positions) for _, packed_pos in ipairs(positions) do local pos = point(point_unpack(packed_pos)) local markers, markers_reachable = self:GetExitZones(pos) if markers == 0 then StoreErrorSource(pos, string.format("No ExitZoneInteractable markers to reach map exit from on Combat start!(%s)",GetMapName()), self) return end if #markers_reachable == 0 then StoreErrorSource(pos, string.format("No reachable ExitZoneInteractable markers from Area point on Combat start!(%s)", GetMapName()), self) end end end function AmbientZoneMarker:VME_Checks(check_unreachables) self:GetEntranceMarker() local positions = self:GetAreaPositions() if #positions == 0 then StoreErrorSource(self, "AmbientZoneMarker without valid area positions. Check Width and Height!") else if check_unreachables then self:VME_CheckAreaPositionsExits(positions) end end end OnMsg.ValidateMap = ValidateGameObjectProperties("AmbientZoneMarker") DefineClass.PropertyHelper_AppearanceObjectAbsolutePos = { __parents = {"PropertyHelper_AbsolutePos", "AppearanceObject"} } function PropertyHelper_AppearanceObjectAbsolutePos:GameInit() self:SetAnimPose(self.parent:GetAnim(), self.parent.VisitPose) self:Face(self.parent) self.parent:Face(self) end function PropertyHelper_AppearanceObjectAbsolutePos:EditorCallback(action_id) PropertyHelper_AbsolutePos.EditorCallback(self, action_id) self:Face(self.parent) self.parent:Face(self) end local function GatherUnits() local neutral, neutral_dead, military_dead = {}, {}, {} for _, unit in ipairs(g_Units) do if not unit.team or unit.team.side == "neutral" then local behavior = g_Combat and unit.combat_behavior or unit.behavior if not unit.conflict_ignore then local dead = unit:IsDead() or unit.command == "Die" insert(dead and neutral_dead or neutral, unit) end else if unit:IsDead() or unit.command == "Die" then insert(military_dead, unit) end end end return neutral, neutral_dead, military_dead end function MakeCowards(command_required) local neutral = GatherUnits() NetUpdateHash("MakeCowards", #neutral) for _, unit in ipairs(neutral) do if unit.command == command_required or (unit.command ~= "Cower" and unit.command ~= "ExitMap") then if unit:IsVisiting() then local marker = unit.behavior_params[1] if marker and IsKindOf(marker[1], "AmbientLifeMarker") then unit.visit_command = unit.behavior unit.visit_marker = marker end end if unit:IsValidPos() then if unit:CanCower() then unit:SetCommand("Cower", "find cower spot") unit:SetCommandParamValue("Cower", "move_anim", "Run") end unit:UpdateMoveAnim() end end end end -- Unmake cowards function OnMsg.GroupChangeSide(group, toSide, units) if toSide ~= "enemy1" and toSide ~= "enemy2" then return end for i, u in ipairs(units) do if u.combat_behavior == "Cower" then u:SetCombatBehavior() u:SetCommand("Idle") end end end function OnMsg.UnitSideChanged(unit, newTeam) local newSide = newTeam and newTeam.side if not newSide or (newSide ~= "enemy1" and newSide ~= "enemy2") then return end if unit.combat_behavior == "Cower" then unit:SetCombatBehavior() unit:SetCommand("Idle") end end function CalmDownCowards() for _, unit in ipairs(g_Units) do if unit.command == "Cower" then unit:SetBehavior() if unit.visit_command then local command, marker = unit.visit_command, unit.visit_marker unit.visit_command, unit.visit_marker = false, false if marker and IsValid(marker[1]) then marker.reserved = unit.handle unit:SetCommand(command, marker) else unit:SetCommand("Idle") end else unit:SetCommand("Idle") end end end end MapVar("g_AmbientLifeSpawn", false) function AmbientLifeToggle() Msg("AmbientLifeDespawn") -- clears if something is already spawned on first cheat use g_AmbientLifeSpawn = not g_AmbientLifeSpawn if g_AmbientLifeSpawn then Msg("AmbientLifeSpawn") else Msg("AmbientLifeDespawn") end end function AmbientLifePerpetualMarkersSteal() local spawn_markers = {} MapForEach("map", "AmbientLifeMarker", function(marker) if not marker.perpetual_unit then marker.steal_activated = false if marker:IsCollectionLeader() and marker:CanSpawn() then insert(spawn_markers, marker) end end end) table.shuffle(spawn_markers, InteractionRand(nil, "AmbientLifeSpawn")) for _, marker in ipairs(spawn_markers) do marker:SpawnCollection() end end function OnMsg.AmbientLifeSpawn() FireNetSyncEventOnHostOnce("AmbientLifeSpawn") end function NetSyncEvents.AmbientLifeSpawn() -- free perpetual units from disabled perpetual markers MapForEach("map", "AmbientLifeMarker", function(marker) if marker.perpetual_unit and not marker:CanSpawn() then marker.steal_activated = false marker.perpetual_unit.perpetual_marker = false marker.perpetual_unit = false end end) SuppressTeamUpdate = true MapForEach("map", "AmbientZoneMarker", function(zone) if zone:CanSpawn() then zone:Spawn() end end) SuppressTeamUpdate = false Msg("TeamsUpdated") AmbientLifePerpetualMarkersSteal() Msg("AmbientLifeSpawned") end function OnMsg.AmbientLifeDespawn() FireNetSyncEventOnHostOnce("AmbientLifeDespawn") end function NetSyncEvents.AmbientLifeDespawn() MapForEach("map", "AmbientLifeMarker", function(marker) marker:Despawn() end) MapForEach("map", "AmbientZoneMarker", function(zone) zone:Despawn() end) Msg("AmbientLifeDespawned") end MapVar("s_SpawnALForbidden", false) function OnMsg.NewGameSessionStart() s_SpawnALForbidden = true end function OnMsg.InitSessionCampaignObjects() s_SpawnALForbidden = false end local interestingStates = { RainHeavy = true, RainLight = true, Conflict = true, ConflictScripted = true, Combat = true, } function OnMsg.GameStateChanged(changed) if netInGame and IsChangingMap() then return end for k, v in sorted_pairs(changed) do if interestingStates[k] then FireNetSyncEventOnHostOnce("AmbientLifeOnGameStateChanged", changed) return end end end function KickOutUnits() MapForEach("map", "AmbientZoneMarker", function(zone) if not zone.ConflictIgnore then zone:ReduceUnits(const.AmbientLife.ConflictReduction, "exit map") end end) MakeCowards() end function NetSyncEvents.AmbientLifeOnGameStateChanged(changed) local didWork = false SuppressTeamUpdate = true if changed.RainHeavy or changed.RainLight then for _, unit in ipairs(g_Units) do unit:ResetMoveStyle() end didWork = true end if not ChangingMap and GetMapName() ~= "" then if changed.Conflict or changed.ConflictScripted then if not (g_Combat or g_StartingCombat) then KickOutUnits() didWork = true end elseif (changed.Conflict == false or changed.ConflictScripted == false) and not s_SpawnALForbidden then if not (GameState.Conflict or GameState.ConflictScripted) then CalmDownCowards() MapForEach("map", "AmbientZoneMarker", function(zone) if zone:CanSpawn() then if not (zone.ConflictIgnore or zone:IsKindOf("AmbientZone_Animal"))then zone:Spawn("refill") end end end) didWork = true end end if changed.Combat and not (GameState.Conflict or GameState.ConflictScripted) then -- straight to turn based mode local neutral = GatherUnits() for _, unit in ipairs(neutral) do local cmd = unit.command if cmd == "EnterMap" or not unit:IsValidPos() then unit:Despawn() elseif cmd ~= "Idle" and not unit:IsDead() and not unit:IsDefeatedVillain() then unit:SetCommand("Idle") end end didWork = true end end SuppressTeamUpdate = false if didWork then Msg("TeamsUpdated") end end function OnMsg.UnitAwarenessChanged(unit) if g_Combat then CreateGameTimeThread(MakeCowards, "Idle") end end function AmbientLifeVisibilityDistanceCheck() for _, unit in ipairs(g_Units) do if unit.command == "Cower" and GameTime() > (unit.cower_cooldown or 0) then local visibility = g_Visibility[unit] for _, threat in ipairs(visibility) do if threat.team and threat.team.side ~= "neutral" then if unit:GetDist2D(threat) < const.AmbientLife.CowerRunDist then unit.cower_from, unit.cower_angle = threat:GetVisualPos(), threat:GetAngle() Msg(unit) break end end end end end end OnMsg.ExplorationComputedVisibility = AmbientLifeVisibilityDistanceCheck local tff = table.findfirst function OnMsg.EnterSector(_, load_game) local marker_units = {} local no_marker_kicks = {} for _, unit in ipairs(g_Units) do local visitable = unit.behavior == "Visit" and unit.behavior_params[1] if visitable then local marker = visitable[1] if marker then marker_units[marker] = marker_units[marker] or {} table.insert(marker_units[marker], unit) else unit:SetBehavior() unit:SetCommand(false) table.insert(no_marker_kicks, unit) end end end --print(string.format("Kicked-out units do to deleted marker: %d", #no_marker_kicks)) local kicked = {} for marker, units in pairs(marker_units) do local unit = units[1] local marker = unit.behavior == "Visit" and unit.behavior_params[1] and unit.behavior_params[1][1] if #units > 1 then local idx = tff(units, function(_, u) return marker:CanVisit(u, "for perpetual") end) or 1 unit = units[idx] table.remove(units, idx) local visitable = marker:GetVisitable() for _, u in ipairs(units) do if IsValid(u) and not u:IsDead() then u:FreeVisitable(visitable) u:SetBehavior() u:SetCommand(false) table.insert(kicked, u) end end unit:ReserveVisitable(visitable) end end --print(string.format("Kicked out pre-occupied AL marker units: %d", #kicked)) end function OnMsg.SetpieceEnded(setpiece) local neutral = GatherUnits() for _, unit in ipairs(neutral) do if unit.command == "ExitMap" then unit:Despawn() elseif unit.command == "Cower" then unit:TeleportToCower() end end end function SavegameSectorDataFixups.AmbientLifeVisitables() local used = {} local duplicates = 0 for i = #g_Visitables, 1, -1 do local visitable = g_Visitables[i] local marker = visitable[1] if used[marker] then table.remove(g_Visitables, i) duplicates = duplicates + 1 else used[marker] = true end end --print(string.format("Removed duplicated visitables: %d", duplicates)) end function GetALMarkersGroups() local marker_groups = {} for id, group in sorted_pairs(Groups) do for _, o in ipairs(group) do if IsKindOf(o, "AmbientLifeMarker") then marker_groups[#marker_groups + 1] = id break end end end return marker_groups end function IsVisitingUnit(unit, AL_class) return IsKindOf(unit, "Unit") and unit.behavior == "Visit" and (not AL_class or IsKindOf(unit.last_visit, AL_class)) end function IsSittingUnit(unit) return IsVisitingUnit(unit, "AL_SitChair") and unit.visit_reached end function IsWallLeaningUnit(unit) return IsVisitingUnit(unit, "AL_WallLean") and unit.visit_reached end function GetALMarkerGroups() local groups = {} MapForEach("map", "AmbientLifeMarker", function(marker) if not IsKindOf(marker, "AmbientLifeMarker") then return end for _, group in ipairs(marker.Groups) do if not groups[group] then groups[group] = true table.insert(groups, group) end end end) return groups end function DbgTestClosestALMarker() local pos = GetPassSlab(GetTerrainCursor()) if not pos then StoreErrorSource(GetTerrainCursor(), "DbgTestClosestALMarker: Mouse cursor should be on passable!") end local closest_visitable = GetClosestVisitable(pos) if closest_visitable then closest_visitable[1]:DbgTest(pos, closest_visitable) else print("No AL marker found nearby") end end local function RegAppearanceEntities(preset, entities) local appearance = FindPreset("AppearancePreset", preset) if appearance then AppearanceMarkEntities(appearance, entities) end end function OnMsg.GatherMapEntities(entities, objs) for _, obj in ipairs(objs) do if IsKindOfClasses(obj, "UnitMarker", "DummyUnit", "CheeringDummy") then RegAppearanceEntities(obj.Appearance, entities) elseif IsKindOf(obj, "AmbientZoneMarker") then for _, def in ipairs(obj.SpawnDefs) do if def.Appearance then RegAppearanceEntities(obj.Appearance, entities) else for _, group in ipairs(Presets.UnitDataCompositeDef) do local unit_def = table.find_value(group, "id", def.UnitDef) if unit_def then local list = unit_def.AppearancesList or empty_table for _, ap_weight in ipairs(list) do RegAppearanceEntities(ap_weight.Preset, entities) end break end end end end elseif IsKindOf(obj, "AmbientLifeMarker") then entities[obj.ToolEntity] = true if obj.Weapon and obj.Weapon ~= "" then local preset = FindPreset("InventoryItemCompositeDef", obj.Weapon) assert(preset) GatherWeaponPresetEntities(preset, entities) end end end end