From 31d2be267d3688cde05d09909e6c9ebfb13688da Mon Sep 17 00:00:00 2001 From: VMSolidus Date: Thu, 21 May 2026 11:01:06 -0400 Subject: [PATCH] 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 | --- code/_onclick/hud/_defines.dm | 8 +++ code/datums/components/morale/moodlets.dm | 35 ++++++++-- .../components/morale/morale_component.dm | 64 +++++++++++++++++- .../service/bartending_skill_component.dm | 2 + html/changelogs/hellfirejag-moodlet-ui.yml | 4 ++ icons/mob/screen/morale_ui.dmi | Bin 0 -> 3495 bytes 6 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 html/changelogs/hellfirejag-moodlet-ui.yml create mode 100644 icons/mob/screen/morale_ui.dmi diff --git a/code/_onclick/hud/_defines.dm b/code/_onclick/hud/_defines.dm index 9ec19e5f057..60b5035a09c 100644 --- a/code/_onclick/hud/_defines.dm +++ b/code/_onclick/hud/_defines.dm @@ -96,6 +96,11 @@ #define ui_health_east_loc "EAST-1:28" // used to manipulate the position of the healths screen element, must be same as the one above #define ui_health_east_template "EAST-1:" // ditto #define ui_internal "EAST-1:28,CENTER+1:17" +/** + * Location that the morale component will place its HUD element, + * which is defined here for your sanity as a maintainer so that you know where it is in reference to everything else. + */ +#define UI_MORALE_LOCATION "EAST-1:28,CENTER+2:19" //Upper-middle right (alerts) #define ui_alert1 "EAST-1:28,CENTER+5:27" @@ -170,3 +175,6 @@ #define ui_ai_view_images "SOUTH:6,WEST+11:16" #define ui_ai_move_up "SOUTH:6,WEST+12:16" #define ui_ai_move_down "SOUTH:6,WEST+13:16" + +// HUD element related signals +#define COMSIG_GET_HUD_ELEMENTS "get_hud_elements" diff --git a/code/datums/components/morale/moodlets.dm b/code/datums/components/morale/moodlets.dm index 749470fdb28..6d04bcc19ff 100644 --- a/code/datums/components/morale/moodlets.dm +++ b/code/datums/components/morale/moodlets.dm @@ -15,10 +15,11 @@ ABSTRACT_TYPE(/datum/moodlet) */ VAR_PRIVATE/morale_modifier = 0.0 // Positive and negative floating points are allowed. - /** - * - */ - var/moodlet_descriptor = "It's a moodlet!" + /// The moodlet description to display to chat when someone with a morale component clicks on their morale icon. + var/moodlet_descriptor = SPAN_GOOD("It's a moodlet!") + + /// The moodlet description that gets sent to the chat of someone when they first obtain a moodlet. + var/initial_descriptor = SPAN_GOOD("You have gained a morale modifier from a moodlet!") /** * How long this moodlet will last if not refreshed. For Aurora's purposes, moodlets are targeted as having "Small effect, extreme duration". @@ -45,20 +46,24 @@ ABSTRACT_TYPE(/datum/moodlet) morale_component = WEAKREF(_morale_component) if (set_points) morale_modifier = set_points _morale_component.add_morale_points(morale_modifier) + send_initial_description(_morale_component.parent) /datum/moodlet/Destroy(force) if (force) // This will be forced when a Morale Component is deleted directly, as it QDEL_NULL_LIST's its own moodlets. + morale_component = null return ..() // Else if the moodlet is deleted directly rather than its parent. var/datum/component/morale/parent = morale_component.resolve() if (!parent || !parent.moodlets[src]) + morale_component = null return ..() // Clean the effects of this moodlet from the parent. parent.moodlets -= src parent.add_morale_points(-morale_modifier) + morale_component = null return ..() /datum/moodlet/proc/get_morale_modifier() @@ -81,3 +86,25 @@ ABSTRACT_TYPE(/datum/moodlet) /datum/moodlet/proc/refresh_moodlet() time_to_die = REALTIMEOFDAY + duration + +/** + * Returns a descriptor for the moodlet. + * + * Before you ask why this exists, + * know that some moodlets will override this with more complicated logic. + */ +/datum/moodlet/proc/get_moodlet_descriptor() + var/remaining_duration = time_to_die - REALTIMEOFDAY + var/seconds = round(remaining_duration % 600) + var/minutes = round((remaining_duration - seconds) / 600) + + return "\t [moodlet_descriptor]\n" \ + + "\t - worth [morale_modifier] points\n" \ + + "\t - remaining duration: [minutes] minutes, [round(seconds / 10)] seconds" + +/** + * Similarly to get_moodlet_descriptor(), some moodlets are going to want to override this with more complicated effects. + * By default, this just sends an initial descriptor. + */ +/datum/moodlet/proc/send_initial_description(datum/owner) + to_chat(owner, initial_descriptor) diff --git a/code/datums/components/morale/morale_component.dm b/code/datums/components/morale/morale_component.dm index 610837e1d97..ddf6eedf277 100644 --- a/code/datums/components/morale/morale_component.dm +++ b/code/datums/components/morale/morale_component.dm @@ -1,3 +1,30 @@ +/// 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. @@ -38,6 +65,15 @@ */ 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 @@ -64,6 +100,12 @@ 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) @@ -78,6 +120,7 @@ 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) @@ -88,6 +131,11 @@ 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) @@ -106,6 +154,10 @@ 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) @@ -121,10 +173,13 @@ 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)) + if (moodlet.time_to_die > current_time || QDELING(moodlet)) continue morale_points -= moodlet.get_morale_modifier() @@ -237,3 +292,10 @@ /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 diff --git a/code/datums/components/skills/service/bartending_skill_component.dm b/code/datums/components/skills/service/bartending_skill_component.dm index 2377499dcf3..044ee518317 100644 --- a/code/datums/components/skills/service/bartending_skill_component.dm +++ b/code/datums/components/skills/service/bartending_skill_component.dm @@ -1,6 +1,8 @@ /datum/component/skill/bartending /datum/moodlet/bartender_drink + moodlet_descriptor = SPAN_GOOD("Consumed a skillfully prepared drink.") + initial_descriptor = SPAN_GOOD("You have gained a morale modifier from consuming a skillfully prepared drink.") /datum/component/drink_moodlet_provider /// The morale boosting value of the moodlet this drink will provide. diff --git a/html/changelogs/hellfirejag-moodlet-ui.yml b/html/changelogs/hellfirejag-moodlet-ui.yml new file mode 100644 index 00000000000..1a3d5a260b8 --- /dev/null +++ b/html/changelogs/hellfirejag-moodlet-ui.yml @@ -0,0 +1,4 @@ +author: Hellfirejag +delete-after: True +changes: + - rscadd: "Added a UI indicator for Morale modifiers." diff --git a/icons/mob/screen/morale_ui.dmi b/icons/mob/screen/morale_ui.dmi new file mode 100644 index 0000000000000000000000000000000000000000..ce8b1910c9735bd1887c3221639903a7eb2d354b GIT binary patch literal 3495 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 XKh?Q-llaJ;%L}lvw6mx^>J|Sl!IF*V literal 0 HcmV?d00001