|
DefineClass.MachineGunEmplacement = { |
|
__parents = { "Interactable", "Object", "GameDynamicDataObject", "EditorObject", "VoxelSnappingObj", "StripComponentAttachProperties", "EntityChangeKeepsFlags" }, |
|
entity = "WayPoint", |
|
variable_entity = true, |
|
flags = { efCollision = true, efApplyToGrids = false, efWalkable = false }, |
|
properties = { |
|
{ category = "Emplacement", id = "weapon_template", name = "Weapon Template", editor = "preset_id", default = "BrowningM2HMG", |
|
preset_class = "InventoryItemCompositeDef", preset_filter = function (preset, obj) return preset.object_class == "MachineGun" end, }, |
|
{ category = "Emplacement", id = "ammo_template", name = "Ammo Template", editor = "preset_id", default = false, |
|
preset_class = "InventoryItemCompositeDef", preset_filter = function (preset, obj) |
|
local wt = InventoryItemDefs[obj.weapon_template] |
|
return wt and preset.object_class == "Ammo" and preset.Caliber == wt.Caliber |
|
end, |
|
no_edit = function(self) return not InventoryItemDefs[self.weapon_template] end }, |
|
{ category = "Emplacement", id = "target_dist", name = "Target Distance", editor = "number", scale = "m", min = 3*guim, max = 20*guim, default = 10*guim, slider = true }, |
|
|
|
{ category = "Usage", id = "appeal_per_target", name = "Appeal Per Target", editor = "number", default = 1000, help = "Base Appeal score per target in threatened area." }, |
|
{ category = "Usage", id = "appeal_optimal_dist", name = "Appeal Optimal Distance", editor = "number", scale = "m", default = 15*guim, min = 0, help = "Distance at which targets in threatened area score their base Appeal Per Target points." }, |
|
{ category = "Usage", id = "appeal_per_meter", name = "Appeal/m", editor = "number", scale = "%", default = -10, help = "Appeal modifier applied additively for each meter difference from Appeal Optimal Distance value." }, |
|
{ category = "Usage", id = "appeal_decay", name = "Appeal Decay", editor = "number", scale = "%", default = 30, min = 0, help = "Appeal lost at the start of new turn before reevaluating potential targets.", }, |
|
{ category = "Usage", id = "appeal_use_threshold", name = "Use Threshold", editor = "number", default = 150, help = "Appeal score above which the AI will seek to use this Emplacement." }, |
|
{ category = "Usage", id = "exploration_manned", name = "Manned in Exploration", editor = "bool", default = false }, |
|
{ category = "Usage", id = "personnel_search_dist", name = "Personnel Search Distance", editor = "number", scale = "m", default = 10*guim, min = 0, no_edit = function(self) return not self.exploration_manned end, help = "Units closer than this distance can be assigned to this Emplacement." }, |
|
{ category = "Usage", id = "start_combat_appeal", name = "Start Combat Appeal", editor = "number", default = 1000, no_edit = function(self) return not self.exploration_manned end, help = "Initial Appeal score in combat if the Emplacement is already manned." }, |
|
}, |
|
area_visual = false, |
|
interaction_visuals = false, |
|
manned_by = false, |
|
appeal = false, |
|
weapon = false, |
|
updating = false, |
|
exploration_personnel_chosen = false, |
|
exploration_update_thread = false, |
|
} |
|
|
|
function MachineGunEmplacement:Init() |
|
|
|
self:SetEnumFlags(const.efCollision) |
|
end |
|
|
|
function MachineGunEmplacement:GameInit() |
|
if IsEditorActive() then |
|
self:EditorEnter() |
|
else |
|
self:EditorExit() |
|
end |
|
self.exploration_update_thread = CreateGameTimeThread(function(self) |
|
while IsValid(self) do |
|
self:ExplorationUpdateTick() |
|
Sleep(1000) |
|
end |
|
self.exploration_update_thread = nil |
|
end, self) |
|
end |
|
|
|
function MachineGunEmplacement:Done() |
|
if self.area_visual then |
|
DoneObject(self.area_visual) |
|
self.area_visual = nil |
|
end |
|
|
|
if self.weapon then |
|
DoneObject(self.weapon) |
|
self.weapon = nil |
|
end |
|
for _, obj in ipairs(self.interaction_visuals) do |
|
DoneObject(obj) |
|
end |
|
self.interaction_visuals = nil |
|
if IsValidThread(self.exploration_update_thread) then |
|
DeleteThread(self.exploration_update_thread) |
|
self.exploration_update_thread = nil |
|
end |
|
end |
|
|
|
function MachineGunEmplacement:Destroy() |
|
if IsValid(self.manned_by) and not self.manned_by:IsDead() then |
|
self.manned_by:LeaveEmplacement(true) |
|
end |
|
return Object.Destroy(self) |
|
end |
|
|
|
function MachineGunEmplacement:SetPos(...) |
|
Interactable.SetPos(self, ...) |
|
self:Update() |
|
end |
|
|
|
function MachineGunEmplacement:SetAngle(...) |
|
Interactable.SetAngle(self, ...) |
|
self:Update() |
|
end |
|
|
|
function MachineGunEmplacement:SetProperty(name, value) |
|
PropertyObject.SetProperty(self, name, value) |
|
if name == "weapon_template" or name == "target_dist" and not self.updating then |
|
self:Update() |
|
end |
|
end |
|
|
|
function MachineGunEmplacement:OnPropertyChanged(prop_id) |
|
if prop_id == "weapon_template" or prop_id == "target_dist" and not self.updating then |
|
self:Update() |
|
end |
|
end |
|
|
|
function MachineGunEmplacement:EditorEnter() |
|
self:ChangeEntity(self.entity) |
|
self:Update() |
|
end |
|
|
|
function MachineGunEmplacement:EditorExit() |
|
self:ChangeEntity("") |
|
self:Update() |
|
end |
|
|
|
function MachineGunEmplacement:SetCollision(value) |
|
CObject.SetCollision(self, value) |
|
local weapon_visual = self.weapon and self.weapon:GetVisualObj() |
|
if weapon_visual then |
|
weapon_visual:SetCollision(value) |
|
end |
|
end |
|
|
|
function MachineGunEmplacement:Update() |
|
local weapon = self.weapon |
|
local ammo = weapon and weapon.ammo |
|
local need_update |
|
|
|
self.updating = true |
|
|
|
if weapon then |
|
need_update = weapon.class ~= self.weapon_template |
|
if ammo then |
|
need_update = need_update or (ammo.class ~= self.class) |
|
else |
|
need_update = need_update or not not InventoryItemDefs[self.class] |
|
end |
|
else |
|
need_update = not not InventoryItemDefs[self.weapon_template] |
|
end |
|
if need_update then |
|
if weapon then |
|
DoneObject(weapon) |
|
self.weapon = nil |
|
weapon = nil |
|
end |
|
|
|
if InventoryItemDefs[self.weapon_template] then |
|
weapon = PlaceInventoryItem(self.weapon_template) |
|
self.weapon = weapon |
|
|
|
local ammo_template = self.ammo_template |
|
if not ammo_template then |
|
local ammo = GetAmmosWithCaliber(weapon.Caliber, "sort")[1] |
|
ammo_template = ammo and ammo.id |
|
end |
|
|
|
if InventoryItemDefs[ammo_template] then |
|
local ammo = PlaceInventoryItem(ammo_template) |
|
ammo.Amount = weapon.MagazineSize |
|
weapon:Reload(ammo, "suspend fx") |
|
DoneObject(ammo) |
|
end |
|
end |
|
|
|
if weapon then |
|
|
|
local min_aim_range = weapon:GetOverwatchConeParam("MinRange") * const.SlabSizeX |
|
local max_aim_range = weapon:GetOverwatchConeParam("MaxRange") * const.SlabSizeX |
|
|
|
self.properties = table.copy(g_Classes[self.class].properties) |
|
local idx = table.find(self.properties, "id", "target_dist") |
|
if idx then |
|
self.properties[idx] = { category = "Emplacement", id = "target_dist", name = "Target Distance", editor = "number", scale = "m", min = min_aim_range, max = max_aim_range, default = min_aim_range, slider = max_aim_range > min_aim_range, read_only = min_aim_range == max_aim_range } |
|
self:SetProperty("target_dist", min_aim_range) |
|
end |
|
else |
|
|
|
self.properties = nil |
|
local meta = self:GetPropertyMetadata("target_dist") |
|
self:SetProperty("target_dist", meta.default) |
|
end |
|
end |
|
|
|
local pos = self:GetPos() |
|
local angle = self:GetAngle() |
|
|
|
local visual = weapon and weapon:GetVisualObj() |
|
if visual then |
|
self:Attach(visual) |
|
visual:SetCollision(self:GetCollision()) |
|
end |
|
|
|
for _, obj in ipairs(self.interaction_visuals) do |
|
DoneObject(obj) |
|
end |
|
self.interaction_visuals = nil |
|
|
|
if weapon and IsEditorActive() then |
|
|
|
local cone_angle = weapon.OverwatchAngle |
|
local min_aim_range = weapon:GetOverwatchConeParam("MinRange") * const.SlabSizeX |
|
local max_aim_range = weapon:GetOverwatchConeParam("MaxRange") * const.SlabSizeX |
|
local distance = Clamp(self.target_dist, min_aim_range, max_aim_range) |
|
self.target_dist = distance |
|
local target = pos + Rotate(point(distance, 0, 0), angle) |
|
local step_positions, step_objs = GetStepPositionsInArea(pos, distance, 0, cone_angle, angle, "force2d") |
|
step_objs = empty_table |
|
self.area_visual = CreateAOETilesSector(step_positions, step_objs, empty_table, self.area_visual, pos, target, 1*guim, distance, cone_angle, "Overwatch_WeaponEditor") |
|
|
|
|
|
self.interaction_visuals = {} |
|
local valid = self:GetValidInteractionPositions() |
|
for _, pos in ipairs(valid) do |
|
local obj = AppearanceObject:new() |
|
obj:SetPos(point_unpack(pos)) |
|
obj:ApplyAppearance("Soldier_Local_01") |
|
obj:SetHierarchyGameFlags(const.gofWhiteColored) |
|
self.interaction_visuals[#self.interaction_visuals + 1] = obj |
|
end |
|
|
|
if self.area_visual then |
|
self.area_visual:SetColorModifier((not valid or #valid == 0) and RGB(255, 0, 0) or RGB(128, 128, 128)) |
|
end |
|
else |
|
if self.area_visual then |
|
DoneObject(self.area_visual) |
|
self.area_visual = nil |
|
end |
|
end |
|
ObjModified(self) |
|
self.updating = false |
|
end |
|
|
|
function MachineGunEmplacement:GetEnemyUnitsInArea(attacker) |
|
local weapon = self.weapon |
|
local units = {} |
|
|
|
if not weapon or not self:IsValidPos() then |
|
return units |
|
end |
|
|
|
local pos = self:GetPos() |
|
local angle = self:GetAngle() |
|
local target = pos + Rotate(point(self.target_dist, 0, 0), angle) |
|
|
|
local aoe_params = { |
|
cone_angle = weapon.OverwatchAngle, |
|
min_range = weapon:GetOverwatchConeParam("MinRange"), |
|
max_range = weapon:GetOverwatchConeParam("MaxRange"), |
|
weapon = weapon, |
|
attacker = attacker, |
|
step_pos = pos, |
|
target_pos = target, |
|
used_ammo = 1, |
|
damage_mod = 100, |
|
attribute_bonus = 0, |
|
dont_destroy_covers = true, |
|
prediction = true, |
|
} |
|
local aoe = GetAreaAttackResults(aoe_params) |
|
for i, aoeHit in ipairs(aoe) do |
|
if IsKindOf(aoeHit.obj, "Unit") and attacker:IsOnEnemySide(aoeHit.obj) then |
|
table.insert_unique(units, aoeHit.obj) |
|
end |
|
end |
|
return units |
|
end |
|
|
|
function MachineGunEmplacement:GetDynamicData(data) |
|
if IsValid(self.manned_by) then |
|
data.manned_by = self.manned_by.handle |
|
end |
|
data.condition = self.weapon and self.weapon.Condition or nil |
|
end |
|
|
|
function MachineGunEmplacement:SetDynamicData(data) |
|
if data.manned_by then |
|
self.manned_by = HandleToObject[data.manned_by] |
|
end |
|
self:Update() |
|
if self.weapon and data.condition then |
|
self.weapon.Condition = data.condition |
|
end |
|
end |
|
|
|
function MachineGunEmplacement:GetTitle() |
|
return T(163835576952, "Machine Gun") |
|
end |
|
|
|
function MachineGunEmplacement:GetInteractionCombatAction(unit) |
|
if self.manned_by then return end |
|
return Presets.CombatAction.Interactions.Interact_ManEmplacement |
|
end |
|
|
|
function MachineGunEmplacement:GetOperatePos() |
|
local visual = self.weapon and self.weapon:GetVisualObj() |
|
local spot = visual and visual:GetSpotBeginIndex("Unit") |
|
local pos = visual and visual:GetSpotPos(spot) |
|
return pos |
|
end |
|
|
|
function MachineGunEmplacement:GetInteractionPos(unit) |
|
if not IsValid(self) then return false end |
|
local operate_pos = self:GetOperatePos() |
|
local passx, passy, passz = SnapToPassSlabXYZ(operate_pos or self) |
|
if unit:IsEqualPos(passx, passy, passz) then |
|
return point(passx, passy, passz) |
|
end |
|
local pos = unit:GetClosestMeleeRangePos(self, operate_pos, nil, "interaction") |
|
return pos |
|
end |
|
|
|
function MachineGunEmplacement:EndInteraction(unit) |
|
unit:EnterEmplacement(self, false) |
|
unit:RecalcUIActions(true) |
|
unit:UpdateOutfit() |
|
local dist = Min(self.target_dist, CombatActions.Overwatch:GetMaxAimRange(unit, self.weapon) * const.SlabSizeX) |
|
local target = RotateRadius(dist, self:GetAngle(), self) |
|
unit:QueueCommand("MGTarget", "MGSetup", 0, {target = target}) |
|
end |
|
|
|
function MachineGunEmplacement:GetValidInteractionPositions() |
|
return GetMeleeRangePositions(nil, self, nil, true) |
|
end |
|
|
|
function MachineGunEmplacement:GetError() |
|
local errors = {} |
|
local ammo = InventoryItemDefs[self.ammo_template] |
|
local weapon = InventoryItemDefs[self.weapon_template] |
|
if not ammo or ammo.caliber ~= weapon.caliber then |
|
local default_ammo |
|
ForEachPreset("InventoryItemCompositeDef", function(obj) |
|
if obj.object_class == "Ammo" and obj.Caliber == weapon.Caliber then |
|
default_ammo = obj.id |
|
return "break" |
|
end |
|
end) |
|
|
|
if default_ammo then |
|
self.ammo_template = default_ammo |
|
errors[#errors+1] = "Missing or incorrect ammo set for MG Emplacement, replaced with " .. default_ammo |
|
else |
|
errors[#errors+1] = "Missing or incorrect ammo set for MG Emplacement, compatible ammo not found" |
|
end |
|
end |
|
if #(self:GetValidInteractionPositions() or "") == 0 then |
|
errors[#errors+1] = "MG Emplacement has no valid interaction positions" |
|
end |
|
if next(errors) then |
|
return table.concat(errors, "\n") |
|
end |
|
end |
|
|
|
function OnMsg.UnitDied(unit) |
|
MapForEach("map", "MachineGunEmplacement", function(obj) |
|
if obj.manned_by == unit then |
|
obj.manned_by = false |
|
end |
|
end) |
|
end |
|
|
|
function OnMsg.DeploymentModeSet() |
|
|
|
for i, u in ipairs(g_Units) do |
|
if u:HasStatusEffect("ManningEmplacement") then |
|
u:RemoveStatusEffect("ManningEmplacement") |
|
end |
|
if u:HasStatusEffect("StationedMachineGun") then |
|
u:RemoveStatusEffect("StationedMachineGun") |
|
end |
|
end |
|
end |
|
|
|
function OnMsg.EnterSector() |
|
|
|
for i, u in ipairs(g_Units) do |
|
if not u:HasStatusEffect("ManningEmplacement") then goto continue end |
|
|
|
local emplacementSector = u:GetEffectValue("hmg_sector") |
|
if emplacementSector and emplacementSector ~= gv_CurrentSectorId then |
|
u:RemoveStatusEffect("ManningEmplacement") |
|
goto continue |
|
end |
|
|
|
local emplacementHandle = u:GetEffectValue("hmg_emplacement") |
|
local emplacementObj = HandleToObject[emplacementHandle] |
|
if not emplacementObj then |
|
u:RemoveStatusEffect("ManningEmplacement") |
|
goto continue |
|
end |
|
|
|
::continue:: |
|
end |
|
|
|
|
|
MapForEach("map", "MachineGunEmplacement", function(obj) |
|
local manned = obj.manned_by |
|
if not IsValid(manned) or (manned and not manned:HasStatusEffect("ManningEmplacement")) then |
|
obj.manned_by = false |
|
end |
|
end) |
|
end |
|
|
|
function OnMsg.CombatStarting() |
|
MapForEach("map", "MachineGunEmplacement", function(obj) |
|
obj.appeal = {} |
|
if IsValid(obj.manned_by) and obj.manned_by.team and obj.manned_by.team.player_enemy then |
|
obj.appeal[obj.manned_by.team.side] = 1000 |
|
g_Combat:AssignEmplacement(obj, obj.manned_by) |
|
end |
|
end) |
|
end |
|
|
|
function MachineGunEmplacement:ExplorationUpdateTick() |
|
if self.exploration_personnel_chosen then |
|
if self.exploration_personnel_chosen.command == "InteractWith" then |
|
return |
|
end |
|
self.exploration_personnel_chosen = false |
|
end |
|
if g_Combat or not self.exploration_manned or IsValid(self.manned_by) then |
|
return |
|
end |
|
|
|
local gunner |
|
local mindist = self.personnel_search_dist + 1 |
|
for _, team in ipairs(g_Teams) do |
|
if team.player_enemy then |
|
for _, unit in ipairs(team.units) do |
|
if IsCloser(self, unit, gunner or mindist) then |
|
if not unit:IsIncapacitated() and not unit:HasStatusEffect("Unconscious") then |
|
gunner = unit |
|
end |
|
end |
|
end |
|
end |
|
end |
|
if gunner then |
|
|
|
local action = self:GetInteractionCombatAction(gunner) |
|
if action and gunner:CanInteractWith(self) then |
|
if AIStartCombatAction(action.id, gunner, 0, {target = self}) then |
|
self.exploration_personnel_chosen = gunner |
|
end |
|
end |
|
end |
|
end |
|
|