Download Lua/AmbientLife.lua from sirnii/myspace: direct link, hf CLI and curl.
- Browser
- Download file 77 kB
-
https://huggingface.co/spaces/sirnii/myspace/resolve/main/Lua/AmbientLife.lua
- Command line
-
hf download hf://spaces/sirnii/myspace/Lua/AmbientLife.lua
-
curl -L -o AmbientLife.lua https://huggingface.co/spaces/sirnii/myspace/resolve/main/Lua/AmbientLife.lua
77 kB
| ------------------------------------------------------------ | |
| ------------------- 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("<style GedName><class></style> <GroupsText>"), 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<count> 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("<UnitDef> : <CountMin>-<CountMax>"), | |
| } | |
| 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 |