From ca29f5340ee92befe2cffb25bcf248621de73ff3 Mon Sep 17 00:00:00 2001 From: AffectedArc07 <25063394+AffectedArc07@users.noreply.github.com> Date: Mon, 26 Oct 2020 14:10:09 +0000 Subject: [PATCH] Runechat - stop bugging me for gods sake (#14141) * Runechat - stop bugging me for gods sake * Hotfix * V2 * Patch 1 * Removes Radio * Colour Sanity * Fixes loc issues * 2020-08-28 * LF --> CRLF * Forgot this * Fixes holopad stuffs * Preference toggle existing! * Drask + Kidan --- code/__DEFINES/layers.dm | 3 + code/__DEFINES/preferences.dm | 5 +- code/__DEFINES/subsystems.dm | 1 + code/__HELPERS/lists.dm | 3 + code/__HELPERS/text.dm | 58 ++++ code/controllers/subsystem/runechat.dm | 233 +++++++++++++++ code/datums/chatmessage.dm | 272 ++++++++++++++++++ code/game/atoms.dm | 8 + code/game/objects/items/devices/megaphone.dm | 4 + code/modules/client/client_defines.dm | 3 + .../client/preference/preferences_toggles.dm | 8 + code/modules/mob/hear_say.dm | 25 +- code/modules/mob/living/carbon/human/human.dm | 8 + .../living/carbon/human/species/_species.dm | 15 + .../mob/living/carbon/human/species/drask.dm | 4 + .../mob/living/carbon/human/species/grey.dm | 4 + .../mob/living/carbon/human/species/kidan.dm | 5 + code/modules/mob/living/silicon/ai/ai.dm | 3 + code/modules/mob/living/silicon/say.dm | 6 +- code/world.dm | 1 + interface/skin.dmf | 23 +- paradise.dme | 2 + 22 files changed, 673 insertions(+), 21 deletions(-) create mode 100644 code/controllers/subsystem/runechat.dm create mode 100644 code/datums/chatmessage.dm diff --git a/code/__DEFINES/layers.dm b/code/__DEFINES/layers.dm index ee1cd3fa762..6e677b02e49 100644 --- a/code/__DEFINES/layers.dm +++ b/code/__DEFINES/layers.dm @@ -80,6 +80,9 @@ #define MASSIVE_OBJ_LAYER 11 #define POINT_LAYER 12 +#define CHAT_LAYER 12.0001 // Do not insert layers between these two values +#define CHAT_LAYER_MAX 12.9999 + #define LIGHTING_PLANE 15 #define LIGHTING_LAYER 15 diff --git a/code/__DEFINES/preferences.dm b/code/__DEFINES/preferences.dm index 7d8528d2da3..ee16b4d59f4 100644 --- a/code/__DEFINES/preferences.dm +++ b/code/__DEFINES/preferences.dm @@ -48,10 +48,11 @@ #define PREFTOGGLE_2_WINDOWFLASHING 8 #define PREFTOGGLE_2_ANONDCHAT 16 #define PREFTOGGLE_2_AFKWATCH 32 +#define PREFTOGGLE_2_RUNECHAT 64 -#define TOGGLES_2_TOTAL 63 // If you add or remove a preference toggle above, make sure you update this define with the total value of the toggles combined. +#define TOGGLES_2_TOTAL 127 // If you add or remove a preference toggle above, make sure you update this define with the total value of the toggles combined. -#define TOGGLES_2_DEFAULT (PREFTOGGLE_2_FANCYUI|PREFTOGGLE_2_ITEMATTACK|PREFTOGGLE_2_WINDOWFLASHING) +#define TOGGLES_2_DEFAULT (PREFTOGGLE_2_FANCYUI|PREFTOGGLE_2_ITEMATTACK|PREFTOGGLE_2_WINDOWFLASHING|PREFTOGGLE_2_RUNECHAT) // Sanity checks #if TOGGLES_TOTAL > 16777215 diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 11732a22375..8038ee7cf9b 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -114,6 +114,7 @@ #define FIRE_PRIORITY_MOBS 100 #define FIRE_PRIORITY_NANOUI 110 #define FIRE_PRIORITY_TICKER 200 +#define FIRE_PRIORITY_RUNECHAT 410 // I hate how high the fire priority on this is -aa #define FIRE_PRIORITY_OVERLAYS 500 #define FIRE_PRIORITY_INPUT 1000 // This must always always be the max highest priority. Player input must never be lost. diff --git a/code/__HELPERS/lists.dm b/code/__HELPERS/lists.dm index 075f3370965..ac6a9620c2c 100644 --- a/code/__HELPERS/lists.dm +++ b/code/__HELPERS/lists.dm @@ -690,6 +690,9 @@ proc/dd_sortedObjectList(list/incoming) // Lazying Episode 3 #define LAZYSET(L, K, V) LAZYINITLIST(L); L[K] = V; +#define LAZYADDASSOC(L, K, V) if(!L) { L = list(); } L[K] += list(V); +#define LAZYREMOVEASSOC(L, K, V) if(L) { if(L[K]) { L[K] -= V; if(!length(L[K])) L -= K; } if(!length(L)) L = null; } + /// Returns whether a numerical index is within a given list's bounds. Faster than isnull(LAZYACCESS(L, I)). #define ISINDEXSAFE(L, I) (I >= 1 && I <= length(L)) diff --git a/code/__HELPERS/text.dm b/code/__HELPERS/text.dm index 7019ee8b2c4..55c1b3901a9 100644 --- a/code/__HELPERS/text.dm +++ b/code/__HELPERS/text.dm @@ -615,3 +615,61 @@ text = replacetext(text, "", "\[cell\]") text = replacetext(text, "", "\[logo\]") return text + +/datum/html/split_holder + var/list/opening + var/inner_text + var/list/closing + +/datum/html/split_holder/New() + opening = list() + inner_text = "" + closing = list() + +/proc/split_html(raw_text="") + // gently borrowed and re-purposed from code/modules/pda/utilities.dm + // define a datum to hold our result + var/datum/html/split_holder/s = new() + + // copy the raw_text to get started + var/text = copytext_char(raw_text, 1) + + // search for tag brackets + var/tag_start = findtext_char(text, "<") + var/tag_stop = findtext_char(text, ">") + + // until we run out of opening tags + while((tag_start != 0) && (tag_stop != 0)) + // if the tag isn't at the beginning of the string + if(tag_start > 1) + // we've found our text, so copy it out + s.inner_text = copytext_char(text, 1, tag_start) + // and chop the text for the next round + text = copytext_char(text, tag_start) + break + // otherwise, we found an opening tag, so add it to the list + var/tag = copytext_char(text, tag_start, tag_stop+1) + s.opening.Add(tag) + // and chop the text for the next round + text = copytext_char(text, tag_stop+1) + // look for the next tag in what's left + tag_start = findtext(text, "<") + tag_stop = findtext(text, ">") + + // search for tag brackets + tag_start = findtext(text, "<") + tag_stop = findtext(text, ">") + + // until we run out of closing tags + while((tag_start != 0) && (tag_stop != 0)) + // we found a closing tag, so add it to the list + var/tag = copytext_char(text, tag_start, tag_stop+1) + s.closing.Add(tag) + // and chop the text for the next round + text = copytext_char(text, tag_stop+1) + // look for the next tag in what's left + tag_start = findtext(text, "<") + tag_stop = findtext(text, ">") + + // return the split html object to the caller + return s diff --git a/code/controllers/subsystem/runechat.dm b/code/controllers/subsystem/runechat.dm new file mode 100644 index 00000000000..c5d49576fda --- /dev/null +++ b/code/controllers/subsystem/runechat.dm @@ -0,0 +1,233 @@ +/// Controls how many buckets should be kept, each representing a tick. (30 seconds worth) +#define BUCKET_LEN (world.fps * 1 * 30) +/// Helper for getting the correct bucket for a given chatmessage +#define BUCKET_POS(scheduled_destruction) (((round((scheduled_destruction - SSrunechat.head_offset) / world.tick_lag) + 1) % BUCKET_LEN) || BUCKET_LEN) +/// Gets the maximum time at which messages will be handled in buckets, used for deferring to secondary queue +#define BUCKET_LIMIT (world.time + TICKS2DS(min(BUCKET_LEN - (SSrunechat.practical_offset - DS2TICKS(world.time - SSrunechat.head_offset)) - 1, BUCKET_LEN - 1))) + +/** + * # Runechat Subsystem + * + * Maintains a timer-like system to handle destruction of runechat messages. Much of this code is modeled + * after or adapted from the timer subsystem. Made by Bobbahbrown of /tg/station13 + * + * Note that this has the same structure for storing and queueing messages as the timer subsystem does + * for handling timers: the bucket_list is a list of chatmessage datums, each of which are the head + * of a circularly linked list. Any given index in bucket_list could be null, representing an empty bucket. + * + * AA Note: + * One of the primary reasons for this is because each chatmessage has a timer attached to it, which is extra load on the GC + * At 150 population, the GC literally cannot keep up with processing 368,000 runechats and 368,000 extra timers in a 1 hour 30 minute round + * This also makes performance profiling a lot easier. + * + */ +SUBSYSTEM_DEF(runechat) + name = "Runechat" + flags = SS_TICKER | SS_NO_INIT + wait = 1 + priority = FIRE_PRIORITY_RUNECHAT + offline_implications = "Runechat messages will no longer clear. Shuttle call recommended." + + /// world.time of the first entry in the bucket list, effectively the 'start time' of the current buckets + var/head_offset = 0 + /// Index of the first non-empty bucket + var/practical_offset = 1 + /// world.tick_lag the bucket was designed for + var/bucket_resolution = 0 + /// How many messages are in the buckets + var/bucket_count = 0 + /// List of buckets, each bucket holds every message that has to be killed that byond tick + var/list/bucket_list = list() + /// Queue used for storing messages that are scheduled for deletion too far in the future for the buckets + var/list/datum/chatmessage/second_queue = list() + +/datum/controller/subsystem/runechat/PreInit() + bucket_list.len = BUCKET_LEN + head_offset = world.time + bucket_resolution = world.tick_lag + +/datum/controller/subsystem/runechat/stat_entry(msg) + ..("ActMsgs:[bucket_count] SecQueue:[length(second_queue)]") + +/datum/controller/subsystem/runechat/fire(resumed = FALSE) + // Store local references to datum vars as it is faster to access them this way + var/list/bucket_list = src.bucket_list + + if (MC_TICK_CHECK) + return + + // Check for when we need to loop the buckets, this occurs when + // the head_offset is approaching BUCKET_LEN ticks in the past + if (practical_offset > BUCKET_LEN) + head_offset += TICKS2DS(BUCKET_LEN) + practical_offset = 1 + resumed = FALSE + + // Store a reference to the 'working' chatmessage so that we can resume if the MC + // has us stop mid-way through processing + var/static/datum/chatmessage/cm + if (!resumed) + cm = null + + // Iterate through each bucket starting from the practical offset + while (practical_offset <= BUCKET_LEN && head_offset + ((practical_offset - 1) * world.tick_lag) <= world.time) + var/datum/chatmessage/bucket_head = bucket_list[practical_offset] + if (!cm || !bucket_head || cm == bucket_head) + bucket_head = bucket_list[practical_offset] + cm = bucket_head + + while (cm) + // If the chatmessage hasn't yet had its life ended then do that now + var/datum/chatmessage/next = cm.next + if (!cm.eol_complete) + cm.end_of_life() + else if (!QDELETED(cm)) // otherwise if we haven't deleted it yet, do so (this is after EOL completion) + qdel(cm) + + if (MC_TICK_CHECK) + return + + // Break once we've processed the entire bucket + cm = next + if (cm == bucket_head) + break + + // Empty the bucket, check if anything in the secondary queue should be shifted to this bucket + bucket_list[practical_offset++] = null + var/i = 0 + for (i in 1 to length(second_queue)) + cm = second_queue[i] + if (cm.scheduled_destruction >= BUCKET_LIMIT) + i-- + break + + // Transfer the message into the bucket, performing necessary circular doubly-linked list operations + bucket_count++ + var/bucket_pos = max(1, BUCKET_POS(cm.scheduled_destruction)) + var/datum/timedevent/head = bucket_list[bucket_pos] + if (!head) + bucket_list[bucket_pos] = cm + cm.next = null + cm.prev = null + continue + + if (!head.prev) + head.prev = head + cm.next = head + cm.prev = head.prev + cm.next.prev = cm + cm.prev.next = cm + if (i) + second_queue.Cut(1, i + 1) + cm = null + +/datum/controller/subsystem/runechat/Recover() + bucket_list |= SSrunechat.bucket_list + second_queue |= SSrunechat.second_queue + +/** + * Enters the runechat subsystem with this chatmessage, inserting it into the end-of-life queue + * + * This will also account for a chatmessage already being registered, and in which case + * the position will be updated to remove it from the previous location if necessary + * + * Arguments: + * * new_sched_destruction Optional, when provided is used to update an existing message with the new specified time + */ +/datum/chatmessage/proc/enter_subsystem(new_sched_destruction = 0) + // Get local references from subsystem as they are faster to access than the datum references + var/list/bucket_list = SSrunechat.bucket_list + var/list/second_queue = SSrunechat.second_queue + + // When necessary, de-list the chatmessage from its previous position + if (new_sched_destruction) + if (scheduled_destruction >= BUCKET_LIMIT) + second_queue -= src + else + SSrunechat.bucket_count-- + var/bucket_pos = BUCKET_POS(scheduled_destruction) + if (bucket_pos > 0) + var/datum/chatmessage/bucket_head = bucket_list[bucket_pos] + if (bucket_head == src) + bucket_list[bucket_pos] = next + if (prev != next) + prev.next = next + next.prev = prev + else + prev?.next = null + next?.prev = null + prev = next = null + scheduled_destruction = new_sched_destruction + + // Ensure the scheduled destruction time is properly bound to avoid missing a scheduled event + scheduled_destruction = max(CEILING(scheduled_destruction, world.tick_lag), world.time + world.tick_lag) + + // Handle insertion into the secondary queue if the required time is outside our tracked amounts + if (scheduled_destruction >= BUCKET_LIMIT) + BINARY_INSERT(src, SSrunechat.second_queue, datum/chatmessage, scheduled_destruction) + return + + // Get bucket position and a local reference to the datum var, it's faster to access this way + var/bucket_pos = BUCKET_POS(scheduled_destruction) + + // Get the bucket head for that bucket, increment the bucket count + var/datum/chatmessage/bucket_head = bucket_list[bucket_pos] + SSrunechat.bucket_count++ + + // If there is no existing head of this bucket, we can set this message to be that head + if (!bucket_head) + bucket_list[bucket_pos] = src + return + + // Otherwise it's a simple insertion into the circularly doubly-linked list + if (!bucket_head.prev) + bucket_head.prev = bucket_head + next = bucket_head + prev = bucket_head.prev + next.prev = src + prev.next = src + + +/** + * Removes this chatmessage datum from the runechat subsystem + */ +/datum/chatmessage/proc/leave_subsystem() + // Attempt to find the bucket that contains this chat message + var/bucket_pos = BUCKET_POS(scheduled_destruction) + + // Get local references to the subsystem's vars, faster than accessing on the datum + var/list/bucket_list = SSrunechat.bucket_list + var/list/second_queue = SSrunechat.second_queue + + // Attempt to get the head of the bucket + var/datum/chatmessage/bucket_head + if (bucket_pos > 0) + bucket_head = bucket_list[bucket_pos] + + // Decrement the number of messages in buckets if the message is + // the head of the bucket, or has a SD less than BUCKET_LIMIT implying it fits + // into an existing bucket, or is otherwise not present in the secondary queue + if(bucket_head == src) + bucket_list[bucket_pos] = next + SSrunechat.bucket_count-- + else if(scheduled_destruction < BUCKET_LIMIT) + SSrunechat.bucket_count-- + else + var/l = length(second_queue) + second_queue -= src + if(l == length(second_queue)) + SSrunechat.bucket_count-- + + // Remove the message from the bucket, ensuring to maintain + // the integrity of the bucket's list if relevant + if(prev != next) + prev.next = next + next.prev = prev + else + prev?.next = null + next?.prev = null + prev = next = null + +#undef BUCKET_LEN +#undef BUCKET_POS +#undef BUCKET_LIMIT diff --git a/code/datums/chatmessage.dm b/code/datums/chatmessage.dm new file mode 100644 index 00000000000..32fca2cb249 --- /dev/null +++ b/code/datums/chatmessage.dm @@ -0,0 +1,272 @@ +/// 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 +/// 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 96 +/// Max length of chat message in characters +#define CHAT_MESSAGE_MAX_LENGTH 110 +/// 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 +/// Macro from Lummox used to get height from a MeasureText proc +#define WXH_TO_HEIGHT(x) text2num(copytext(x, findtextEx(x, "x") + 1)) + +/** + * # Chat Message Overlay + * + * Datum for generating a message overlay on the map + */ +/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, 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 + /// Contains the reference to the next chatmessage in the bucket, used by runechat subsystem + var/datum/chatmessage/next + /// Contains the reference to the previous chatmessage in the bucket, used by runechat subsystem + var/datum/chatmessage/prev + +/** + * 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 + * * italics - Should we use italics or not + * * lifespan - The lifespan of the message in deciseconds + */ +/datum/chatmessage/New(text, atom/target, mob/owner, italics, size, 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/generate_image, text, target, owner, lifespan, italics, size) + +/datum/chatmessage/Destroy() + if (owned_by) + if (owned_by.seen_messages) + LAZYREMOVEASSOC(owned_by.seen_messages, message_loc, src) + owned_by.images.Remove(message) + owned_by = null + message_loc = null + message = null + leave_subsystem() + return ..() + +/** + * Calls qdel on the chatmessage when its parent is deleted, used to register qdel signal + */ +/datum/chatmessage/proc/on_parent_qdel() + 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 + * * radio_speech - Fancy shmancy radio icon represents that we use radio + * * lifespan - The lifespan of the message in deciseconds + * * italics - Just copy and paste, sir + */ +/datum/chatmessage/proc/generate_image(text, atom/target, mob/owner, lifespan, italics, size) + // Register client who owns this message + owned_by = owner.client + RegisterSignal(owned_by, COMSIG_PARENT_QDELETING, .proc/on_parent_qdel) + + // Clip message + var/maxlen = CHAT_MESSAGE_MAX_LENGTH + var/datum/html/split_holder/s = split_html(text) + if (length_char(s.inner_text) > maxlen) + var/chattext = copytext_char(s.inner_text, 1, maxlen + 1) + "..." + text = jointext(s.opening, "") + chattext + jointext(s.closing, "") + + // 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_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 + + var/output_color = sanitize_color(target.get_runechat_color()) // Get_runechat_color can be overriden on atoms to display a specific one (Example: Humans having their hair colour as runechat colour) + + // Approximate text height + var/static/regex/html_metachars = new(@"&[A-Za-z]{1,7};", "g") + var/complete_text = "[text]" + var/mheight = WXH_TO_HEIGHT(owned_by.MeasureText(complete_text, null, CHAT_MESSAGE_WIDTH)) + approx_lines = max(1, mheight / CHAT_MESSAGE_APPROX_LHEIGHT) + + // Translate any existing messages upwards, apply exponential decay factors to timers + message_loc = target + 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 + var/sched_remaining = m.scheduled_destruction - world.time + if (!m.eol_complete) + var/remaining_time = (sched_remaining) * (CHAT_MESSAGE_EXP_DECAY ** idx++) * (CHAT_MESSAGE_HEIGHT_DECAY ** combined_height) + m.enter_subsystem(world.time + remaining_time) // push updated time to runechat SS + + // 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++) + message.plane = GAME_PLANE + message.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA | KEEP_APART + message.alpha = 0 + message.pixel_y = owner.bound_height * 0.95 + message.maptext_width = CHAT_MESSAGE_WIDTH + message.maptext_height = mheight + message.maptext_x = (CHAT_MESSAGE_WIDTH - owner.bound_width) * -0.5 + message.maptext = complete_text + + // View the message + LAZYADDASSOC(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) + enter_subsystem() + +/** + * 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) + eol_complete = scheduled_destruction + fadetime + animate(message, alpha = 0, time = fadetime, flags = ANIMATION_PARALLEL) + enter_subsystem(eol_complete) // re-enter the runechat SS with the EOL completion time to QDEL self + +/** + * Creates a message overlay at a defined location for a given speaker + * + * Arguments: + * * speaker - The atom who is saying this message + * * raw_message - The text content of the message + * * italics - Vacuum and other things + * * size - Size of the message + */ +/mob/proc/create_chat_message(atom/movable/speaker, raw_message, italics=FALSE, size) + + if(isobserver(src)) + return + + + // Display visual above source + new /datum/chatmessage(raw_message, speaker, src, italics, size) + + +// Tweak these defines to change the available color ranges +#define CM_COLOR_SAT_MIN 0.6 +#define CM_COLOR_SAT_MAX 0.7 +#define CM_COLOR_LUM_MIN 0.65 +#define CM_COLOR_LUM_MAX 0.75 + +/** + * 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 + station_name()), 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 rgb + 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 "#[num2hex(c, 2)][num2hex(x, 2)][num2hex(m, 2)]" + if(1) + return "#[num2hex(x, 2)][num2hex(c, 2)][num2hex(m, 2)]" + if(2) + return "#[num2hex(m, 2)][num2hex(c, 2)][num2hex(x, 2)]" + if(3) + return "#[num2hex(m, 2)][num2hex(x, 2)][num2hex(c, 2)]" + if(4) + return "#[num2hex(x, 2)][num2hex(m, 2)][num2hex(c, 2)]" + if(5) + return "#[num2hex(c, 2)][num2hex(m, 2)][num2hex(x, 2)]" + + +/** + * Ensures a colour is bright enough for the system + * + * This proc is used to brighten parts of a colour up if its too dark, and looks bad + * + * Arguments: + * * hex - Hex colour to be brightened up + */ +/datum/chatmessage/proc/sanitize_color(color) + var/list/HSL = rgb2hsl(hex2num(copytext(color,2,4)),hex2num(copytext(color,4,6)),hex2num(copytext(color,6,8))) + HSL[3] = max(HSL[3],0.4) + var/list/RGB = hsl2rgb(arglist(HSL)) + return "#[num2hex(RGB[1],2)][num2hex(RGB[2],2)][num2hex(RGB[3],2)]" + +/** + * Proc to allow atoms to set their own runechat colour + * + * This is a proc designed to be overridden in places if you want a specific atom to use a specific runechat colour + * Exampls include consoles using a colour based on their screen colour, and mobs using a colour based off of a customisation property + * + */ +/atom/proc/get_runechat_color() + return chat_color diff --git a/code/game/atoms.dm b/code/game/atoms.dm index 73dcd14c7c8..5f1ef3e94c7 100644 --- a/code/game/atoms.dm +++ b/code/game/atoms.dm @@ -48,6 +48,11 @@ var/list/atom_colours //used to store the different colors on an atom //its inherent color, the colored paint applied on it, special color effect etc... + /// Last name used to calculate a color for the chatmessage overlays. Used for caching. + var/chat_color_name + /// Last color calculated for the the chatmessage overlays. Used for caching. + var/chat_color + /atom/New(loc, ...) if(GLOB.use_preloader && (src.type == GLOB._preloader.target_path))//in case the instanciated atom is creating other atoms in New() GLOB._preloader.load(src) @@ -847,6 +852,9 @@ GLOBAL_LIST_EMPTY(blood_splatter_icons) if(M.client) speech_bubble_hearers += M.client + if((M.client?.prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) && M.can_hear()) + M.create_chat_message(src, message) + if(length(speech_bubble_hearers)) var/image/I = image('icons/mob/talk.dmi', src, "[bubble_icon][say_test(message)]", FLY_LAYER) I.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA diff --git a/code/game/objects/items/devices/megaphone.dm b/code/game/objects/items/devices/megaphone.dm index c6fa7b20c66..5bc16ac2838 100644 --- a/code/game/objects/items/devices/megaphone.dm +++ b/code/game/objects/items/devices/megaphone.dm @@ -71,6 +71,10 @@ for(var/obj/O in oview(14, get_turf(src))) O.hear_talk(user, message_to_multilingual("[message]")) + for(var/mob/M in get_mobs_in_view(7, src)) + if((M.client?.prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) && M.can_hear()) + M.create_chat_message(user, message, FALSE, "big") + /obj/item/megaphone/emag_act(user as mob) if(!emagged) to_chat(user, "You overload \the [src]'s voice synthesizer.") diff --git a/code/modules/client/client_defines.dm b/code/modules/client/client_defines.dm index 46ec605e71d..6e98a285f7b 100644 --- a/code/modules/client/client_defines.dm +++ b/code/modules/client/client_defines.dm @@ -89,5 +89,8 @@ var/next_keysend_trip_reset = 0 var/keysend_tripped = FALSE + /// Messages currently seen by this client + var/list/seen_messages + // Last world.time that the player tried to request their resources. var/last_ui_resource_send = 0 diff --git a/code/modules/client/preference/preferences_toggles.dm b/code/modules/client/preference/preferences_toggles.dm index d14fbe84b46..44efef7fc84 100644 --- a/code/modules/client/preference/preferences_toggles.dm +++ b/code/modules/client/preference/preferences_toggles.dm @@ -305,3 +305,11 @@ set desc = "Silence the current admin midi playing" usr.stop_sound_channel(CHANNEL_ADMIN) to_chat(src, "The current admin midi has been silenced") + + +/client/verb/toggle_runechat() + set name = "Enable/Disable Runechat" + set category = "Preferences" + set desc = "Toggle runechat messages" + prefs.toggles2 ^= PREFTOGGLE_2_RUNECHAT + to_chat(src, "You will [(prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) ? "now see" : "no longer see"] floating chat messages.") diff --git a/code/modules/mob/hear_say.dm b/code/modules/mob/hear_say.dm index b053e70eab1..5608ffbedd2 100644 --- a/code/modules/mob/hear_say.dm +++ b/code/modules/mob/hear_say.dm @@ -3,7 +3,6 @@ /mob/proc/combine_message(var/list/message_pieces, var/verb, var/mob/speaker, always_stars = FALSE) var/iteration_count = 0 var/msg = "" // This is to make sure that the pieces have actually added something - . = "[verb], \"" for(var/datum/multilingual_say_piece/SP in message_pieces) iteration_count++ var/piece = SP.message @@ -44,8 +43,10 @@ // There is literally no content left in this message, we need to shut this shit down . = "" // hear_say will suppress it else - . = trim(. + trim(msg)) - . += "\"" + if(verb) + . = "[verb], \"[trim(msg)]\"" + else + . = trim(msg) /mob/proc/hear_say(list/message_pieces, verb = "says", italics = 0, mob/speaker = null, sound/speech_sound, sound_vol, sound_frequency, use_voice = TRUE) if(!client) @@ -78,10 +79,11 @@ var/mob/living/carbon/human/H = speaker speaker_name = H.GetVoice() - var/message = combine_message(message_pieces, verb, speaker) + var/message = combine_message(message_pieces, null, speaker) if(message == "") return + var/message_clean = message if(italics) message = "[message]" @@ -101,13 +103,18 @@ else to_chat(src, "[speaker.name] talks but you cannot hear [speaker.p_them()].") else - to_chat(src, "[speaker_name][use_voice ? speaker.GetAltName() : ""] [track][message]") + to_chat(src, "[speaker_name][speaker.GetAltName()] [track][verb], \"[message]\"") + + // Create map text message + if (client?.prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) // can_hear is checked up there on L99 + create_chat_message(speaker, message_clean, italics) + if(speech_sound && (get_dist(speaker, src) <= world.view && src.z == speaker.z)) var/turf/source = speaker? get_turf(speaker) : get_turf(src) playsound_local(source, speech_sound, sound_vol, 1, sound_frequency) -/mob/proc/hear_radio(list/message_pieces, verb = "says", part_a, part_b, mob/speaker = null, hard_to_hear = 0, vname = "", atom/follow_target) +/mob/proc/hear_radio(list/message_pieces, verb = "says", part_a, part_b, mob/speaker = null, hard_to_hear = 0, vname = "", atom/follow_target, radio_freq) if(!client) return @@ -171,7 +178,7 @@ to_chat(src, heard) -/mob/proc/hear_holopad_talk(list/message_pieces, verb = "says", mob/speaker = null) +/mob/proc/hear_holopad_talk(list/message_pieces, verb = "says", mob/speaker = null, obj/effect/overlay/holo_pad_hologram/H) if(sleeping || stat == UNCONSCIOUS) hear_sleep(multilingual_to_message(message_pieces)) return @@ -180,10 +187,14 @@ return var/message = combine_message(message_pieces, verb, speaker) + var/message_unverbed = combine_message(message_pieces, null, speaker) var/name = speaker.name if(!say_understands(speaker)) name = speaker.voice_name + if((client?.prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) && can_hear()) + create_chat_message(H, message_unverbed) + var/rendered = "[name] [message]" to_chat(src, rendered) diff --git a/code/modules/mob/living/carbon/human/human.dm b/code/modules/mob/living/carbon/human/human.dm index 62fc21ff968..942e4cc9a23 100644 --- a/code/modules/mob/living/carbon/human/human.dm +++ b/code/modules/mob/living/carbon/human/human.dm @@ -1974,3 +1974,11 @@ Eyes need to have significantly high darksight to shine unless the mob has the X /mob/living/carbon/human/proc/get_perceived_trauma() return min(health, maxHealth - getStaminaLoss()) + +/** + * Helper to get the mobs runechat colour span + * + * Basically just a quick redirect to the DNA handler that gets the species-specific colour handler + */ +/mob/living/carbon/human/get_runechat_color() + return dna.species.get_species_runechat_color(src) diff --git a/code/modules/mob/living/carbon/human/species/_species.dm b/code/modules/mob/living/carbon/human/species/_species.dm index d7969ac1879..6716c8f1a0f 100644 --- a/code/modules/mob/living/carbon/human/species/_species.dm +++ b/code/modules/mob/living/carbon/human/species/_species.dm @@ -860,3 +860,18 @@ It'll return null if the organ doesn't correspond, so include null checks when u var/obj/item/organ/internal/ears/ears = H.get_int_organ(/obj/item/organ/internal/ears) if(istype(ears) && !ears.deaf) . = TRUE + +/** + * Species-specific runechat colour handler + * + * Checks the species datum flags and returns the appropriate colour + * Can be overridden on subtypes to short-circuit these checks (Example: Grey colour is eye colour) + * Arguments: + * * H - The human who this DNA belongs to + */ +/datum/species/proc/get_species_runechat_color(mob/living/carbon/human/H) + if(bodyflags & HAS_SKIN_COLOR) + return H.skin_colour + else + var/obj/item/organ/external/head/HD = H.get_organ("head") + return HD.hair_colour diff --git a/code/modules/mob/living/carbon/human/species/drask.dm b/code/modules/mob/living/carbon/human/species/drask.dm index 16f0273df1b..e8c53708afb 100644 --- a/code/modules/mob/living/carbon/human/species/drask.dm +++ b/code/modules/mob/living/carbon/human/species/drask.dm @@ -61,3 +61,7 @@ "eyes" = /obj/item/organ/internal/eyes/drask, //5 darksight. "brain" = /obj/item/organ/internal/brain/drask ) + +/datum/species/drask/get_species_runechat_color(mob/living/carbon/human/H) + var/obj/item/organ/internal/eyes/E = H.get_int_organ(/obj/item/organ/internal/eyes) + return E.eye_colour diff --git a/code/modules/mob/living/carbon/human/species/grey.dm b/code/modules/mob/living/carbon/human/species/grey.dm index 790a7f75d3f..76acd899adb 100644 --- a/code/modules/mob/living/carbon/human/species/grey.dm +++ b/code/modules/mob/living/carbon/human/species/grey.dm @@ -88,3 +88,7 @@ H.adjustFireLoss(1) return TRUE return ..() + +/datum/species/grey/get_species_runechat_color(mob/living/carbon/human/H) + var/obj/item/organ/internal/eyes/E = H.get_int_organ(/obj/item/organ/internal/eyes) + return E.eye_colour diff --git a/code/modules/mob/living/carbon/human/species/kidan.dm b/code/modules/mob/living/carbon/human/species/kidan.dm index 8d684b36ba0..d5ba45fba03 100644 --- a/code/modules/mob/living/carbon/human/species/kidan.dm +++ b/code/modules/mob/living/carbon/human/species/kidan.dm @@ -40,3 +40,8 @@ "is cracking their exoskeleton!", "is stabbing themselves with their mandibles!", "is holding their breath!") + + +/datum/species/kidan/get_species_runechat_color(mob/living/carbon/human/H) + var/obj/item/organ/internal/eyes/E = H.get_int_organ(/obj/item/organ/internal/eyes) + return E.eye_colour diff --git a/code/modules/mob/living/silicon/ai/ai.dm b/code/modules/mob/living/silicon/ai/ai.dm index cebac878c6b..0586e1f950e 100644 --- a/code/modules/mob/living/silicon/ai/ai.dm +++ b/code/modules/mob/living/silicon/ai/ai.dm @@ -1268,6 +1268,9 @@ GLOBAL_LIST_INIT(ai_verbs_default, list( var/name_used = M.GetVoice() //This communication is imperfect because the holopad "filters" voices and is only designed to connect to the master only. var/rendered = "Relayed Speech: [name_used] [message]" + if(client?.prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) + var/message_clean = combine_message(message_pieces, null, M) + create_chat_message(M, message_clean) show_message(rendered, 2) /mob/living/silicon/ai/proc/malfhacked(obj/machinery/power/apc/apc) diff --git a/code/modules/mob/living/silicon/say.dm b/code/modules/mob/living/silicon/say.dm index f75475fa92d..d85d924fa86 100644 --- a/code/modules/mob/living/silicon/say.dm +++ b/code/modules/mob/living/silicon/say.dm @@ -75,8 +75,12 @@ var/obj/machinery/hologram/holopad/T = current if(istype(T) && T.masters[src]) + var/obj/effect/overlay/holo_pad_hologram/H = T.masters[src] + if ((client?.prefs.toggles2 & PREFTOGGLE_2_RUNECHAT) && can_hear()) + var/message = combine_message(message_pieces, null, src) + create_chat_message(H, message) for(var/mob/M in hearers(T.loc))//The location is the object, default distance. - M.hear_holopad_talk(message_pieces, verb, src) + M.hear_holopad_talk(message_pieces, verb, src, H) to_chat(src, "Holopad transmitted, [real_name] [combine_message(message_pieces, verb, src)]") else to_chat(src, "No holopad connected.") diff --git a/code/world.dm b/code/world.dm index c79588e7c6c..40f4268ef5a 100644 --- a/code/world.dm +++ b/code/world.dm @@ -7,3 +7,4 @@ area = /area/space view = "15x15" cache_lifespan = 0 //stops player uploaded stuff from being kept in the rsc past the current session + fps = 20 // If this isnt hard-defined, anything relying on this variable before world load will cry a lot diff --git a/interface/skin.dmf b/interface/skin.dmf index 5bca7b5de8b..dd05914c9df 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -1,16 +1,16 @@ macro "default" menu "menu" - elem + elem name = "&File" command = "" saved-params = "is-checked" - elem + elem name = "&Quick screenshot\tF2" command = ".screenshot auto" category = "&File" saved-params = "is-checked" - elem + elem name = "&Save screenshot as...\tShift+F2" command = ".screenshot" category = "&File" @@ -20,21 +20,21 @@ menu "menu" command = ".reconnect" category = "&File" saved-params = "is-checked" - elem + elem name = "" command = "" category = "&File" saved-params = "is-checked" - elem + elem name = "&Quit" command = ".quit" category = "&File" saved-params = "is-checked" - elem + elem name = "&Icons" command = "" saved-params = "is-checked" - elem + elem name = "&Size" command = "" category = "&Icons" @@ -82,7 +82,7 @@ menu "menu" can-check = true group = "size" saved-params = "is-checked" - elem + elem name = "&Scaling" command = "" category = "&Icons" @@ -114,16 +114,16 @@ menu "menu" category = "&Icons" can-check = true saved-params = "is-checked" - elem + elem name = "&Help" command = "" saved-params = "is-checked" - elem + elem name = "&Admin help\tF1" command = "adminhelp" category = "&Help" saved-params = "is-checked" - elem + elem name = "&Hotkeys" command = "Hotkey-Help" category = "&Help" @@ -222,6 +222,7 @@ window "mapwindow" text-color = none is-default = true saved-params = "icon-size" + style=".center { text-align: center; } .maptext { font-family: 'Small Fonts'; font-size: 7px; -dm-text-outline: 1px black; color: white; line-height: 1.1; } .small { font-size: 6px; } .big { font-size: 8px; } .reallybig { font-size: 8px; } .extremelybig { font-size: 8px; } .clown { color: #FF69Bf;} .tajaran {color: #803B56;} .skrell {color: #00CED1;} .solcom {color: #22228B;} .com_srus {color: #7c4848;} .zombie {color: #ff0000;} .soghun {color: #228B22;} .vox {color: #AA00AA;} .diona {color: #804000; font-weight: bold;} .trinary {color: #727272;} .kidan {color: #664205;} .slime {color: #0077AA;} .drask {color: #a3d4eb;} .vulpkanin {color: #B97A57;} .abductor {color: #800080; font-style: italic;} .his_grace { color: #15D512; } .hypnophrase { color: #0d0d0d; font-weight: bold; } .yell { font-weight: bold; }" window "outputwindow" elem "outputwindow" diff --git a/paradise.dme b/paradise.dme index 0c2973a5041..9732867adf5 100644 --- a/paradise.dme +++ b/paradise.dme @@ -240,6 +240,7 @@ #include "code\controllers\subsystem\overlays.dm" #include "code\controllers\subsystem\parallax.dm" #include "code\controllers\subsystem\radio.dm" +#include "code\controllers\subsystem\runechat.dm" #include "code\controllers\subsystem\shuttles.dm" #include "code\controllers\subsystem\sounds.dm" #include "code\controllers\subsystem\spacedrift.dm" @@ -266,6 +267,7 @@ #include "code\datums\beam.dm" #include "code\datums\browser.dm" #include "code\datums\callback.dm" +#include "code\datums\chatmessage.dm" #include "code\datums\click_intercept.dm" #include "code\datums\datacore.dm" #include "code\datums\datum.dm"