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, "
", "\[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"