Files
Aurora.3/code/datums/components/morale/morale_component.dm
T
VMSolidus 31d2be267d Morale UI Indicators (#22468)
This PR extends the functionality of the Morale Component to also
provide a UI element. Which is dynamically controlled by the component.
Players can click on it to view their current morale modifiers, and what
percentage effect said modifiers are currently providing. Morale is not
actually new, the mechanic has been on the server for over a month now,
though the players had no way of knowing it was there unless they
closely follow along with the github history.

I have extensively tested this over the course of 6 hours of work, and
have attached video proof of said testing.


https://github.com/user-attachments/assets/c0d3ca3a-0a52-46e1-8e46-9d1e5ef46e4f

### Asset Licenses
The following assets that **have not** been created by myself are
included in this PR:

| Path | Original Author | License |
| icons/mob/screen/morale_ui.dmi |
https://github.com/BeeStation/NSV13/commit/b6b1e2bf2cc60455851317d8e82cca8716d9dac1
| CC-BY-SA-3.0 |
2026-05-21 15:01:06 +00:00

302 lines
14 KiB
Plaintext

/// Screen space movable object for the morale component.
/atom/movable/screen/morale
name = "morale"
icon = 'icons/mob/screen/morale_ui.dmi'
icon_state = "morale_hidden"
screen_loc = UI_MORALE_LOCATION
/atom/movable/screen/morale/Click(location, control, params)
if (!istype(usr))
return
var/datum/component/morale/morale_comp = usr.GetComponent(MORALE_COMPONENT)
if (!morale_comp)
qdel(src) // whoops the attached mob doesn't have a morale component.
return
if (!length(morale_comp.moodlets))
to_chat(usr, SPAN_NOTICE("You currently have no morale modifiers."))
return
// This section is Unlinted since we need to access a same-file PRIVATE var
// and for whatever ungodly reason strongdmm doesn't allow same-file to touch VAR_PRIVATE
UNLINT(to_chat(usr, SPAN_NOTICE("Your current morale bonus is [morale_comp.morale_ratio * 100]%")))
to_chat(usr, SPAN_NOTICE("You have the following morale modifiers: "))
for (var/datum/moodlet/moodlet as anything in morale_comp.moodlets)
to_chat(usr, moodlet.get_moodlet_descriptor())
/**
* Having a Morale Component allows a character to receive and benefit from Moodlets, providing a variety of buffs(or debuffs) depending on the total morale points.
* This component acts as both proof a mob can be affected by morale, as well as a method of tracking the effects of morale on each system.
*/
/datum/component/morale
/**
* The set of all moodlets associated with this Morale Component. These are also tightly controlled in their initialization, but are not private.
* That doesn't mean you need to be setting them anywhere other than in moodlets.dm.
* load_moodlet() is your best bet for "Add or Get" a moodlet, and is 100% of the time what you want to use if you're trying to make sure someone has a moodlet.
*/
var/list/datum/moodlet/moodlets = list()
/**
* The current sum total of morale_points. This var is intentionally private because it is self-managed by the component, and should never be set directly.
* If you need the contents of this var outside of the component, you MUST use get_morale_points().
*/
VAR_PRIVATE/morale_points = 0.0 // Positive and negative floating points are allowed.
/**
* The current "Morale Ratio" calculated in advance as the Hyperbolic Tangent of morale_points. This var is self-managed by the component and should never be set directly.
* This gets updated whenever morale_points are changed, and is used by the Morale Component to handle fast calculations of its various effects.
* If you need the contents of this var outside of the component, you MUST use get_morale_ratio().
*
* morale_ratio is NEVER to be set by anything outside of the component.
*/
VAR_PRIVATE/morale_ratio = 0.0 // Positive and negative floating points are allowed.
/**
* The "B" constant in the equation for y = Atanh(Bx + C).
* This constant is not arbitrary, it was carefully selected such that the equation will give "75% of its effect" at 50 morale points, and "96% of its effect" at 100 morale.
* This allows for there to be an effect of diminishing returns for chasing ever increasingly more morale points, while front-loading the bulk of the effects at a specific amount of moodlets.
* Since the effects of morale are a "Logistic Curve", "100% of the morale effect" is only ever obtained at +INFINITY.
* This also goes for the opposite direction, morale penalties max out only at -INFINITY points, but get to "75% of the penalty effect" at -50 points.
*
* The actual "Effects" of morale are to be per-signal, and are defined by the A value in y = Atanh(Bx + C)
* This is private for a reason, if you need to change it, do so by using set_beta_value(), which will also make the component recalculate its morale ratio.
*/
VAR_PRIVATE/beta_value = 0.0195
/**
* UI element stored for the morale component,
* which is attached to the screen of a client controlling a character with this component.
* Its lifecycle is strictly controlled by this component, do not under any circumstances touch it from outside this file.
*
* You have been warned.
*/
VAR_PRIVATE/atom/movable/screen/morale/morale_ui
// By default, all of these values are roughly equivalent to "up to half" a skill rank.
/// How much this morale component contributes to signal based unarmed values.
var/unarmed_chance_contribution = 2.5
/// How many effective ranks of unarmed combat skill this morale component can contribute
var/unarmed_rank_contribution = 0.5
/**
* The maximum possible panic chance from negative morale point sums.
* Since negative moodlets are unique to psychic damage, the effects of psychically induced panic are uniquely stronger than simply being unskilled.
*/
var/panic_chance_ceiling = 10
/// The maximum possible positive or negative contribution to surgery success chances from morale modifiers.
var/surgery_success_contribution = 10
/datum/component/morale/proc/get_morale_ratio()
return morale_ratio
/datum/component/morale/proc/get_morale_points()
return morale_points
/datum/component/morale/proc/add_morale_points(input)
morale_points += input
morale_ratio = ftanh(beta_value * morale_points)
// I get to do this freakishly compact state setting because I can
// logically prove via VAR_PRIVATE that this is only ever set via a hyperbolic tangent
// And that because a hyperbolic tangent will only ever return a value between -1 and 1,
// the range of this equation becomes the set of integers between 1 and 9 inclusive.
morale_ui.icon_state = ((morale_ratio > -0.0001 && morale_ratio < 0.0001) ? "morale_hidden" : "morale" + "[round(morale_ratio * 4) + 5]")
/datum/component/morale/proc/set_beta_value(input)
beta_value = input
morale_ratio = ftanh(beta_value * morale_points)
/**
* Your one-stop-shop for making moodlets. This proc returns the pre-existing moodlet of a given type.
* If it doesn't already exist, then one will be created.
*/
/datum/component/morale/proc/load_moodlet(datum/moodlet/moodlet_type, set_points)
RETURN_TYPE(moodlet_type)
var/datum/moodlet/loaded_moodlet = locate(moodlet_type) in moodlets
if (!loaded_moodlet)
loaded_moodlet = new moodlet_type(src, set_points)
moodlets.Add(loaded_moodlet)
START_PROCESSING(SSprocessing, src)
return loaded_moodlet
if (set_points) loaded_moodlet.set_moodlet(set_points)
return loaded_moodlet
/datum/component/morale/Initialize()
. = ..()
if (!parent)
return
// Generate the morale UI in advance.
morale_ui = new /atom/movable/screen/morale()
// Separate from the general morale interactions, this one is special for generating the morale HUD elements.
RegisterSignal(parent, COMSIG_MOB_UPDATE_VISION, PROC_REF(get_morale_hud), override = TRUE)
// Behold my wall of RegisterSignal()
RegisterSignal(parent, COMSIG_APPLY_HIT_EFFECT, PROC_REF(modify_hit_effect), override = TRUE)
RegisterSignal(parent, COMSIG_BEFORE_GUN_FIRE, PROC_REF(handle_accuracy), override = TRUE)
RegisterSignal(parent, COMSIG_GUN_TOGGLE_FIRING_MODE, PROC_REF(safety_fumble), override = TRUE)
RegisterSignal(parent, COMSIG_UNARMED_HARM_ATTACKER, PROC_REF(handle_harm_attack), override = TRUE)
RegisterSignal(parent, COMSIG_UNARMED_HARM_DEFENDER, PROC_REF(handle_harm_defend), override = TRUE)
RegisterSignal(parent, COMSIG_UNARMED_DISARM_ATTACKER, PROC_REF(handle_disarm_attack), override = TRUE)
RegisterSignal(parent, COMSIG_UNARMED_DISARM_DEFENDER, PROC_REF(handle_disarm_defend), override = TRUE)
RegisterSignal(parent, COMSIG_MECH_MOVE_WASD, PROC_REF(handle_user_move), override = TRUE)
RegisterSignal(parent, COMSIG_MECH_MOVE_STRAFE, PROC_REF(handle_user_strafe), override = TRUE)
RegisterSignal(parent, COMSIG_MECH_TOGGLE_POWER, PROC_REF(handle_mech_toggle_power), override = TRUE)
RegisterSignal(parent, COMSIG_GET_SURGERY_SUCCESS_MODIFIERS, PROC_REF(handle_surgery_modifiers), override = TRUE)
/datum/component/morale/Destroy()
QDEL_LIST_FORCE(moodlets)
if (!parent)
return ..()
// Cleanup morale HUD elements.
UnregisterSignal(parent, COMSIG_MOB_UPDATE_VISION)
QDEL_NULL(morale_ui)
// Behold my wall of UnregisterSignal()
UnregisterSignal(parent, COMSIG_APPLY_HIT_EFFECT)
UnregisterSignal(parent, COMSIG_BEFORE_GUN_FIRE)
UnregisterSignal(parent, COMSIG_GUN_TOGGLE_FIRING_MODE)
UnregisterSignal(parent, COMSIG_UNARMED_HARM_ATTACKER)
UnregisterSignal(parent, COMSIG_UNARMED_HARM_DEFENDER)
UnregisterSignal(parent, COMSIG_UNARMED_DISARM_ATTACKER)
UnregisterSignal(parent, COMSIG_UNARMED_DISARM_DEFENDER)
UnregisterSignal(parent, COMSIG_MECH_MOVE_WASD)
UnregisterSignal(parent, COMSIG_MECH_MOVE_STRAFE)
UnregisterSignal(parent, COMSIG_MECH_TOGGLE_POWER)
UnregisterSignal(parent, COMSIG_GET_SURGERY_SUCCESS_MODIFIERS)
return ..()
/datum/component/morale/process(seconds_per_tick)
if (!length(moodlets))
return PROCESS_KILL
var/current_time = REALTIMEOFDAY
var/list_trimmed = FALSE
for (var/datum/moodlet/moodlet as anything in moodlets)
if (moodlet.time_to_die > current_time || QDELING(moodlet))
continue
morale_points -= moodlet.get_morale_modifier()
qdel(moodlet, TRUE)
moodlets.Remove(moodlet)
list_trimmed = TRUE
if (!list_trimmed)
return
morale_ratio = ftanh(morale_points)
/*
AND NOW THE GIANT WALL OF SIGNAL HANDLERS
LOOK UPON MY SIGNALS HANDLERS YE MIGHTY AND DESPAIR
Remember how I said low morale will exclusively come from psychic damage?
The Night has shed a tear to tell you of fear and of sorrow and pain that you shall never outgrow.
Oh yea, the negative morale effects will be !!!FUN!!! in exchange for being extremely rare
Otherwise, morale effects are generally equivalent to "up to half a skill rank"
for a large variety of numerical effects related to skills.
*/
/datum/component/morale/proc/modify_hit_effect(owner, mob/living/target, obj/item/weapon, power, hit_zone)
SIGNAL_HANDLER
*power = *power * (1 + (0.05 * morale_ratio))
/datum/component/morale/proc/handle_accuracy(mob/shooter, accuracy_decrease, dispersion_increase)
SIGNAL_HANDLER
*accuracy_decrease = *accuracy_decrease - morale_ratio
*dispersion_increase = *dispersion_increase - 10 * morale_ratio
/datum/component/morale/proc/safety_fumble(mob/shooter, obj/item/gun/shoota, cancelled)
SIGNAL_HANDLER
// Up to 50% chance to fumble a safety when in a psionically-induced panic.
if (cancelled || morale_points >= 0 || !prob(floor(5 * panic_chance_ceiling * -morale_ratio)))
return
*cancelled = TRUE
shooter.visible_message(
SPAN_DANGER("\The [shooter] fumbles with \the [shoota]'s safety in a blind panic!"),
SPAN_DANGER("You fumble with \the [shoota]'s safety in a blind panic!"))
/datum/component/morale/proc/handle_harm_attack(mob/attacker, mob/defender, attacker_skill_level, miss_chance, rand_damage, block_chance)
SIGNAL_HANDLER
*attacker_skill_level = *attacker_skill_level + (unarmed_rank_contribution * morale_ratio)
*miss_chance = *miss_chance - unarmed_chance_contribution * morale_ratio
*block_chance = *block_chance - unarmed_chance_contribution * morale_ratio
/datum/component/morale/proc/handle_harm_defend(mob/defender, mob/attacker, defender_skill_level, miss_chance, rand_damage, block_chance)
SIGNAL_HANDLER
*defender_skill_level = *defender_skill_level - (unarmed_rank_contribution * morale_ratio)
*miss_chance = *miss_chance + unarmed_chance_contribution * morale_ratio
*block_chance = *block_chance + unarmed_chance_contribution * morale_ratio
/datum/component/morale/proc/handle_disarm_attack(mob/attacker, mob/defender, attacker_skill_level, disarm_cost, push_chance, disarm_chance)
SIGNAL_HANDLER
*attacker_skill_level = *attacker_skill_level + (unarmed_rank_contribution * morale_ratio)
*push_chance = *push_chance - unarmed_chance_contribution * morale_ratio
*disarm_chance = *disarm_chance - unarmed_chance_contribution * morale_ratio
/datum/component/morale/proc/handle_disarm_defend(mob/defender, mob/attacker, defender_skill_level, disarm_cost, push_chance, disarm_chance)
SIGNAL_HANDLER
*defender_skill_level = *defender_skill_level + (unarmed_rank_contribution * morale_ratio)
*push_chance = *push_chance + unarmed_chance_contribution * morale_ratio
*disarm_chance = *disarm_chance + unarmed_chance_contribution * morale_ratio
/datum/component/morale/proc/handle_user_move(mob/living/user, direction, delay_modifier)
SIGNAL_HANDLER
if (parent != user)
return
if (morale_points < 0 && prob(floor(panic_chance_ceiling * -morale_ratio)))
user.visible_message(
SPAN_DANGER("\The [user] fumbles with their mech's controls in a blind panic!"),
SPAN_DANGER("You fumble with your mech's controls in a blind panic!"))
*direction = pick(GLOB.cardinals)
if (direction == NORTH)
return
*delay_modifier = *delay_modifier - 0.5 * morale_ratio
/datum/component/morale/proc/handle_user_strafe(mob/living/user, direction, delay_modifier)
SIGNAL_HANDLER
if (parent != user)
return
if (morale_points < 0 && prob(floor(panic_chance_ceiling * -morale_ratio)))
user.visible_message(
SPAN_DANGER("\The [user] fumbles with their mech's controls in a blind panic!"),
SPAN_DANGER("You fumble with your mech's controls in a blind panic!"))
*direction = angle2dir(dir2angle(direction) + 180)
*delay_modifier = *delay_modifier - 0.5 * morale_ratio
/datum/component/morale/proc/handle_mech_toggle_power(mob/user, cancelled, delay)
SIGNAL_HANDLER
if (parent != user || cancelled || morale_points >= 0)
return
if (prob(floor(panic_chance_ceiling * -morale_ratio)))
to_chat(user, SPAN_DANGER("The pressure on your mind overwhelms you completely. You can't even think to find the power switch..."))
*cancelled = TRUE
return
*delay = *delay - (5 * morale_ratio) SECONDS
to_chat(user, SPAN_WARNING("The pressure on your mind causes you to stumble in searching for the power switch..."))
/datum/component/morale/proc/handle_surgery_modifiers(mob/living/user, success_rate)
SIGNAL_HANDLER
*success_rate = *success_rate + surgery_success_contribution * morale_ratio
/datum/component/morale/proc/get_morale_hud(mob/living/carbon/human/user)
SIGNAL_HANDLER
if (QDELING(src))
return // draw nothing.
user.client?.screen |= morale_ui