Files
Bubberstation/code/datums/chatmessage.dm
MrMelbert 7c0973ccf4 Refactors names and voices / potted plants no longer conceal voice (just appearance) / honorifics show on examine (#92781)
## About The Pull Request

Closes #92778
Closes #86829

<img width="347" height="39" alt="image"
src="https://github.com/user-attachments/assets/c50bd1ff-8c00-47a7-a31a-617fae2adc5b"
/>

1. Splits `TRAIT_UNKNOWN` into `TRAIT_UNKNOWN_APPEARANCE` and
`TRAIT_UNKNOWN_VOICE`

2. Renames some stuff like `getvoice` and `getspecialvoice`

3. Gets rid some crummy signals around `get_visible_name` and
`get_voice`

4. Heads now apply the disfigured trait when relevant (rather than
snowflake checking for damage amount)

5. Ling voice refactored into using special voice (it was only used by a
viro symptom anyways; I don't anticipate this overlap being problematic)

6. Mask voice changer refactored into a trait

## Why It's Good For The Game

Potted plants shouldn't have magical voice concealing powers -
especially not over radio, but not over in person either. It's a damn
plant

So I addressed this by refactoring our face and voice system. Overall
things should be a lot cleaner and easier to use.

## Changelog

🆑 Melbert
refactor: Refactored a lot of code relating to human face and voice, ie,
what shows up in examine and in say. Report anything odd when examining
people, with ID cards, when talking over radio, or when disguised
refcator: Refactored how you get disfigured when your head's super
damaged
refactor: Refactored ling mimic voice and traitor voice changer
del: Potted plants no longer hide voice. They still hide appearance,
though
qol: Honorifics now show in examine / in world, rather than only when
speaking.
/🆑
2025-09-12 17:11:06 +02:00

357 lines
15 KiB
Plaintext

/// How long the chat message's spawn-in animation will occur for
#define CHAT_MESSAGE_SPAWN_TIME (0.2 SECONDS)
/// How long the chat message will exist prior to any exponential decay
#define CHAT_MESSAGE_LIFESPAN (5 SECONDS)
/// How long the chat message's end of life fading animation will occur for
#define CHAT_MESSAGE_EOL_FADE (0.7 SECONDS)
/// Grace period for fade before we actually delete the chat message
#define CHAT_MESSAGE_GRACE_PERIOD (0.2 SECONDS)
/// Factor of how much the message index (number of messages) will account to exponential decay
#define CHAT_MESSAGE_EXP_DECAY 0.7
/// Factor of how much height will account to exponential decay
#define CHAT_MESSAGE_HEIGHT_DECAY 0.9
/// Approximate height in pixels of an 'average' line, used for height decay
#define CHAT_MESSAGE_APPROX_LHEIGHT 11
/// Max width of chat message in pixels
#define CHAT_MESSAGE_WIDTH 112
/// The dimensions of the chat message icons
#define CHAT_MESSAGE_ICON_SIZE 9
///Base layer of chat elements
#define CHAT_LAYER 1
///Highest possible layer of chat elements
#define CHAT_LAYER_MAX 2
/// Maximum precision of float before rounding errors occur (in this context)
#define CHAT_LAYER_Z_STEP 0.0001
/// The number of z-layer 'slices' usable by the chat message layering
#define CHAT_LAYER_MAX_Z (CHAT_LAYER_MAX - CHAT_LAYER) / CHAT_LAYER_Z_STEP
/**
* # Chat Message Overlay
*
* Datum for generating a message overlay on the map
*/
/datum/chatmessage
/// The visual element of the chat message
var/image/message
/// The location in which the message is appearing
var/atom/message_loc
/// The client who heard this message
var/client/owned_by
/// Contains the scheduled destruction time, used for scheduling EOL
var/scheduled_destruction
/// Contains the time that the EOL for the message will be complete, used for qdel scheduling
var/eol_complete
/// Contains the approximate amount of lines for height decay
var/approx_lines
/// The current index used for adjusting the layer of each sequential chat message such that recent messages will overlay older ones
var/static/current_z_idx = 0
/// When we started animating the message
var/animate_start = 0
/// Our animation lifespan, how long this message will last
var/animate_lifespan = 0
/// Callback to finish_image_generation passed to SSrunechat
var/datum/callback/finish_callback
/**
* Constructs a chat message overlay
*
* Arguments:
* * text - The text content of the overlay
* * target - The target atom to display the overlay at
* * owner - The mob that owns this overlay, only this mob will be able to view it
* * language - The language this message was spoken in
* * extra_classes - Extra classes to apply to the span that holds the text
* * lifespan - The lifespan of the message in deciseconds
*/
/datum/chatmessage/New(text, atom/target, mob/owner, datum/language/language, list/extra_classes = list(), lifespan = CHAT_MESSAGE_LIFESPAN)
. = ..()
if (!istype(target))
CRASH("Invalid target given for chatmessage")
if(QDELETED(owner) || !istype(owner) || !owner.client)
stack_trace("/datum/chatmessage created with [isnull(owner) ? "null" : "invalid"] mob owner")
qdel(src)
return
INVOKE_ASYNC(src, PROC_REF(generate_image), text, target, owner, language, extra_classes, lifespan)
/datum/chatmessage/Destroy()
if (!QDELING(owned_by))
if(REALTIMEOFDAY < animate_start + animate_lifespan)
stack_trace("Del'd before we finished fading, with [(animate_start + animate_lifespan) - REALTIMEOFDAY] time left")
if (owned_by.seen_messages)
LAZYREMOVEASSOC(owned_by.seen_messages, message_loc, src)
owned_by.images.Remove(message)
if (finish_callback)
SSrunechat.message_queue -= finish_callback
finish_callback = null
owned_by = null
message_loc = null
message = null
return ..()
/**
* Calls qdel on the chatmessage when its parent is deleted, used to register qdel signal
*/
/datum/chatmessage/proc/on_parent_qdel()
SIGNAL_HANDLER
qdel(src)
/**
* Generates a chat message image representation
*
* Arguments:
* * text - The text content of the overlay
* * target - The target atom to display the overlay at
* * owner - The mob that owns this overlay, only this mob will be able to view it
* * language - The language this message was spoken in
* * extra_classes - Extra classes to apply to the span that holds the text
* * lifespan - The lifespan of the message in deciseconds
*/
/datum/chatmessage/proc/generate_image(text, atom/target, mob/owner, datum/language/language, list/extra_classes, lifespan)
/// Cached icons to show what language the user is speaking
var/static/list/language_icons
// Register client who owns this message
owned_by = owner.client
RegisterSignal(owned_by, COMSIG_QDELETING, PROC_REF(on_parent_qdel))
// Remove spans in the message from things like the recorder
var/static/regex/span_check = new(@"<\/?span[^>]*>", "gi")
text = replacetext(text, span_check, "")
// Clip message
var/maxlen = owned_by.prefs.read_preference(/datum/preference/numeric/max_chat_length)
if (length_char(text) > maxlen)
text = copytext_char(text, 1, maxlen + 1) + "..." // BYOND index moment
// Get rid of any URL schemes that might cause BYOND to automatically wrap something in an anchor tag
var/static/regex/url_scheme = new(@"[A-Za-z][A-Za-z0-9+-\.]*:\/\/", "g")
text = replacetext(text, url_scheme, "")
// Reject whitespace
var/static/regex/whitespace = new(@"^\s*$")
if (whitespace.Find(text))
qdel(src)
return
// Non mobs speakers can be small
if (!ismob(target))
extra_classes |= "small"
// Why are you yelling?
if(copytext_char(text, -2) == "!!")
extra_classes |= SPAN_YELL
var/list/prefixes
var/chat_color_name_to_use
// Append radio icon if from a virtual speaker
if (extra_classes.Find("virtual-speaker"))
var/image/r_icon = image('icons/ui/chat/chat_icons.dmi', icon_state = "radio")
LAZYADD(prefixes, "\icon[r_icon]")
else if (extra_classes.Find("emote"))
var/image/r_icon = image('icons/ui/chat/chat_icons.dmi', icon_state = "emote")
LAZYADD(prefixes, "\icon[r_icon]")
chat_color_name_to_use = target.get_visible_name(add_id_name = FALSE) // use face name for nonverbal messages
if(isnull(chat_color_name_to_use))
if(HAS_TRAIT(target, TRAIT_SIGN_LANG))
chat_color_name_to_use = target.get_visible_name(add_id_name = FALSE) // use face name for signers too
else
chat_color_name_to_use = target.get_voice() // for everything else, use the target's voice name
// Calculate target color if not already present
if (!target.chat_color || target.chat_color_name != chat_color_name_to_use)
target.chat_color = colorize_string(chat_color_name_to_use)
target.chat_color_darkened = colorize_string(chat_color_name_to_use, 0.85, 0.85)
target.chat_color_name = chat_color_name_to_use
// Append language icon if the language uses one
var/datum/language/language_instance = GLOB.language_datum_instances[language]
if (language_instance?.display_icon(owner))
var/icon/language_icon = LAZYACCESS(language_icons, language)
if (isnull(language_icon))
language_icon = icon(language_instance.icon, icon_state = language_instance.icon_state)
language_icon.Scale(CHAT_MESSAGE_ICON_SIZE, CHAT_MESSAGE_ICON_SIZE)
LAZYSET(language_icons, language, language_icon)
LAZYADD(prefixes, "\icon[language_icon]")
text = "[prefixes?.Join("&nbsp;")][text]"
// We dim italicized text to make it more distinguishable from regular text
var/tgt_color = extra_classes.Find("italics") ? target.chat_color_darkened : target.chat_color
// Approximate text height
var/complete_text = "<span style='color: [tgt_color]'><span class='center [extra_classes.Join(" ")]'>[owner.apply_message_emphasis(text)]</span></span>"
var/mheight
WXH_TO_HEIGHT(owned_by.MeasureText(complete_text, null, CHAT_MESSAGE_WIDTH), mheight)
if(!VERB_SHOULD_YIELD)
return finish_image_generation(mheight, target, owner, complete_text, lifespan)
finish_callback = CALLBACK(src, PROC_REF(finish_image_generation), mheight, target, owner, complete_text, lifespan)
SSrunechat.message_queue += finish_callback
return
///finishes the image generation after the MeasureText() call in generate_image().
///necessary because after that call the proc can resume at the end of the tick and cause overtime.
/datum/chatmessage/proc/finish_image_generation(mheight, atom/target, mob/owner, complete_text, lifespan)
finish_callback = null
var/rough_time = REALTIMEOFDAY
approx_lines = max(1, mheight / CHAT_MESSAGE_APPROX_LHEIGHT)
var/starting_height = target.maptext_height
// Translate any existing messages upwards, apply exponential decay factors to timers
message_loc = isturf(target) ? target : get_atom_on_turf(target)
if (owned_by.seen_messages)
var/idx = 1
var/combined_height = approx_lines
for(var/datum/chatmessage/m as anything in owned_by.seen_messages[message_loc])
combined_height += m.approx_lines
var/time_spent = rough_time - m.animate_start
var/time_before_fade = m.animate_lifespan - CHAT_MESSAGE_EOL_FADE
// When choosing to update the remaining time we have to be careful not to update the
// scheduled time once the EOL has been executed.
var/continuing = 0
if (time_spent >= time_before_fade)
if(m.message.pixel_z < starting_height)
var/max_height = m.message.pixel_z + m.approx_lines * CHAT_MESSAGE_APPROX_LHEIGHT - starting_height
if(max_height > 0)
animate(m.message, pixel_z = m.message.pixel_z + max_height, time = CHAT_MESSAGE_SPAWN_TIME, flags = continuing | ANIMATION_PARALLEL)
continuing |= ANIMATION_CONTINUE
else if(mheight + starting_height >= m.message.pixel_z)
animate(m.message, pixel_z = m.message.pixel_z + mheight, time = CHAT_MESSAGE_SPAWN_TIME, flags = continuing | ANIMATION_PARALLEL)
continuing |= ANIMATION_CONTINUE
continue
var/remaining_time = time_before_fade * (CHAT_MESSAGE_EXP_DECAY ** idx++) * (CHAT_MESSAGE_HEIGHT_DECAY ** combined_height)
// Ensure we don't accidentially spike alpha up or something silly like that
m.message.alpha = m.get_current_alpha(time_spent)
if(remaining_time > 0)
if(time_spent < CHAT_MESSAGE_SPAWN_TIME)
// We haven't even had the time to fade in yet!
animate(m.message, alpha = 255, CHAT_MESSAGE_SPAWN_TIME - time_spent, flags=continuing)
continuing |= ANIMATION_CONTINUE
// Stay faded in for a while, then
animate(m.message, alpha = 255, time = remaining_time, flags=continuing)
continuing |= ANIMATION_CONTINUE
// Fade out
animate(m.message, alpha = 0, time = CHAT_MESSAGE_EOL_FADE, flags=continuing)
continuing |= ANIMATION_CONTINUE
m.animate_lifespan = remaining_time + CHAT_MESSAGE_EOL_FADE
else
// Your time has come my son
animate(m.message, alpha = 0, time = CHAT_MESSAGE_EOL_FADE, flags=continuing)
continuing |= ANIMATION_CONTINUE
// We run this after the alpha animate, because we don't want to interrup it, but also don't want to block it by running first
// Sooo instead we do this. bit messy but it fuckin works
if(m.message.pixel_z < starting_height)
var/max_height = m.message.pixel_z + m.approx_lines * CHAT_MESSAGE_APPROX_LHEIGHT - starting_height
if(max_height > 0)
animate(m.message, pixel_z = m.message.pixel_z + max_height, time = CHAT_MESSAGE_SPAWN_TIME, flags = continuing | ANIMATION_PARALLEL)
continuing |= ANIMATION_CONTINUE
else if(mheight + starting_height >= m.message.pixel_z)
animate(m.message, pixel_z = m.message.pixel_z + mheight, time = CHAT_MESSAGE_SPAWN_TIME, flags = continuing | ANIMATION_PARALLEL)
continuing |= ANIMATION_CONTINUE
// Reset z index if relevant
if (current_z_idx >= CHAT_LAYER_MAX_Z)
current_z_idx = 0
// Build message image
message = image(loc = message_loc, layer = CHAT_LAYER + CHAT_LAYER_Z_STEP * current_z_idx++)
SET_PLANE_EXPLICIT(message, RUNECHAT_PLANE, message_loc)
message.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA | KEEP_APART
message.alpha = 0
message.pixel_z = starting_height
message.pixel_w = -target.base_pixel_w
message.maptext_width = CHAT_MESSAGE_WIDTH
message.maptext_height = mheight * 1.25 // We add extra because some characters are superscript, like actions
message.maptext_x = (CHAT_MESSAGE_WIDTH - owner.bound_width) * -0.5
message.maptext = MAPTEXT(complete_text)
animate_start = rough_time
animate_lifespan = lifespan
// View the message
LAZYADDASSOCLIST(owned_by.seen_messages, message_loc, src)
owned_by.images |= message
// Fade in
animate(message, alpha = 255, time = CHAT_MESSAGE_SPAWN_TIME)
var/time_before_fade = lifespan - CHAT_MESSAGE_SPAWN_TIME - CHAT_MESSAGE_EOL_FADE
// Stay faded in
animate(alpha = 255, time = time_before_fade)
// Fade out
animate(alpha = 0, time = CHAT_MESSAGE_EOL_FADE)
RegisterSignal(message_loc, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(loc_z_changed))
// Register with the runechat SS to handle destruction
addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(qdel), src), lifespan + CHAT_MESSAGE_GRACE_PERIOD, TIMER_DELETE_ME, SSrunechat)
/datum/chatmessage/proc/get_current_alpha(time_spent)
if(time_spent < CHAT_MESSAGE_SPAWN_TIME)
return (time_spent / CHAT_MESSAGE_SPAWN_TIME) * 255
var/time_before_fade = animate_lifespan - CHAT_MESSAGE_EOL_FADE
if(time_spent <= time_before_fade)
return 255
return (1 - ((time_spent - time_before_fade) / CHAT_MESSAGE_EOL_FADE)) * 255
/datum/chatmessage/proc/loc_z_changed(datum/source, turf/old_turf, turf/new_turf, same_z_layer)
SIGNAL_HANDLER
SET_PLANE(message, RUNECHAT_PLANE, new_turf)
/**
* Creates a message overlay at a defined location for a given speaker
*
* Arguments:
* * speaker - The atom who is saying this message
* * message_language - The language that the message is said in
* * raw_message - The text content of the message
* * spans - Additional classes to be added to the message
*/
/mob/proc/create_chat_message(atom/movable/speaker, datum/language/message_language, raw_message, list/spans, runechat_flags = NONE)
if(SSlag_switch.measures[DISABLE_RUNECHAT] && !HAS_TRAIT(speaker, TRAIT_BYPASS_MEASURES))
return
if(HAS_TRAIT(speaker, TRAIT_RUNECHAT_HIDDEN))
return
// Ensure the list we are using, if present, is a copy so we don't modify the list provided to us
spans = spans ? spans.Copy() : list()
// Check for virtual speakers (aka hearing a message through a radio)
var/atom/movable/originalSpeaker = speaker
if (istype(speaker, /atom/movable/virtualspeaker))
var/atom/movable/virtualspeaker/v = speaker
speaker = v.source
spans |= "virtual-speaker"
// Ignore virtual speaker (most often radio messages) from ourselves
if (originalSpeaker != src && speaker == src)
return
// Display visual above source
if(runechat_flags & EMOTE_MESSAGE)
new /datum/chatmessage(raw_message, speaker, src, message_language, list("emote", "italics"))
else
new /datum/chatmessage(raw_message, speaker, src, message_language, spans)
#undef CHAT_LAYER_MAX_Z
#undef CHAT_LAYER_Z_STEP
#undef CHAT_MESSAGE_APPROX_LHEIGHT
#undef CHAT_MESSAGE_GRACE_PERIOD
#undef CHAT_MESSAGE_EOL_FADE
#undef CHAT_MESSAGE_EXP_DECAY
#undef CHAT_MESSAGE_HEIGHT_DECAY
#undef CHAT_MESSAGE_ICON_SIZE
#undef CHAT_MESSAGE_LIFESPAN
#undef CHAT_MESSAGE_SPAWN_TIME
#undef CHAT_MESSAGE_WIDTH