MapVar("MoraleEffectCooldown", {}) MapVar("MoraleModifierCooldown", {}) MapVar("MoraleGlobalCooldown", 0) MapVar("MoraleActionThread", false) local modifier_cooldowns = { -- [id] = number of turns -- 0 = cannot happen during current turn -- 1 = cannot happen during current and next turns } MoraleLevelName = { [-3] = T(738790082445, --[[MoraleLevelName: Abysmal]] "Abysmal"), [-2] = T(628671961455, --[[MoraleLevelName: Very Low]] "Very Low"), [-1] = T(966377690475, --[[MoraleLevelName: Low]] "Low"), [0] = T(274341293889, --[[MoraleLevelName: Stable]] "Stable"), [1] = T(899829984127, --[[MoraleLevelName: High]] "High"), [2] = T(981991901247, --[[MoraleLevelName: Very High]] "Very High"), [3] = T(447600477466, --[[MoraleLevelName: Exceptional]] "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 --get highest/lowest personal morale in the units table 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 --remove from the units table all units with different than the highest/lowest personal morale 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, "Morale is improving and is now ()", 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, "Morale is dropping and is now ()", 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 --return end local leadership = 0 for _, unit in ipairs(self.units) do leadership = Max(leadership, unit.leadership) end -- find eligible effects, trigger one 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) -- limit 1 effect/turn 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: ", num1 = pchance} elseif morale > 0 then effects_text = T{891625701767, " on start of turn Positive effect chance: ", num = morale * const.Scale.AP, num1 = pchance} else effects_text = T{295409017319, " on start of turn Negative effect chance: ", num = morale * const.Scale.AP, num1 = nchance} end return T{834924000608, "Team Morale: The morale level of each merc is influenced by Team Morale and various individual factors. Morale modifies AP and can trigger positive and negative effects based on the highest Leadership among the mercs.", level = MoraleLevelName[morale] or morale, effects = effects_text} end -- called when a potentially morale-altering event happens 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, " 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, " 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, " is Downed",merc_name = unit:GetDisplayName()} else local disliked_unit = select(2, ...) negative_text = T{471976678995, " dislikes ",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, " likes ", 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, " 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, " Mercs", num = count} or unit.Nick or unit.Name or T(521796235967, "Merc") elseif ally then return count > 1 and T{237316267844, " Allies", num = count} or unit.Nick or unit.Name or T(307626260917, "Ally") elseif enemy then return count > 1 and T{392526468031, " 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] -- activate morale-related AI-control (panic, berserk) on eligible units, if any 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, " is panicked", name = name} or T{164773003084, " 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, " is going berserk", name = name} or T{896715224643, " 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 --with this set, controller will restore camera angle on done and focus this obj DoneObject(controller) ClearAllCombatBadges() end end function ScheduleMoraleActions() -- schedule units from the current team who panicked or went berserk to take their action 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) -- execution controller will wait for the current combat action to end if not units then units = g_PanickedUnits g_PanickedUnits = {} end local name = UnitsDisplayAlias(units) -- make sure the units have enough AP to act for _, unit in ipairs(units) do unit.ActionPoints = unit:GetMaxActionPoints() end local notification = --[[self.team.player_team and "allyMoraleEffect" or ]]"enemyMoraleEffect" local text = #units == 1 and T{561380303080, " is panicked", name = name} or T{164773003084, " 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