diff --git a/code/datums/chat_message.dm b/code/datums/chat_message.dm new file mode 100644 index 0000000000..82d4aff167 --- /dev/null +++ b/code/datums/chat_message.dm @@ -0,0 +1,334 @@ +#define CHAT_MESSAGE_SPAWN_TIME 0.2 SECONDS +#define CHAT_MESSAGE_LIFESPAN 5 SECONDS +#define CHAT_MESSAGE_EOL_FADE 0.7 SECONDS +#define CHAT_MESSAGE_EXP_DECAY 0.8 // Messages decay at pow(factor, idx in stack) +#define CHAT_MESSAGE_HEIGHT_DECAY 0.7 // Increase message decay based on the height of the message +#define CHAT_MESSAGE_APPROX_LHEIGHT 11 // Approximate height in pixels of an 'average' line, used for height decay + +#define CHAT_MESSAGE_WIDTH 96 // pixels +#define CHAT_MESSAGE_EXT_WIDTH 128 +#define CHAT_MESSAGE_LENGTH 68 // characters +#define CHAT_MESSAGE_EXT_LENGTH 150 + +#define CHAT_MESSAGE_MOB 1 +#define CHAT_MESSAGE_OBJ 2 +#define WXH_TO_HEIGHT(x) text2num(copytext((x), findtextEx((x), "x") + 1)) // thanks lummox + +#define CHAT_RUNE_EMOTE 0x1 +#define CHAT_RUNE_RADIO 0x2 + +/** + * # Chat Message Overlay + * + * Datum for generating a message overlay on the map + * Ported from TGStation; https://github.com/tgstation/tgstation/pull/50608/, author: bobbahbrown + */ + +// Cached runechat icon +var/list/runechat_image_cache = list() + + +/hook/startup/proc/runechat_images() + var/image/radio_image = image('icons/UI_Icons/chat/chat_icons.dmi', icon_state = "radio") + runechat_image_cache["radio"] = radio_image + + var/image/emote_image = image('icons/UI_Icons/chat/chat_icons.dmi', icon_state = "emote") + runechat_image_cache["emote"] = emote_image + + return TRUE + +/datum/chatmessage + /// The visual element of the chat messsage + 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 + var/scheduled_destruction + /// Contains the approximate amount of lines for height decay + var/approx_lines + /// If we are currently processing animation and cleanup at EOL + var/ending_life + +/** + * 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 + * * 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, list/extra_classes = null, lifespan = CHAT_MESSAGE_LIFESPAN) + . = ..() + if(!istype(target)) + CRASH("Invalid target given for chatmessage") + if(!istype(owner) || QDELETED(owner) || !owner.client) + stack_trace("/datum/chatmessage created with [isnull(owner) ? "null" : "invalid"] mob owner") + qdel(src) + return + generate_image(text, target, owner, extra_classes, lifespan) + +/datum/chatmessage/Destroy() + if(owned_by) + UnregisterSignal(owned_by, COMSIG_PARENT_QDELETING) + LAZYREMOVEASSOC(owned_by.seen_messages, message_loc, src) + owned_by.images.Remove(message) + if(message_loc) + UnregisterSignal(message_loc, COMSIG_PARENT_QDELETING) + owned_by = null + message_loc = null + message = null + return ..() + +/** + * 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 + * * 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, list/extra_classes, lifespan) + set waitfor = FALSE + + if(!target || !owner) + qdel(src) + return + + // Register client who owns this message + owned_by = owner.client + RegisterSignal(owned_by, COMSIG_PARENT_QDELETING, .proc/qdel_self) + + var/extra_length = owned_by.is_preference_enabled(/datum/client_preference/runechat_long_messages) + var/maxlen = extra_length ? CHAT_MESSAGE_EXT_LENGTH : CHAT_MESSAGE_LENGTH + var/msgwidth = extra_length ? CHAT_MESSAGE_EXT_WIDTH : CHAT_MESSAGE_WIDTH + + // Clip message + if(length_char(text) > maxlen) + text = copytext_char(text, 1, maxlen + 1) + "..." // BYOND index moment + + // Calculate target color if not already present + if(!target.chat_color || target.chat_color_name != target.name) + target.chat_color = colorize_string(target.name) + target.chat_color_darkened = colorize_string(target.name, 0.85, 0.85) + target.chat_color_name = target.name + + // 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" + + // If we heard our name, it's important + // Differnt from our own system of name emphasis, maybe unify + var/list/names = splittext(owner.name, " ") + for (var/word in names) + text = replacetext(text, word, "[word]") + + var/list/prefixes + + // Append prefixes + if(extra_classes.Find("virtual-speaker")) + LAZYADD(prefixes, "\icon[runechat_image_cache["radio"]]") + if(extra_classes.Find("emote")) + // Icon on both ends? + //var/image/I = runechat_image_cache["emote"] + //text = "\icon[I][text]\icon[I]" + + // Icon on one end? + //LAZYADD(prefixes, "\icon[runechat_image_cache["emote"]]") + + // Asterisks instead? + text = "* [text] *" + + text = "[prefixes?.Join(" ")][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 = "[text]" + var/mheight = WXH_TO_HEIGHT(owned_by.MeasureText(complete_text, null, msgwidth)) + approx_lines = max(1, mheight / CHAT_MESSAGE_APPROX_LHEIGHT) + + // Translate any existing messages upwards, apply exponential decay factors to timers + message_loc = target + RegisterSignal(message_loc, COMSIG_PARENT_QDELETING, .proc/qdel_self) + if(owned_by.seen_messages) + var/idx = 1 + var/combined_height = approx_lines + for(var/msg in owned_by.seen_messages[message_loc]) + var/datum/chatmessage/m = msg + animate(m.message, pixel_y = m.message.pixel_y + mheight, time = CHAT_MESSAGE_SPAWN_TIME) + combined_height += m.approx_lines + + if(!m.ending_life) // Don't bother! + var/sched_remaining = m.scheduled_destruction - world.time + if(sched_remaining > CHAT_MESSAGE_SPAWN_TIME) + var/remaining_time = (sched_remaining) * (CHAT_MESSAGE_EXP_DECAY ** idx++) * (CHAT_MESSAGE_HEIGHT_DECAY ** combined_height) + m.scheduled_destruction = world.time + remaining_time + spawn(remaining_time) + m.end_of_life() + + // Build message image + message = image(loc = message_loc, layer = ABOVE_MOB_LAYER) + message.plane = PLANE_RUNECHAT + message.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA | KEEP_APART + message.alpha = 0 + message.pixel_y = (owner.bound_height * 0.95)*owner.size_multiplier + message.maptext_width = msgwidth + message.maptext_height = mheight + message.maptext_x = (msgwidth - owner.bound_width) * -0.5 + message.maptext = complete_text + + if(owner.contains(target)) // Special case, holding an atom speaking (pAI, recorder...) + message.plane = PLANE_PLAYER_HUD_ABOVE + + // View the message + LAZYADDASSOCLIST(owned_by.seen_messages, message_loc, src) + owned_by.images += message + animate(message, alpha = 255, time = CHAT_MESSAGE_SPAWN_TIME) + + // Prepare for destruction + scheduled_destruction = world.time + (lifespan - CHAT_MESSAGE_EOL_FADE) + spawn(lifespan - CHAT_MESSAGE_EOL_FADE) + end_of_life() + +/** + * Applies final animations to overlay CHAT_MESSAGE_EOL_FADE deciseconds prior to message deletion + */ +/datum/chatmessage/proc/end_of_life(fadetime = CHAT_MESSAGE_EOL_FADE) + if(gc_destroyed || ending_life) + return + ending_life = TRUE + animate(message, alpha = 0, time = fadetime, flags = ANIMATION_PARALLEL) + spawn(fadetime) + qdel(src) + +/** + * Creates a message overlay at a defined location for a given speaker + * + * Arguments: + * * speaker - The atom who is saying this message + * * message - The text content of the message + * * italics - Decides if this should be small or not, as generally italics text are for whisper/radio overhear + * * existing_extra_classes - Additional classes to add to the message + */ +/mob/proc/create_chat_message(atom/movable/speaker, message, italics, list/existing_extra_classes, audible = TRUE) + if(!client) + return + + // Doesn't want to hear + if(ismob(speaker) && !client.is_preference_enabled(/datum/client_preference/runechat_mob)) + return + else if(isobj(speaker) && !client.is_preference_enabled(/datum/client_preference/runechat_obj)) + return + + // Incapable of receiving + if((audible && is_deaf()) || (!audible && is_blind())) + return + + // Check for virtual speakers (aka hearing a message through a radio) + if(existing_extra_classes.Find("radio")) + return + + /* Not currently necessary + message = strip_html_properly(message) + if(!message) + return + */ + + var/list/extra_classes = list() + extra_classes += existing_extra_classes + + if(italics) + extra_classes |= "italics" + + if(client.is_preference_enabled(/datum/client_preference/runechat_border)) + extra_classes |= "black_outline" + + var/dist = get_dist(src, speaker) + switch (dist) + if(4 to 5) + extra_classes |= "small" + if(5 to 16) + extra_classes |= "very_small" + + // Display visual above source + new /datum/chatmessage(message, speaker, src, extra_classes) + +// Tweak these defines to change the available color ranges +#define CM_COLOR_SAT_MIN 0.6 +#define CM_COLOR_SAT_MAX 0.95 +#define CM_COLOR_LUM_MIN 0.70 +#define CM_COLOR_LUM_MAX 0.90 + +/** + * Gets a color for a name, will return the same color for a given string consistently within a round.atom + * + * Note that this proc aims to produce pastel-ish colors using the HSL colorspace. These seem to be favorable for displaying on the map. + * + * Arguments: + * * name - The name to generate a color for + * * sat_shift - A value between 0 and 1 that will be multiplied against the saturation + * * lum_shift - A value between 0 and 1 that will be multiplied against the luminescence + */ +/datum/chatmessage/proc/colorize_string(name, sat_shift = 1, lum_shift = 1) + // seed to help randomness + var/static/rseed = rand(1,26) + + // get hsl using the selected 6 characters of the md5 hash + var/hash = copytext(md5(name + "[world_startup_time]"), rseed, rseed + 6) + var/h = hex2num(copytext(hash, 1, 3)) * (360 / 255) + var/s = (hex2num(copytext(hash, 3, 5)) >> 2) * ((CM_COLOR_SAT_MAX - CM_COLOR_SAT_MIN) / 63) + CM_COLOR_SAT_MIN + var/l = (hex2num(copytext(hash, 5, 7)) >> 2) * ((CM_COLOR_LUM_MAX - CM_COLOR_LUM_MIN) / 63) + CM_COLOR_LUM_MIN + + // adjust for shifts + s *= clamp(sat_shift, 0, 1) + l *= clamp(lum_shift, 0, 1) + + // convert to rgba + var/h_int = round(h/60) // mapping each section of H to 60 degree sections + var/c = (1 - abs(2 * l - 1)) * s + var/x = c * (1 - abs((h / 60) % 2 - 1)) + var/m = l - c * 0.5 + x = (x + m) * 255 + c = (c + m) * 255 + m *= 255 + switch(h_int) + if(0) + return rgb(c,x,m) + if(1) + return rgb(x,c,m) + if(2) + return rgb(m,c,x) + if(3) + return rgb(m,x,c) + if(4) + return rgb(x,m,c) + if(5) + return rgb(c,m,x) + +/atom/proc/runechat_message(message, range = world.view, italics, list/classes = list(), audible = TRUE) + var/list/hear = get_mobs_and_objs_in_view_fast(get_turf(src), range, remote_ghosts = FALSE) + + var/list/hearing_mobs = hear["mobs"] + + for(var/mob in hearing_mobs) + var/mob/M = mob + if(!M.client) + continue + M.create_chat_message(src, message, italics, classes, audible) diff --git a/code/datums/components/_component.dm b/code/datums/components/_component.dm index 1cb9e0b1c4..c4ee090ce8 100644 --- a/code/datums/components/_component.dm +++ b/code/datums/components/_component.dm @@ -213,10 +213,10 @@ * * Arguments: * * datum/target Datum to stop listening to signals from - * * sig_typeor_types Signal string key or list of signal keys to stop listening to specifically + * * sig_type_or_types Signal string key or list of signal keys to stop listening to specifically */ /datum/proc/UnregisterSignal(datum/target, sig_type_or_types) - var/list/lookup = target.comp_lookup + var/list/lookup = target?.comp_lookup if(!signal_procs || !signal_procs[target] || !lookup) return if(!islist(sig_type_or_types)) diff --git a/code/modules/mob/hear_say.dm b/code/modules/mob/hear_say.dm index c5685aa0dc..1d2feb60aa 100644 --- a/code/modules/mob/hear_say.dm +++ b/code/modules/mob/hear_say.dm @@ -11,9 +11,13 @@ if(SP.speaking && SP.speaking.flags & INNATE) // Snowflake for noise lang if(radio) - return SP.speaking.format_message_radio(piece) + .["formatted"] = SP.speaking.format_message_radio(piece) + .["raw"] = piece + return else - return SP.speaking.format_message(piece) + .["formatted"] = SP.speaking.format_message(piece) + .["raw"] = piece + return if(iteration_count == 1) piece = capitalize(piece)