Files
John WillardandGitHub 774c07cd9b Basic mob stat panel gutting (#95543)
## About The Pull Request

Changes Guardian HUD to use action buttons instead of HUD elements,
which means their buttons
1. Can now be moved
2. Can be binded

Other Holoparasite HUD changes:

1- Moves some special buttons like explosive's explosion ability to be
where the "Toggle Mode" button typically is, to be consistent with some
other modes (assassin, gas) that also works like their unique ability.
2- Gives non-dextrous holoparasites the ability to move floors, since
they float.
3- Fixes dextrous holoparasites having 2 health HUDs
4- Moves "Check Guardian Type" verb to a new action button meant to
mimick the Antag UI Info button.

Gives Soulscythes a Combat mode indicator

Locks Revenant's combat mode indicator (cause it does nothing)

Adds another hoverable HUD element, basic mobs can now hover over their
healthdoll to see their Health %

<img width="85" height="123" alt="image"
src="https://github.com/user-attachments/assets/ad660ba4-296c-4699-9ec4-03a6e7b97880"
/>
<img width="95" height="101" alt="image"
src="https://github.com/user-attachments/assets/12d4d9d3-cf2c-4c52-81aa-17ce538b9d48"
/>


Adds a Unit Test to ensure all basic mobs have a HUD that shows some
form of health & combat mode (unless unnecessary like Revenants), to not
lose any information from basic mob conversion now that they lose the
stat panel.

## Why It's Good For The Game

This is yet another supplement of
https://github.com/tgstation/tgstation/pull/95383 - Removing stat panel
entries for all basic mobs is a great move towards lessening the
reliance of mobs on the stat panel.

This is also just good QoL for Holoparasites, especially since their
stat panel had 2 entries for "Master HP" and your "Own" HP, despite
Holoparasites not even having their own HP at all.

## Changelog

🆑
qol: Holoparasites' abilities are now action buttons, so you can rebind
or move them.
qol: Holoparasites now have floor change buttons.
fix: Holoparasites and Soulscythes now have a combat mode indicator.
fix: Dextrous holoparasites now have their inventory slot again while
non-dextrous had it taken away (not that they could use it).
/🆑
2026-04-13 17:05:57 -06:00

353 lines
14 KiB
Plaintext

/**
* A mob which acts as a guardian angel to another mob, sharing health but protecting them using special powers.
* Usually either obtained in magical form by a wizard, or technological form by a traitor. Sometimes found by miners.
*/
/mob/living/basic/guardian
name = "Guardian Spirit"
real_name = "Guardian Spirit"
desc = "A mysterious being that stands by its charge, ever vigilant."
icon = 'icons/mob/nonhuman-player/guardian.dmi'
icon_state = "magicbase"
icon_living = "magicbase"
icon_dead = "magicbase"
gender = NEUTER
basic_mob_flags = DEL_ON_DEATH
mob_biotypes = MOB_SPECIAL
sentience_type = SENTIENCE_HUMANOID
hud_type = /datum/hud/guardian
speed = 0
status_flags = CANPUSH
maxHealth = INFINITY // The spirit itself is invincible and passes damage to its host
health = INFINITY
damage_coeff = list(BRUTE = 1, BURN = 1, TOX = 1, STAMINA = 0, OXY = 1)
unsuitable_atmos_damage = 0
unsuitable_cold_damage = 0
unsuitable_heat_damage = 0
speak_emote = list("hisses")
bubble_icon = "guardian"
response_help_continuous = "passes through"
response_help_simple = "pass through"
response_disarm_continuous = "flails at"
response_disarm_simple = "flail at"
response_harm_continuous = "punches"
response_harm_simple = "punch"
attack_sound = 'sound/items/weapons/punch1.ogg'
attack_verb_continuous = "punches"
attack_verb_simple = "punch"
obj_damage = 40
melee_damage_lower = 15
melee_damage_upper = 15
melee_attack_cooldown = CLICK_CD_MELEE
light_system = OVERLAY_LIGHT
light_range = 3
light_on = FALSE
/// The summoner of the guardian, we share health with them and can't move too far away (usually)
var/mob/living/summoner
/// How far from the summoner the guardian can be.
var/range = 10
/// The guardian's colour, used for their sprite, chat, and some effects.
var/guardian_colour
/// Coloured overlay we apply
var/mutable_appearance/overlay
/// Which toggle button the guardian has. Won't get one if it's null.
var/toggle_button_type = null
/// Name used by the guardian creator.
var/creator_name = "Error"
/// Description used by the guardian creator.
var/creator_desc = "This shouldn't be here! Report it on GitHub!"
/// Icon used by the guardian creator.
var/creator_icon = "fuck"
/// What type of guardian are we?
var/guardian_type = null
/// How are we themed?
var/datum/guardian_fluff/theme
/// A string explaining to the guardian what they can do.
var/playstyle_string = span_boldholoparasite("You are a Guardian without any type. You shouldn't exist and are an affront to god!")
/// Are we forced to not be able to manifest/recall?
var/locked = FALSE
/// Cooldown between manifests/recalls.
COOLDOWN_DECLARE(manifest_cooldown)
/// Cooldown between the summoner resetting the guardian's client.
COOLDOWN_DECLARE(resetting_cooldown)
/// List of actions we give to our summoner.
var/static/list/control_actions = list(
/datum/action/cooldown/mob_cooldown/guardian_comms,
/datum/action/cooldown/mob_cooldown/recall_guardian,
/datum/action/cooldown/mob_cooldown/replace_guardian,
)
/// List of actions we give to ourselves.
var/static/list/self_actions = list(
/datum/action/cooldown/guardian/check_type,
/datum/action/cooldown/guardian/toggle_light,
/datum/action/cooldown/guardian/communicate,
/datum/action/cooldown/guardian/manifest,
/datum/action/cooldown/guardian/recall,
)
/mob/living/basic/guardian/Initialize(mapload, datum/guardian_fluff/theme)
. = ..()
GLOB.parasites += src
src.theme = theme
theme?.apply(src)
AddElement(/datum/element/death_drops, /obj/effect/temp_visual/guardian/phase/out)
AddElement(/datum/element/simple_flying)
// life link
update_appearance(UPDATE_ICON)
manifest_effects()
create_actions()
/mob/living/basic/guardian/Destroy()
GLOB.parasites -= src
if (is_deployed())
recall_effects()
cut_summoner(different_person = TRUE)
return ..()
///Creates the guardian's default action buttons and sets them to go in their proper location.
///Subtypes overwrite this for special ability types and whatnot.
/mob/living/basic/guardian/proc/create_actions()
for (var/action_type in self_actions + toggle_button_type)
if(isnull(action_type)) //no toggle button type
continue
if (locate(action_type) in actions)
continue
var/datum/action/new_action = new action_type(src)
new_action.Grant(src)
update_action_buttons()
/mob/living/basic/guardian/update_overlays()
. = ..()
. += overlay
/mob/living/basic/guardian/Login() //if we have a mind, set its name to ours when it logs in
. = ..()
if (!. || isnull(client))
return FALSE
if (isnull(summoner))
to_chat(src, span_boldholoparasite("For some reason, somehow, you have no summoner. Please report this bug immediately."))
stack_trace("Guardian created with client but no summoner.")
else
to_chat(src, span_holoparasite("You are a <b>[theme.name]</b>, bound to serve [summoner.real_name]."))
to_chat(src, span_holoparasite("You are capable of manifesting or recalling to your master with the buttons on your HUD. You will also find a button to communicate with [summoner.p_them()] privately there."))
to_chat(src, span_holoparasite("While personally invincible, you will die if [summoner.real_name] does, and any damage dealt to you will have a portion passed on to [summoner.p_them()] as you feed upon [summoner.p_them()] to sustain yourself."))
to_chat(src, playstyle_string)
if (!isnull(guardian_colour))
return // Already set up so we don't need to do it again
locked = TRUE
guardian_rename()
guardian_recolour()
locked = FALSE
/mob/living/basic/guardian/mind_initialize()
. = ..()
if (isnull(summoner))
to_chat(src, span_boldholoparasite("For some reason, somehow, you have no summoner. Please report this bug immediately."))
return
mind.enslave_mind_to_creator(summoner) // Once our mind is created, we become enslaved to our summoner. cant be done in the first run of set_summoner, because by then we dont have a mind yet.
/// Pick a new colour for our guardian
/mob/living/basic/guardian/proc/guardian_recolour()
if (isnull(client))
return
var/chosen_guardian_colour = tgui_color_picker(src, "What would you like your colour to be?", "Choose Your Colour", COLOR_WHITE)
if (isnull(chosen_guardian_colour)) //redo proc until we get a color
to_chat(src, span_warning("Invalid colour, please try again."))
return guardian_recolour()
set_guardian_colour(chosen_guardian_colour)
/// Apply a new colour to our guardian
/mob/living/basic/guardian/proc/set_guardian_colour(colour)
guardian_colour = colour
set_light_color(guardian_colour)
overlay?.color = guardian_colour
update_appearance(UPDATE_ICON)
/mob/living/basic/guardian/proc/guardian_rename()
if (isnull(client))
return
var/new_name = sanitize_name(reject_bad_text(tgui_input_text(src, "What would you like your name to be?", "Choose Your Name", generate_random_name(), MAX_NAME_LEN)))
if (!new_name) //redo proc until we get a good name
to_chat(src, span_warning("Invalid name, please try again."))
return guardian_rename()
to_chat(src, span_notice("Your new name [span_name(new_name)] anchors itself in your mind."))
fully_replace_character_name(null, new_name)
/// Picks a random name as a suggestion
/mob/living/basic/guardian/proc/generate_random_name()
var/list/surname_options = list("Guardian") // Fallback in case you define a guardian with no theme
switch(theme?.fluff_type)
if (GUARDIAN_MAGIC)
surname_options = GLOB.guardian_fantasy_surnames
if (GUARDIAN_TECH)
surname_options = GLOB.guardian_tech_surnames
return "[pick(GLOB.guardian_first_names)] [pick(surname_options)]"
/mob/living/basic/guardian/melee_attack(atom/target, list/modifiers, ignore_cooldown)
if (!is_deployed())
balloon_alert(src, "not tangible!")
return FALSE
return ..()
/mob/living/basic/guardian/death(gibbed)
if (!QDELETED(summoner))
to_chat(summoner, span_bolddanger("Your [name] died somehow!"))
summoner.dust()
return ..()
/mob/living/basic/guardian/ex_act(severity, target)
switch(severity)
if (EXPLODE_DEVASTATE)
investigate_log("has been gibbed by an explosion.", INVESTIGATE_DEATHS)
gib()
return TRUE
if (EXPLODE_HEAVY)
adjust_brute_loss(60)
if (EXPLODE_LIGHT)
adjust_brute_loss(30)
return TRUE
/mob/living/basic/guardian/gib()
death(TRUE)
/mob/living/basic/guardian/dust(just_ash, drop_items, give_moodlet, force)
death(TRUE)
/// Link up with a summoner mob.
/mob/living/basic/guardian/proc/set_summoner(mob/living/to_who, different_person = FALSE)
if (QDELETED(src))
return // Just in case
if (QDELETED(to_who))
ghostize(FALSE)
qdel(src) // No life of free invulnerability for you.
return
cut_summoner(different_person)
AddComponent(/datum/component/life_link, to_who, CALLBACK(src, PROC_REF(on_harm)), CALLBACK(src, PROC_REF(on_summoner_death)))
summoner = to_who
for (var/action_type in control_actions)
if (locate(action_type) in summoner.actions)
continue
var/datum/action/new_action = new action_type(summoner)
new_action.Grant(summoner)
if (different_person)
if (mind)
mind.enslave_mind_to_creator(to_who)
else //mindless guardian, manually give them factions
add_faction(summoner.get_faction())
add_ally(summoner.allies)
summoner.add_ally(src)
remove_all_languages(LANGUAGE_MASTER)
copy_languages(to_who, LANGUAGE_MASTER) // make sure holoparasites speak same language as master
RegisterSignal(to_who, COMSIG_QDELETING, PROC_REF(on_summoner_deletion))
RegisterSignal(to_who, COMSIG_LIVING_ON_WABBAJACKED, PROC_REF(on_summoner_wabbajacked))
RegisterSignal(to_who, COMSIG_LIVING_SHAPESHIFTED, PROC_REF(on_summoner_shapeshifted))
RegisterSignal(to_who, COMSIG_LIVING_UNSHAPESHIFTED, PROC_REF(on_summoner_unshapeshifted))
recall(forced = TRUE)
leash_to(src, summoner)
if (to_who.stat == DEAD)
on_summoner_death(src, to_who)
summoner.updatehealth()
/// Remove all references to our summoner
/mob/living/basic/guardian/proc/cut_summoner(different_person = FALSE)
if (isnull(summoner))
return
if (is_deployed())
recall_effects()
var/summoner_turf = get_turf(summoner)
if (!isnull(summoner_turf))
forceMove(summoner_turf)
unleash()
UnregisterSignal(summoner, list(COMSIG_QDELETING, COMSIG_LIVING_ON_WABBAJACKED, COMSIG_LIVING_SHAPESHIFTED, COMSIG_LIVING_UNSHAPESHIFTED))
if (different_person)
summoner.remove_ally(src)
remove_faction(summoner.get_faction())
mind?.remove_all_antag_datums()
if (!length(summoner.get_all_linked_holoparasites() - src))
for (var/action_type in control_actions)
var/datum/action/remove_action = locate(action_type) in summoner.actions
if (isnull(remove_action))
continue
remove_action.Remove(summoner)
summoner = null
/// Connects these two mobs by a leash
/mob/living/basic/guardian/proc/leash_to(atom/movable/leashed, atom/movable/leashed_to)
leashed.AddComponent(\
/datum/component/leash,\
owner = leashed_to,\
distance = range,\
force_teleport_out_effect = /obj/effect/temp_visual/guardian/phase/out,\
force_teleport_in_effect = /obj/effect/temp_visual/guardian/phase,\
)
/// Removes the leash from this guardian
/mob/living/basic/guardian/proc/unleash()
qdel(GetComponent(/datum/component/leash))
/// Called when our owner dies. We fucked up, so now neither of us get to exist.
/mob/living/basic/guardian/proc/on_summoner_death(mob/living/source, mob/living/former_owner)
cut_summoner()
if (!isnull(former_owner.loc))
forceMove(former_owner.loc)
to_chat(src, span_danger("Your summoner has died!"))
visible_message(span_bolddanger("\The [src] dies along with its user!"))
former_owner.visible_message(span_bolddanger("[former_owner]'s body is completely consumed by the strain of sustaining [src]!"))
former_owner.dust(drop_items = TRUE)
/// Called when our health changes, inform our owner of why they are getting hurt (if they are)
/mob/living/basic/guardian/proc/on_harm(mob/living/source, mob/living/summoner, amount)
if (QDELETED(src) || QDELETED(summoner) || amount <= 2)
return
to_chat(summoner, span_bolddanger("[name] is under attack! You take damage!"))
summoner.visible_message(span_bolddanger("Blood sprays from [summoner] as [src] takes damage!"))
if(summoner.stat == UNCONSCIOUS || summoner.stat == HARD_CRIT)
to_chat(summoner, span_bolddanger("Your head pounds, you can't take the strain of sustaining [src] in this condition!"))
summoner.adjust_organ_loss(ORGAN_SLOT_BRAIN, amount * 0.5)
/// When our owner is deleted, we go too.
/mob/living/basic/guardian/proc/on_summoner_deletion(mob/living/source)
SIGNAL_HANDLER
cut_summoner()
to_chat(src, span_danger("Your summoner is gone, you feel yourself fading!"))
ghostize(FALSE)
qdel(src)
/// Signal proc for [COMSIG_LIVING_ON_WABBAJACKED], when our summoner is wabbajacked we should be alerted.
/mob/living/basic/guardian/proc/on_summoner_wabbajacked(mob/living/source, mob/living/new_mob)
SIGNAL_HANDLER
set_summoner(new_mob)
to_chat(src, span_holoparasite("Your summoner has changed form!"))
/// Signal proc for [COMSIG_LIVING_SHAPESHIFTED], when our summoner is shapeshifted we should change to the new mob
/mob/living/basic/guardian/proc/on_summoner_shapeshifted(mob/living/source, mob/living/new_shape)
SIGNAL_HANDLER
set_summoner(new_shape)
to_chat(src, span_holoparasite("Your summoner has shapeshifted into that of a [new_shape]!"))
/// Signal proc for [COMSIG_LIVING_UNSHAPESHIFTED], when our summoner unshapeshifts go back to that mob
/mob/living/basic/guardian/proc/on_summoner_unshapeshifted(mob/living/source, mob/living/old_summoner)
SIGNAL_HANDLER
set_summoner(old_summoner)
to_chat(src, span_holoparasite("Your summoner has shapeshifted back into their normal form!"))
/mob/living/basic/guardian/wabbajack(what_to_randomize, change_flags = WABBAJACK)
visible_message(span_warning("[src] resists the polymorph!")) // Ha, no
/mob/living/basic/guardian/can_suicide()
return FALSE // You gotta persuade your boss to end it instead, sorry
/// Returns true if you are out and about
/mob/living/basic/guardian/proc/is_deployed()
return isnull(summoner) || loc != summoner