|
MapVar("MoraleEffectCooldown", {}) |
|
MapVar("MoraleModifierCooldown", {}) |
|
MapVar("MoraleGlobalCooldown", 0) |
|
MapVar("MoraleActionThread", false) |
|
|
|
local modifier_cooldowns = { |
|
|
|
|
|
|
|
} |
|
|
|
MoraleLevelName = { |
|
[-3] = T(738790082445, "<error>Abysmal</error>"), |
|
[-2] = T(628671961455, "<error>Very Low</error>"), |
|
[-1] = T(966377690475, "<error>Low</error>"), |
|
[0] = T(274341293889, "Stable"), |
|
[1] = T(899829984127, "High"), |
|
[2] = T(981991901247, "Very High"), |
|
[3] = T(447600477466, "Exceptional"), |
|
} |
|
|
|
MoraleLevelIcon = { |
|
[-2] = "UI/Hud/morale_very_low.png", |
|
[-1] = "UI/Hud/morale_low.png", |
|
[0] = "UI/Hud/morale_normal.png", |
|
[1] = "UI/Hud/morale_high.png", |
|
[2] = "UI/Hud/morale_very_high.png", |
|
} |
|
|
|
local function GetMoraleEffectTarget(effect, team) |
|
if effect.AppliedTo == "custom" then |
|
return effect:GetTargetUnit(team) |
|
end |
|
|
|
local units |
|
if effect.AppliedTo == "teammate" then |
|
units = table.icopy(team.units) |
|
else |
|
units = {} |
|
for _, t in ipairs(g_Teams) do |
|
if effect.AppliedTo == "ally" and (t == team or t:IsAllySide(team)) then |
|
table.iappend(units, t.units) |
|
elseif effect.AppliedTo == "enemy" and t:IsEnemySide(team) then |
|
table.iappend(units, t.units) |
|
end |
|
end |
|
end |
|
|
|
units = table.ifilter(units, function(idx, unit) return not unit:IsIncapacitated() and unit.species == "Human" and unit:IsAware() end) |
|
|
|
if effect.AppliedTo ~= "enemy" then |
|
|
|
local bestMerc |
|
for _, unit in ipairs(units) do |
|
if not bestMerc then |
|
bestMerc = unit |
|
elseif effect.Activation == "positive" and bestMerc:GetPersonalMorale() < unit:GetPersonalMorale() then |
|
bestMerc = unit |
|
elseif effect.Activation == "negative" and bestMerc:GetPersonalMorale() > unit:GetPersonalMorale() then |
|
bestMerc = unit |
|
end |
|
end |
|
|
|
|
|
local morale = bestMerc and bestMerc:GetPersonalMorale() |
|
if morale then |
|
for idx, unit in ipairs(units) do |
|
if unit:GetPersonalMorale() ~= morale then |
|
table.remove(units, idx) |
|
end |
|
end |
|
end |
|
end |
|
|
|
if #units > 0 then |
|
return table.interaction_rand(units, "Combat") |
|
end |
|
end |
|
|
|
function GetEnemyPanicTargets(team) |
|
local ref_unit |
|
for _, unit in ipairs(team.units) do |
|
if not unit:IsDead() then |
|
ref_unit = unit |
|
break |
|
end |
|
end |
|
if not ref_unit then return end |
|
|
|
local enemies = table.icopy(GetAllEnemyUnits(ref_unit)) |
|
local num_targets = (team.morale < 2) and 1 or InteractionRandRange(1, 3, "Combat") |
|
local targets = {} |
|
while #enemies > 0 and #targets < num_targets do |
|
local unit, idx = table.interaction_rand(enemies, "Combat") |
|
targets[#targets + 1] = unit |
|
table.remove(enemies, idx) |
|
end |
|
return enemies |
|
end |
|
|
|
function CombatTeam:GetMoraleEffectChance(effect_type, leadership) |
|
if not leadership then |
|
leadership = 0 |
|
for _, unit in ipairs(self.units) do |
|
if not unit:IsIncapacitated() then |
|
leadership = Max(leadership, unit.Leadership) |
|
end |
|
end |
|
end |
|
if effect_type == "positive" then |
|
return 20 * self.morale * Max(0, leadership - 50) / 50 |
|
end |
|
assert(effect_type == "negative") |
|
return Max(0, -20 * self.morale * (50 - Max(0, leadership - 50)) / 50) |
|
end |
|
|
|
function CombatTeam:ChangeMorale(delta, event) |
|
if not g_Combat then return end |
|
|
|
assert(self:IsPlayerControlled()) |
|
self.morale = Clamp(self.morale + delta, -2, 2) |
|
|
|
if delta > 0 then |
|
for _, unit in ipairs(self.units) do |
|
if HasPerk(unit, "Pessimist") then |
|
local chance = CharacterEffectDefs.Pessimist:ResolveValue("procChance") |
|
local roll = InteractionRand(100, "Pessimist") |
|
if roll < chance then |
|
PlayVoiceResponse(unit, "Pessimist") |
|
CombatLog("important", T(877663227979, "Pessimist: Morale increase event negated")) |
|
return |
|
end |
|
end |
|
end |
|
|
|
CombatLog("important", T{990449238632, "<em>Morale</em> is improving and is now <em><morale_level></em> (<event>)", morale_level = MoraleLevelName[self.morale], event = event}) |
|
else |
|
for _, unit in ipairs(self.units) do |
|
if HasPerk(unit, "Optimist") then |
|
local chance = CharacterEffectDefs.Optimist:ResolveValue("procChance") |
|
local roll = InteractionRand(100, "Optimist") |
|
if roll < chance then |
|
PlayVoiceResponse(unit, "Optimist") |
|
CombatLog("important", T(875387191185, "Optimist: Morale decrease event negated")) |
|
return |
|
end |
|
end |
|
end |
|
|
|
CombatLog("important", T{293473420725, "<em>Morale</em> is dropping and is now <em><morale_level></em> (<event>)", morale_level = MoraleLevelName[self.morale], event = Untranslated(event)}) |
|
|
|
if self.morale <= -2 then |
|
PlayVoiceResponse(table.rand(self.units), "TacticalLoss") |
|
end |
|
end |
|
|
|
if event and modifier_cooldowns[event] then |
|
MoraleModifierCooldown[event] = g_Combat.current_turn + modifier_cooldowns[event] |
|
end |
|
|
|
if MoraleGlobalCooldown >= g_Combat.current_turn or #self.units == 0 then |
|
|
|
end |
|
|
|
local leadership = 0 |
|
for _, unit in ipairs(self.units) do |
|
leadership = Max(leadership, unit.leadership) |
|
end |
|
|
|
|
|
local effect_targets = {} |
|
local eligible_effects = {} |
|
for id, effect in sorted_pairs(MoraleEffects) do |
|
local target |
|
|
|
local can_activate |
|
local chance = self:GetMoraleEffectChance(effect.Activation, leadership) |
|
if effect.Activation == "positive" then |
|
can_activate = (delta > 0 and self.morale > 0) and (InteractionRand(100, "Combat") < chance) |
|
elseif effect.Activation == "negative" then |
|
can_activate = (delta < 0 and self.morale < 0) and (InteractionRand(100, "Combat") < chance) |
|
end |
|
|
|
if can_activate and (MoraleEffectCooldown[id] or 0) < g_Combat.current_turn then |
|
target = GetMoraleEffectTarget(effect, self) |
|
end |
|
|
|
if target then |
|
effect_targets[id] = target |
|
eligible_effects[#eligible_effects + 1] = effect |
|
end |
|
end |
|
|
|
if #eligible_effects > 0 then |
|
local effect = table.weighted_rand(eligible_effects, "Weight", InteractionRand(1000000, "PickMoraleEffectSeed")) |
|
local target = effect_targets[effect.id] |
|
|
|
effect:Activate(target) |
|
|
|
local cooldown = Max(0, effect.GlobalCooldown) |
|
MoraleGlobalCooldown = Max(MoraleGlobalCooldown, g_Combat.current_turn + cooldown) |
|
if effect.Cooldown >= 0 then |
|
MoraleEffectCooldown[effect.id] = Max(MoraleEffectCooldown[effect.id], g_Combat.current_turn + effect.Cooldown) |
|
end |
|
end |
|
Msg("MoraleChange") |
|
ObjModified(self) |
|
ObjModified(Selection) |
|
end |
|
|
|
function CombatTeam:GetMoraleLevelAndEffectsText() |
|
local morale = self.morale |
|
local effects_text = "" |
|
local pchance = self:GetMoraleEffectChance("positive") |
|
local nchance = self:GetMoraleEffectChance("negative") |
|
if morale == 0 then |
|
effects_text = T{872793014384, " Positive effect chance: <percent(num1)><newline>", num1 = pchance} |
|
elseif morale > 0 then |
|
effects_text = T{891625701767, " <ap(num)> on start of turn<newline> Positive effect chance: <percent(num1)>", num = morale * const.Scale.AP, num1 = pchance} |
|
else |
|
effects_text = T{295409017319, " <ap(num)> on start of turn<newline> Negative effect chance: <percent(num1)>", num = morale * const.Scale.AP, num1 = nchance} |
|
end |
|
return T{834924000608, "Team Morale: <level><newline><effects><newline><newline>The morale level of each merc is influenced by Team Morale and various individual factors. Morale <em>modifies AP</em> and can trigger positive and negative effects based on the <em>highest Leadership</em> among the mercs.", level = MoraleLevelName[morale] or morale, effects = effects_text} |
|
end |
|
|
|
|
|
|
|
function MoraleModifierEvent(event, ...) |
|
if not g_Combat or (MoraleModifierCooldown[event] or 0 >= g_Combat.current_turn) then |
|
return |
|
end |
|
|
|
if event == "LieutenantDefeated" then |
|
for _, team in ipairs(g_Teams) do |
|
if team:IsPlayerControlled() and #team.units > 0 then |
|
local unit = select(1, ...) |
|
team:ChangeMorale(1, T{626055315388, "<villain_name> defeated",villain_name = unit:GetDisplayName()}) |
|
end |
|
end |
|
elseif event == "UnitDied" then |
|
local unit = select(1, ...) |
|
if unit.team:IsPlayerControlled() then |
|
for _, merc in ipairs(unit.team.units) do |
|
if merc ~= unit and unit.team and table.find(merc.Likes, unit.unitdatadef_id) then |
|
unit.team:ChangeMorale(-1, T{660013290366, "<merc_name> died",merc_name = unit:GetDisplayName()}) |
|
break |
|
end |
|
end |
|
end |
|
elseif event == "UnitDowned" or event == "BecomeDisliked" then |
|
local unit = select(1, ...) |
|
if unit.team and unit.team:IsPlayerControlled() then |
|
local negative_text |
|
if event == "UnitDowned" then |
|
negative_text = T{904916427918, "<merc_name> is Downed",merc_name = unit:GetDisplayName()} |
|
else |
|
local disliked_unit = select(2, ...) |
|
negative_text = T{471976678995, "<merc_name> dislikes <disliked_merc>",merc_name = unit:GetDisplayName(), disliked_merc = disliked_unit:GetDisplayName()} |
|
end |
|
unit.team:ChangeMorale(-1, negative_text) |
|
end |
|
elseif event == "SpectacularKill" or event == "BecomeLiked" then |
|
local unit = select(1, ...) |
|
if unit.team and unit.team:IsPlayerControlled() then |
|
local positive_text |
|
if event == "SpectacularKill" then |
|
positive_text = T(784410614255, "Good kill") |
|
else |
|
local liked_unit = select(2, ...) |
|
positive_text = T{205575546925, "<merc_name> likes <liked_merc>", merc_name = unit:GetDisplayName(), liked_merc = liked_unit:GetDisplayName()} |
|
end |
|
unit.team:ChangeMorale(1, positive_text) |
|
end |
|
elseif event == "UnitDamaged" then |
|
local unit = select(1, ...) |
|
local dmg = select(2, ...) |
|
if unit.team and unit.team:IsPlayerControlled() and dmg >= 30 then |
|
unit.team:ChangeMorale(-1, T{347215662696, "<merc_name> is hurt",merc_name = unit:GetDisplayName()}) |
|
end |
|
end |
|
end |
|
|
|
function TFormat.UnitDisplayAlias(ctx) |
|
local unit = ctx and ctx[1] |
|
if unit then |
|
local enemy = not unit.team.player_team and not unit.team.player_ally |
|
local ally = unit.team.player_ally |
|
local merc = IsMerc(unit) |
|
local count = #ctx |
|
|
|
if merc then |
|
return count > 1 and T{849089434818, "<num> Mercs", num = count} or unit.Nick or unit.Name or T(521796235967, "Merc") |
|
elseif ally then |
|
return count > 1 and T{237316267844, "<num> Allies", num = count} or unit.Nick or unit.Name or T(307626260917, "Ally") |
|
elseif enemy then |
|
return count > 1 and T{392526468031, "<num> Enemies", num = count} or unit.Nick or unit.Name or T(616781107824, "Enemy") |
|
end |
|
end |
|
end |
|
|
|
function UnitsDisplayAlias(units) |
|
local unit = IsValid(units) and units or (units and units[1]) |
|
if not unit then return T(146939580323, "Someone") end |
|
|
|
return TFormat.UnitDisplayAlias(units) |
|
end |
|
|
|
function ExecMoraleActions() |
|
local team = g_Teams[g_CurrentTeam] |
|
|
|
|
|
local panicked = table.ifilter(team.units, function(idx, unit) return unit:HasStatusEffect("Panicked") and not unit:IsIncapacitated() and unit.ActionPoints > 0 end) |
|
local controller |
|
local lastUnit |
|
if #panicked > 0 then |
|
local name = UnitsDisplayAlias(panicked) |
|
local notification = (team.player_team or team.player_ally) and "allyMoraleEffect" or "enemyMoraleEffect" |
|
local text = #panicked == 1 and T{561380303080, "<name> is panicked", name = name} or T{164773003084, "<name> are panicked", name = name} |
|
controller = CreateAIExecutionController({override_notification = notification, override_notification_text = text}) |
|
controller:Execute(panicked) |
|
|
|
for _, unit in ipairs(panicked) do |
|
unit:RemoveStatusEffect("FreeMove") |
|
unit.ActionPoints = 0 |
|
lastUnit = unit |
|
ObjModified(unit) |
|
end |
|
end |
|
|
|
local berserk = table.ifilter(team.units, function(idx, unit) return unit:HasStatusEffect("Berserk") and not unit:IsIncapacitated() and unit.ActionPoints > 0 end) |
|
if #berserk > 0 then |
|
local name = UnitsDisplayAlias(berserk) |
|
local notification = team.player_team and "allyMoraleEffect" or "enemyMoraleEffect" |
|
local text = #berserk == 1 and T{455420829781, "<name> is going berserk", name = name} or T{896715224643, "<name> are going berserk", name = name} |
|
if not controller then |
|
controller = CreateAIExecutionController({override_notification = notification, override_notification_text = text}) |
|
else |
|
controller.override_notification = notification |
|
controller.override_notification_text = text |
|
end |
|
controller:Execute(berserk) |
|
for _, unit in ipairs(berserk) do |
|
unit:RemoveStatusEffect("FreeMove") |
|
unit.ActionPoints = 0 |
|
lastUnit = unit |
|
ObjModified(unit) |
|
end |
|
end |
|
|
|
if controller then |
|
HideTacticalNotification("allyMoraleEffect") |
|
HideTacticalNotification("enemyMoraleEffect") |
|
controller.restore_camera_obj = lastUnit |
|
DoneObject(controller) |
|
ClearAllCombatBadges() |
|
end |
|
end |
|
|
|
function ScheduleMoraleActions() |
|
if not IsValidThread(MoraleActionThread) then |
|
MoraleActionThread = CreateGameTimeThread(ExecMoraleActions) |
|
end |
|
end |
|
|
|
function OnMsg.CombatStart() |
|
MoraleEffectCooldown = {} |
|
MoraleModifierCooldown = {} |
|
MoraleGlobalCooldown = 0 |
|
if IsValidThread(MoraleActionThread) then |
|
DeleteThread(MoraleActionThread) |
|
MoraleActionThread = false |
|
end |
|
end |
|
|
|
function OnMsg.EnterSector() |
|
if not g_Combat then |
|
for _, team in ipairs(g_Teams) do |
|
team.morale = 0 |
|
end |
|
end |
|
end |
|
|
|
function OnMsg.ConflictEnd(sector) |
|
if gv_CurrentSectorId == sector.Id and not g_Combat then |
|
for _, team in ipairs(g_Teams) do |
|
team.morale = 0 |
|
end |
|
end |
|
end |
|
|
|
MapVar("g_PanickedUnits", {}) |
|
MapVar("g_PanicThread", false) |
|
|
|
function PanicOutOfSequence(units) |
|
return CreateGameTimeThread(function(units) |
|
if not units then |
|
units = g_PanickedUnits |
|
g_PanickedUnits = {} |
|
end |
|
local name = UnitsDisplayAlias(units) |
|
|
|
for _, unit in ipairs(units) do |
|
unit.ActionPoints = unit:GetMaxActionPoints() |
|
end |
|
|
|
local notification = "enemyMoraleEffect" |
|
local text = #units == 1 and T{561380303080, "<name> is panicked", name = name} or T{164773003084, "<name> are panicked", name = name} |
|
local controller = CreateAIExecutionController({override_notification = notification, override_notification_text = text}) |
|
SetInGameInterfaceMode("IModeCombatMovement") |
|
if ActionCameraPlaying then |
|
RemoveActionCamera(true) |
|
WaitMsg("ActionCameraRemoved", 5000) |
|
end |
|
controller:Execute(units) |
|
|
|
for _, unit in ipairs(units) do |
|
unit:RemoveStatusEffect("FreeMove") |
|
if not unit.infinite_ap then |
|
unit.ActionPoints = 0 |
|
end |
|
ObjModified(unit) |
|
end |
|
HideTacticalNotification(notification) |
|
DoneObject(controller) |
|
AdjustCombatCamera("reset") |
|
end, units) |
|
end |
|
|
|
function OnMsg.StatusEffectAdded(unit, id) |
|
if id == "Panicked" and unit.team ~= g_Teams[g_CurrentTeam] then |
|
g_PanickedUnits[#g_PanickedUnits + 1] = unit |
|
g_PanicThread = g_PanicThread or PanicOutOfSequence() |
|
end |
|
end |