Files
Bubberstation/code/datums/chatmessage.dm
Waterpig d3d3a12540 The big fix for pixel_x and pixel_y use cases. (#90124)
## About The Pull Request

516 requires float layered overlays to be using pixel_w and pixel_z
instead of pixel_x and pixel_y respectively, unless we want
visual/layering errors. This makes sense, as w,z are for visual effects
only. Sadly seems we were not entirely consistent in this, and many
things seem to have been using x,y incorrectly.

This hopefully fixes that, and thus also fixes layering issues. Complete
1:1 compatibility not guaranteed.

I did the lazy way suggested to me by SmArtKar to speed it up (Runtiming
inside apply_overlays), and this is still included in the PR to flash
out possible issues in a TM (Plus I will need someone to grep the
runtimes for me after the TM period to make sure nothing was missed).
After this is done I'll remove all these extra checks.

Lints will probably be failing for a bit, got to wait for [this
update](4b77cd487d)
to them to make it into release. Or just unlint the lines, though that's
probably gonna produce code debt

## Why It's Good For The Game

Fixes this massive 516 mess, hopefully.

closes #90281

## Changelog
🆑
refactor: Changed many of our use cases for pixel_x and pixel_y
correctly into pixel_w and pixel_z, fixing layering issues in the
process.
/🆑

---------

Co-authored-by: SmArtKar <44720187+SmArtKar@users.noreply.github.com>
Co-authored-by: SmArtKar <master.of.bagets@gmail.com>
2025-03-28 14:18:45 +00: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.GetVoice() // 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.say_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