From 11a9a2d644dae82ddaea0c2e20e3a5267168b4b6 Mon Sep 17 00:00:00 2001 From: lew <82828093+Llywelwyn@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:47:26 +0100 Subject: [PATCH] Multi-Languages: a complete Saycode rewrite (#22637) Rewrites Saycode and Langchat to add support for multiple languages in one message, including audible emotes. image Here's some samples to explain how to mix languages. image `Languages ,2 can be mixed ,3 like this.` image `!explains, ,2Emotes work too. ,0The text will get auto-quoted.` image `,3Most languages ,0can be ,2mixed ,1arbitrarily, ,3any number of ,0times.` image `,2Emotes. ,eAnd he nods. ,3They don't have to come first anymore.` There are some exceptions. The exceptions are currently anything with any of the flags `SIGNLANG`, `HIVEMIND`, `PRESSUREPROOF`, `KNOWONLYHEAR`. Exceptions work the same as current languages do: they must be the first language in the message. If so, they prevent switching into any other language mid-message; if they're not first, they just wont trigger. They're exceptions currently because there's not really a clean way that I or the people I asked for help on this one to make them look nice. `SIGNLANG` for example doesn't scramble text, it shows it's own ` gestures a lengthy message.` text for those that don't understand. Could we just replace every instance of sign language with that if somebody doesn't understand? Probably. It would look pretty awful though. e.g. `Alina Eskelinen says, "Hello." Alina Eskelinen gestures a short message.` This definitely needs testmerging because it more or less rewrites the entire pipeline surrounding `say`. The `say` code itself had to be rewritten to support the multiple languages, as well as all the existing plumbing for listeners receiving messages. In return though, it's significantly more straightforward and hopefully by extension easier for people to add to in the future. Primarily, instead of having four different `hear_say`, `hear_radio`, `hear_sleep`, etc., routes for messages to come through, every single audible message is received by `hear_message`, which is responsible for figuring out how clear the message is (is the radio damaged? is it a whisper we're eavesdropping on?), who needs to receive it in their chatbox, formatting it correctly for each listener, and finally if any npc or object within range needs to react to it in some way, like a parrot or a mech. changelog: - rscadd: "Adds code-switching: you can now speak in multiple languages in the same message." - rscadd: "Adds audible emotes to the language list. They can be triggered with ,e." - rscdel: "Removes SSrunechat." - refactor: "Rewrote langchat in order to support multiple languages and partial comprehension." - refactor: "Rewrote a vast majority of all saycode and the code responsible for displaying saytext to clients." - bugfix: "Sleeping mobs are no longer able to understand all languages." - bugfix: "Langchat now correctly shows the appropriate comprehension for all viewers rather than all viewers sharing the comprehension of the last viewer." - bugfix: "Languages which are supposed to be invisible when not understood no longer appear as scrambled overhead text." Forgive me whoever has to review this. Biggest areas that have room for error is stuff like a borer inside someone's head, and Dionae stuff. Old langchat had odd exceptions for those and I was forced to rewrite it entirely, but I think I got it all back to how it was working before. --- aurorastation.dme | 5 +- code/__DEFINES/species_languages.dm | 14 + code/__DEFINES/subsystems.dm | 1 - code/controllers/subsystems/runechat.dm | 15 - code/datums/langchat/langchat.dm | 338 ++++++------------ code/game/atom/_atom.dm | 16 +- .../changeling/implements/hivemind.dm | 10 +- code/game/objects/items.dm | 2 +- .../game/objects/items/devices/radio/radio.dm | 6 +- code/game/objects/objs.dm | 2 +- .../crates_lockers/closets/statue.dm | 2 +- .../machinery/telecomms/broadcasting.dm | 22 +- code/modules/emotes/emote_mob.dm | 2 +- code/modules/hallucinations/mob.dm | 20 +- .../modules/heavy_vehicle/mech_interaction.dm | 5 +- .../mob/abstract/ghost/observer/say.dm | 8 +- .../abstract/ghost/storyteller/storyteller.dm | 6 +- .../mob/abstract/new_player/new_player.dm | 9 +- code/modules/mob/hear_say.dm | 313 ---------------- code/modules/mob/language/generic.dm | 9 +- code/modules/mob/language/language.dm | 23 +- code/modules/mob/living/bot/bot.dm | 4 +- code/modules/mob/living/carbon/brain/say.dm | 18 +- code/modules/mob/living/carbon/human/say.dm | 28 +- code/modules/mob/living/carbon/slime/say.dm | 26 +- code/modules/mob/living/say.dm | 249 +++++++------ code/modules/mob/living/silicon/pai/say.dm | 2 +- code/modules/mob/living/silicon/say.dm | 58 +-- .../simple_animal/borer/borer_captive.dm | 14 +- .../mob/living/simple_animal/borer/say.dm | 30 +- .../simple_animal/friendly/spiderbot.dm | 14 +- .../hostile/commanded/commanded.dm | 15 +- .../living/simple_animal/hostile/toy/mech.dm | 5 +- .../mob/living/simple_animal/parrot.dm | 35 +- .../modules/mob/living/simple_animal/shade.dm | 15 +- .../mob/living/simple_animal/simple_animal.dm | 4 +- code/modules/mob/living/whisper.dm | 132 +++++-- code/modules/mob/say.dm | 85 ++++- code/modules/mob/say_message/hear_message.dm | 291 +++++++++++++++ code/modules/mob/say_message/say_message.dm | 187 ++++++++++ code/modules/mob/say_message/say_segment.dm | 25 ++ code/modules/shareddream/brainghost.dm | 6 +- code/unit_tests/mob_tests.dm | 9 +- html/changelogs/lewis-saycode.yml | 13 + .../quarantined_outpost_holopads.dm | 8 +- .../quarantined_outpost_objects.dm | 8 +- 46 files changed, 1185 insertions(+), 924 deletions(-) delete mode 100644 code/controllers/subsystems/runechat.dm delete mode 100644 code/modules/mob/hear_say.dm create mode 100644 code/modules/mob/say_message/hear_message.dm create mode 100644 code/modules/mob/say_message/say_message.dm create mode 100644 code/modules/mob/say_message/say_segment.dm create mode 100644 html/changelogs/lewis-saycode.yml diff --git a/aurorastation.dme b/aurorastation.dme index 96147b23f3c..ad06d52c1e1 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -393,7 +393,6 @@ #include "code\controllers\subsystems\radio.dm" #include "code\controllers\subsystems\records.dm" #include "code\controllers\subsystems\responseteam.dm" -#include "code\controllers\subsystems\runechat.dm" #include "code\controllers\subsystems\sentry.dm" #include "code\controllers\subsystems\skills.dm" #include "code\controllers\subsystems\skybox.dm" @@ -2713,7 +2712,6 @@ #include "code\modules\mob\emote.dm" #include "code\modules\mob\examinations.dm" #include "code\modules\mob\gender.dm" -#include "code\modules\mob\hear_say.dm" #include "code\modules\mob\holder.dm" #include "code\modules\mob\inventory.dm" #include "code\modules\mob\login.dm" @@ -3049,6 +3047,9 @@ #include "code\modules\mob\living\simple_animal\hostile\retaliate\retaliate.dm" #include "code\modules\mob\living\simple_animal\hostile\toy\mech.dm" #include "code\modules\mob\living\simple_animal\mechanical\mechanical.dm" +#include "code\modules\mob\say_message\hear_message.dm" +#include "code\modules\mob\say_message\say_message.dm" +#include "code\modules\mob\say_message\say_segment.dm" #include "code\modules\modular_computers\_description.dm" #include "code\modules\modular_computers\laptop_vendor.dm" #include "code\modules\modular_computers\computers\modular_computer\core.dm" diff --git a/code/__DEFINES/species_languages.dm b/code/__DEFINES/species_languages.dm index 52da8480ae0..e2236cc519b 100644 --- a/code/__DEFINES/species_languages.dm +++ b/code/__DEFINES/species_languages.dm @@ -102,6 +102,20 @@ #define PRESSUREPROOF BITFLAG(11) // Pressure doesn't affect hearing #define PASSLISTENOBJ BITFLAG(12) // Listening Objs can't hear this language +// These language flags do not support multiple languages in one message. +#define LANG_NO_MULTILANG (SIGNLANG | HIVEMIND | PRESSUREPROOF | KNOWONLYHEAR) + +// Clarity levels: how clearly one listener perceives one message (the acoustic/sight axis). +#define CLARITY_NONE 0 // not perceived (deaf / vacuum / blind-sign) +#define CLARITY_FAINT 1 // body stars()-ed (radio static, whisper eavesdrop) +#define CLARITY_DROWSY 2 // word-sampled "you almost hear..." (asleep) +#define CLARITY_CLEAR 3 // verbatim (subject to comprehension only) + +// Delivery mode: how the message reached the listener. Selects the envelope. +#define SAYMODE_SPOKEN 1 +#define SAYMODE_RADIO 2 +#define SAYMODE_SIGN 3 + // Autohiss #define AUTOHISS_OFF 0 #define AUTOHISS_BASIC 1 diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 9a3bb4d4ce8..cc1287c9d02 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -280,7 +280,6 @@ #define FIRE_PRIORITY_TGUI 110 #define FIRE_PRIORITY_STATPANEL 390 #define FIRE_PRIORITY_CHAT 400 -#define FIRE_PRIORITY_RUNECHAT 410 #define FIRE_PRIORITY_TIMER 700 #define FIRE_PRIORITY_SOUND_LOOPS 800 #define FIRE_PRIORITY_SPEECH_CONTROLLER 900 diff --git a/code/controllers/subsystems/runechat.dm b/code/controllers/subsystems/runechat.dm deleted file mode 100644 index 8b99a635326..00000000000 --- a/code/controllers/subsystems/runechat.dm +++ /dev/null @@ -1,15 +0,0 @@ -TIMER_SUBSYSTEM_DEF(runechat) - name = "Runechat" - priority = FIRE_PRIORITY_RUNECHAT - - var/list/datum/callback/message_queue = list() - -/datum/controller/subsystem/timer/runechat/fire(resumed) - CAN_BE_REDEFINED(TRUE) - . = ..() //poggers - while(message_queue.len) - var/datum/callback/queued_message = message_queue[message_queue.len] - queued_message.Invoke() - message_queue.len-- - if(MC_TICK_CHECK) - return diff --git a/code/datums/langchat/langchat.dm b/code/datums/langchat/langchat.dm index 12e231581af..45ddd09b561 100644 --- a/code/datums/langchat/langchat.dm +++ b/code/datums/langchat/langchat.dm @@ -1,285 +1,183 @@ -/atom/var/langchat_height = 32 // abovetile usually -/atom/var/langchat_color = "#FFFFFF" -/atom/var/langchat_styles = "" +/// Returns whether langchat is enabled for a given mob. +#define LANGCHAT_CLIENT_ENABLED(M) (M && M.client && M.client.prefs && (M.client.prefs.toggles_secondary & FLOATING_MESSAGES)) #define LANGCHAT_LONGEST_TEXT 64 #define LANGCHAT_WIDTH 96 #define LANGCHAT_MAX_ALPHA 196 -//pop defines -#define LANGCHAT_DEFAULT_POP 0 //normal message -#define LANGCHAT_PANIC_POP 1 //this causes shaking -#define LANGCHAT_FAST_POP 2 //this just makes it go away faster +// Types of message pop +#define LANGCHAT_DEFAULT_POP 0 // Normal messages. +#define LANGCHAT_PANIC_POP 1 // Fast, shaking messages. +#define LANGCHAT_FAST_POP 2 // Fast messages. -// params for default pop +// Parameters for LANGCHAT_DEFAULT_POP #define LANGCHAT_MESSAGE_POP_TIME 3 #define LANGCHAT_MESSAGE_POP_Y_SINK 8 -// params for panic pop +// Parameters for LANGCHAT_PANIC_POP #define LANGCHAT_MESSAGE_PANIC_POP_TIME 1 #define LANGCHAT_MESSAGE_PANIC_POP_Y_SINK 8 #define LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE 6 #define LANGCHAT_MESSAGE_PANIC_SHAKE_TIMES 6 #define LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN 1 -// params for fast pop + +// Parameters for LANGCHAT_FAST_POP #define LANGCHAT_MESSAGE_FAST_POP_TIME 1 #define LANGCHAT_MESSAGE_FAST_POP_Y_SINK 8 -#define langchat_client_enabled(M) (M && M.client && M.client.prefs && (M.client.prefs.toggles_secondary & FLOATING_MESSAGES)) +/atom/var/langchat_height = 32 // Abovetile usually +/atom/var/langchat_color = "#FFFFFF" +/atom/var/langchat_styles = "" -/// Hides the images, if they exist. Do not null the langchat images; they are rotated when the mob is buckled or proned to maintain text orientation. +/// A single overhead bubble storing the text image, and the listeners it was shown to. +/datum/langchat_bubble + var/image/bubble + var/list/mob/listeners + +/// Initializes a new bubble from a text image and a list of listeners. +/datum/langchat_bubble/New(image/bubble, list/mob/listeners) + . = ..() + src.bubble = bubble + src.listeners = listeners + +/// The floating bubbles currently shown for this atom. +/atom/var/list/langchat_images + +/// Drops all active bubbles for this atom. /atom/proc/langchat_drop_images() - if(langchat_listeners) - for(var/mob/M in langchat_listeners) - if(M.client) - M.client.images -= langchat_image - for(var/mob/M in langchat_listeners_untranslated) - if(M.client) - M.client.images -= langchat_image_untranslated - langchat_listeners = null - langchat_listeners_untranslated = null + for(var/datum/langchat_bubble/entry as anything in langchat_images) + for(var/mob/listener as anything in entry.listeners) + if(listener.client) + listener.client.images -= entry.bubble + langchat_images = null -/atom/proc/get_maxptext_x_offset(image/maptext_image) +/atom/proc/get_maptext_x_offset(image/maptext_image) return (world.icon_size / 2) - (maptext_image.maptext_width / 2) -/atom/movable/get_maxptext_x_offset(image/maptext_image) +/atom/movable/get_maptext_x_offset(image/maptext_image) return (bound_width / 2) - (maptext_image.maptext_width / 2) -/mob/get_maxptext_x_offset(image/maptext_image) +/mob/get_maptext_x_offset(image/maptext_image) return (icon_size / 2) - (maptext_image.maptext_width / 2) -/// Creates the image if it doesn't exist, resets settings that are modified by speech procs. -/atom/proc/langchat_make_image(override_color = null) - if(!langchat_image) - langchat_image = image(null, src) - langchat_image.layer = 20 - langchat_image.plane = RUNECHAT_PLANE - langchat_image.appearance_flags = NO_CLIENT_COLOR|KEEP_APART|RESET_COLOR|RESET_TRANSFORM - langchat_image.maptext_y = langchat_height - langchat_image.maptext_height = 64 - langchat_image.maptext_y -= LANGCHAT_MESSAGE_POP_Y_SINK - langchat_image.maptext_x = get_maxptext_x_offset(langchat_image) +/// Constructs a single floating langchat bubble showing maptext_string to the given listeners. +/atom/proc/langchat_build_image(maptext_string, list/mob/listeners, override_color, animation_style = LANGCHAT_DEFAULT_POP, list/additional_styles = list("langchat")) + if(!length(listeners)) + return - langchat_image.pixel_y = 0 - langchat_image.alpha = 0 - langchat_image.color = override_color ? override_color : langchat_color + var/image/bubble = image(null, src) + bubble.layer = 20 + bubble.plane = RUNECHAT_PLANE + bubble.appearance_flags = NO_CLIENT_COLOR|KEEP_APART|RESET_COLOR|RESET_TRANSFORM + bubble.maptext_y = langchat_height - LANGCHAT_MESSAGE_POP_Y_SINK + bubble.maptext_height = 64 + bubble.alpha = 0 + bubble.color = override_color ? override_color : langchat_color if(appearance_flags & PIXEL_SCALE) - langchat_image.appearance_flags |= PIXEL_SCALE + bubble.appearance_flags |= PIXEL_SCALE + langchat_configure_bubble(bubble, override_color) -/mob/langchat_make_image(override_color = null) - var/new_image = FALSE - if(!langchat_image) - new_image = TRUE - . = ..() - // Recenter for icons more than 32 wide - if(new_image) - langchat_image.maptext_x += (icon_size - 32) / 2 + bubble.maptext = generate_text_image(maptext_string, additional_styles) + bubble.maptext_width = LANGCHAT_WIDTH + bubble.maptext_x = get_maptext_x_offset(bubble) + bubble.loc = isturf(loc) ? src : recursive_holder_check(src) -/// Creates the (untranslated) image if it doesn't exist, resets settings that are modified by speech procs. -/atom/proc/langchat_make_image_untranslated(override_color = null) - if(!langchat_image_untranslated) - langchat_image_untranslated = image(null, src) - langchat_image_untranslated.layer = 20 - langchat_image_untranslated.plane = RUNECHAT_PLANE - langchat_image_untranslated.appearance_flags = NO_CLIENT_COLOR|KEEP_APART|RESET_COLOR|RESET_TRANSFORM - langchat_image_untranslated.maptext_y = langchat_height - langchat_image_untranslated.maptext_height = 64 - langchat_image_untranslated.maptext_y -= LANGCHAT_MESSAGE_POP_Y_SINK - langchat_image_untranslated.maptext_x = get_maxptext_x_offset(langchat_image_untranslated) + for(var/mob/listener as anything in listeners) + listener.client.images += bubble - langchat_image_untranslated.pixel_y = 0 - langchat_image_untranslated.alpha = 0 - langchat_image_untranslated.color = override_color ? override_color : langchat_color - if(appearance_flags & PIXEL_SCALE) - langchat_image_untranslated.appearance_flags |= PIXEL_SCALE + animate_style(bubble, animation_style) + LAZYADD(langchat_images, new /datum/langchat_bubble(bubble, listeners)) -/mob/langchat_make_image_untranslated(override_color = null) - var/new_image = FALSE - if(!langchat_image_untranslated) - new_image = TRUE - . = ..() - // Recenter for icons more than 32 wide - if(new_image) - langchat_image_untranslated.maptext_x += (icon_size - 32) / 2 +/// Per-type appearance tweaks applied to a freshly built bubble. +/atom/proc/langchat_configure_bubble(image/bubble, override_color) + return -/mob/abstract/ghost/langchat_make_image(override_color = null) +/mob/abstract/ghost/langchat_configure_bubble(image/bubble, override_color) if(!override_color) - override_color = "#c51fb7" - . = ..() - langchat_image.appearance_flags |= RESET_ALPHA + bubble.color = "#c51fb7" + bubble.appearance_flags |= RESET_ALPHA -/atom/proc/langchat_speech(message, list/listeners, datum/language/language, override_color, skip_language_check = FALSE, animation_style = LANGCHAT_DEFAULT_POP, list/additional_styles = list("langchat")) +/// Floats flat text to everybody who can see it. +/atom/proc/langchat_speech(message, list/mob/listeners, override_color, animation_style = LANGCHAT_DEFAULT_POP, list/additional_styles = list("langchat")) langchat_drop_images() - langchat_make_image(override_color) - langchat_make_image_untranslated(override_color) - langchat_listeners = listeners - langchat_listeners_untranslated = list() - var/mob/listener - // Listener list management. - for(listener in langchat_listeners) - // Remove those who have the langchat_client disabled or who are deaf. - if(!langchat_client_enabled(listener) || listener.ear_deaf) - langchat_listeners -= listener + var/list/mob/shown = list() + for(var/mob/listener in listeners) + if(!LANGCHAT_CLIENT_ENABLED(listener) || listener.ear_deaf) continue - // Handle listeners who don't understand the language being spoken. - if(!skip_language_check && !listener.say_understands(src, language)) - langchat_listeners_untranslated += listener - langchat_listeners -= listener + shown += listener + if(!length(shown)) + return - // Generate the translated langchat_image. - langchat_image.maptext = generate_text_image(message, additional_styles = additional_styles) - langchat_image.maptext_width = LANGCHAT_WIDTH - langchat_image.maptext_x = get_maxptext_x_offset(langchat_image) - for(var/mob/comprehending_listener in langchat_listeners) - comprehending_listener.client.images += langchat_image - - // Generate the untranslated langchat_image. Note that we have to loop through confused listeners first here, as some - // might know languages that confer partial comprehension, and that would result in a unique langchat image. - for(var/mob/confused_listener in langchat_listeners_untranslated) - langchat_image_untranslated.maptext = generate_text_image(message, language, additional_styles, confused_listener.languages) - langchat_image_untranslated.maptext_width = LANGCHAT_WIDTH - langchat_image_untranslated.maptext_x = get_maxptext_x_offset(langchat_image_untranslated) - confused_listener.client.images += langchat_image_untranslated + langchat_build_image(message, shown, override_color, animation_style, additional_styles) var/timer = (length(message) / LANGCHAT_LONGEST_TEXT) * 4 SECONDS + 2 SECONDS + addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, langchat_drop_images)), timer, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_NO_HASH_WAIT) - if(isturf(loc)) - langchat_image.loc = src - if(langchat_image_untranslated) - langchat_image_untranslated.loc = src - else - langchat_image.loc = recursive_holder_check(src) - if(langchat_image_untranslated) - langchat_image_untranslated.loc = recursive_holder_check(src) - - animate_style(langchat_image, animation_style) - if(langchat_listeners_untranslated) - animate_style(langchat_image_untranslated, animation_style) - - addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, langchat_drop_images), language), timer, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_NO_HASH_WAIT) - -/atom/proc/langchat_long_speech(message, list/listeners, datum/language/language, override_color, skip_language_check = FALSE) +/// Floats a say message to every listener in view. Each listener receives their own perceived text based on +/// their comprehension of the spoken languages. Identical text is grouped into a single bubble for performance. +/atom/proc/langchat_say_message(datum/say_message/msg, list/mob/listeners, override_color, animation_style = LANGCHAT_DEFAULT_POP, list/additional_styles = list("langchat")) langchat_drop_images() - langchat_make_image() - langchat_make_image_untranslated(override_color) - var/text_left = null - var/truncated_message = message + // Group listeners by exact text perceived. + var/list/groups = list() + for(var/mob/listener in listeners) + if(!LANGCHAT_CLIENT_ENABLED(listener) || listener.ear_deaf) + continue + var/perceived = msg.plain_text_for(listener) + if(!length(perceived)) + continue + var/list/group = groups[perceived] + if(!group) + group = list() + groups[perceived] = group + group += listener + if(!length(groups)) + return - langchat_listeners = listeners - var/mob/listener - // Listener list management. - for(listener in langchat_listeners) - // Remove those who have the langchat_client disabled or who are deaf. - if(!langchat_client_enabled(listener) || listener.ear_deaf) - langchat_listeners -= listener - // Handle listeners who don't understand the language being spoken. - if(!skip_language_check && !listener.say_understands(src, language)) - langchat_listeners_untranslated += listener - langchat_listeners -= listener + for(var/perceived in groups) + langchat_build_image(perceived, groups[perceived], override_color, animation_style, additional_styles) - if(length(message) > LANGCHAT_LONGEST_TEXT) - truncated_message = copytext_char(message, 1, LANGCHAT_LONGEST_TEXT - 5) + "..." - text_left = "..." + copytext_char(message, LANGCHAT_LONGEST_TEXT - 5) - var/timer = 6 SECONDS - if(text_left) - timer = 4 SECONDS - truncated_message = "[truncated_message]" + // One-off timer for duration. Disappears for all listeners at the same time. + var/timer = (length(msg.to_string()) / LANGCHAT_LONGEST_TEXT) * 4 SECONDS + 2 SECONDS + addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, langchat_drop_images)), timer, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_NO_HASH_WAIT) - // Generate the translated langchat_image. - langchat_image.maptext = generate_text_image(truncated_message) - langchat_image.maptext_width = LANGCHAT_WIDTH - langchat_image.maptext_x = get_maxptext_x_offset(langchat_image) - for(var/mob/comprehending_listener in langchat_listeners) - comprehending_listener.client.images += langchat_image - - // Generate the untranslated langchat_image. Note that we have to loop through confused listeners first here, as some - // might know languages that confer partial comprehension, and that would result in a unique langchat image. - for(var/mob/confused_listener in langchat_listeners_untranslated) - langchat_image_untranslated.maptext = confused_listener.generate_text_image(truncated_message, src) - langchat_image_untranslated.maptext_width = LANGCHAT_WIDTH - langchat_image_untranslated.maptext_x = get_maxptext_x_offset(langchat_image_untranslated) - confused_listener.client.images += langchat_image_untranslated - - if(isturf(loc)) - langchat_image.loc = src - if(langchat_image_untranslated) - langchat_image_untranslated.loc = src - else - langchat_image.loc = recursive_holder_check(src) - if(langchat_image_untranslated) - langchat_image_untranslated.loc = recursive_holder_check(src) - - animate_style(langchat_image) - if(langchat_listeners_untranslated) - animate_style(langchat_image_untranslated) - - if(text_left) - addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, langchat_long_speech), text_left, listeners, language), timer, TIMER_OVERRIDE|TIMER_UNIQUE|TIMER_NO_HASH_WAIT) - else - addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, langchat_drop_images), language), timer, TIMER_OVERRIDE|TIMER_UNIQUE|TIMER_NO_HASH_WAIT) - -/** - * Generates the maptext_image, translated or untranslated, for a given message. - */ -/atom/proc/generate_text_image(message, datum/language/language = null, list/additional_styles = list("langchat"), list/languages = null) +/// Builds the maptext span for a floating message. +/atom/proc/generate_text_image(message, list/additional_styles = list("langchat")) + var/list/styles = additional_styles ? additional_styles.Copy() : list() var/text_to_display = message var/use_mob_style = TRUE - var/image/r_icon - - if(language) - message = language.scramble(message, languages) - text_to_display = message if(length(text_to_display) > LANGCHAT_LONGEST_TEXT) text_to_display = copytext_char(text_to_display, 1, LANGCHAT_LONGEST_TEXT + 1) + "..." - if(additional_styles.Find("emote")) - additional_styles.Remove("emote") + if(styles.Find("emote")) + styles.Remove("emote") use_mob_style = FALSE - r_icon = image('icons/mob/chat_icons.dmi', icon_state = "emote") - else if(additional_styles.Find("virtual-speaker")) - additional_styles.Remove("virtual-speaker") - r_icon = image('icons/mob/chat_icons.dmi', icon_state = "radio") - if(r_icon) + var/image/r_icon = image('icons/mob/chat_icons.dmi', icon_state = "emote") text_to_display = "\icon[r_icon]&zwsp;[text_to_display]" - text_to_display = "[text_to_display]" - return text_to_display + return "[text_to_display]" -/** - * Animate the given maptext_image. - */ -/atom/proc/animate_style(var/image/langchat_image, animation_style = LANGCHAT_DEFAULT_POP) - if(langchat_image) +/// Animates the given maptext image. +/atom/proc/animate_style(var/image/bubble, animation_style = LANGCHAT_DEFAULT_POP) + if(bubble) switch(animation_style) if(LANGCHAT_DEFAULT_POP) - langchat_image.alpha = 0 - animate(langchat_image, pixel_y = langchat_image.pixel_y + LANGCHAT_MESSAGE_POP_Y_SINK, alpha = LANGCHAT_MAX_ALPHA, time = LANGCHAT_MESSAGE_POP_TIME) + bubble.alpha = 0 + animate(bubble, pixel_y = bubble.pixel_y + LANGCHAT_MESSAGE_POP_Y_SINK, alpha = LANGCHAT_MAX_ALPHA, time = LANGCHAT_MESSAGE_POP_TIME) if(LANGCHAT_PANIC_POP) - langchat_image.alpha = LANGCHAT_MAX_ALPHA - animate(langchat_image, pixel_y = langchat_image.pixel_y + LANGCHAT_MESSAGE_PANIC_POP_Y_SINK, time = LANGCHAT_MESSAGE_PANIC_POP_TIME) - animate(pixel_x = langchat_image.pixel_x - LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) + bubble.alpha = LANGCHAT_MAX_ALPHA + animate(bubble, pixel_y = bubble.pixel_y + LANGCHAT_MESSAGE_PANIC_POP_Y_SINK, time = LANGCHAT_MESSAGE_PANIC_POP_TIME) + animate(pixel_x = bubble.pixel_x - LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) for(var/i = 1 to LANGCHAT_MESSAGE_PANIC_SHAKE_TIMES) - animate(pixel_x = langchat_image.pixel_x + 2*LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = 2*LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) - animate(pixel_x = langchat_image.pixel_x - 2*LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) - animate(pixel_x = langchat_image.pixel_x + LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) + animate(pixel_x = bubble.pixel_x + 2*LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = 2*LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) + animate(pixel_x = bubble.pixel_x - 2*LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) + animate(pixel_x = bubble.pixel_x + LANGCHAT_MESSAGE_PANIC_SHAKE_SIZE, time = LANGCHAT_MESSAGE_PANIC_SHAKE_TIME_TAKEN, easing = CUBIC_EASING) if(LANGCHAT_FAST_POP) - langchat_image.alpha = 0 - animate(langchat_image, pixel_y = langchat_image.pixel_y + LANGCHAT_MESSAGE_FAST_POP_Y_SINK, alpha = LANGCHAT_MAX_ALPHA, time = LANGCHAT_MESSAGE_FAST_POP_TIME) + bubble.alpha = 0 + animate(bubble, pixel_y = bubble.pixel_y + LANGCHAT_MESSAGE_FAST_POP_Y_SINK, alpha = LANGCHAT_MAX_ALPHA, time = LANGCHAT_MESSAGE_FAST_POP_TIME) -/** Displays image to a single listener after it was built above eg. for chaining different game logic than speech code -This does just that, doesn't check deafness or language! Do what you will in that regard **/ -/atom/proc/langchat_display_image(mob/M) - if(langchat_image) - if(!langchat_client_enabled(M)) - return - if(!langchat_listeners) // shouldn't happen - langchat_listeners = list() - langchat_listeners |= M - M.client.images += langchat_image - -/// Will attempt to find what's holding this item if it's being contained by something, ie if it's in a satchel held by a human, this'll return the human +/// Attempts to return what's holding this item if being contained by something. e.g. Cortical borer returns their host. /proc/recursive_holder_check(obj/item/held_item, recursion_limit = 3) if(recursion_limit <= 0) return held_item diff --git a/code/game/atom/_atom.dm b/code/game/atom/_atom.dm index a5237effb0f..f859c948744 100644 --- a/code/game/atom/_atom.dm +++ b/code/game/atom/_atom.dm @@ -141,17 +141,6 @@ /// The mob currently interacting with the atom during a `do_after` timer. Used to validate `DO_TARGET_UNIQUE_ACT` flag checks. var/mob/do_unique_target_user - /* - * Duplicate vars and logic created for untranslated images for the sake of getting an untranslated langchat to display for listeners who do not understand - * the language being spoken. Someone could certainly think of cleaner ways to do this, but for want of a better solution right now, it has been implemented - * in this rote manner to make it easier to strip out in future if it needs replaced. - */ - - var/image/langchat_image - var/image/langchat_image_untranslated - var/list/mob/langchat_listeners - var/list/mob/langchat_listeners_untranslated - /atom/Destroy(force) if(opacity) updateVisibility(src) @@ -180,10 +169,7 @@ // The component is attached to us normaly and will be deleted elsewhere orbiters = null do_unique_target_user = null - QDEL_NULL(langchat_image) - QDEL_NULL(langchat_image_untranslated) - langchat_listeners?.Cut() - langchat_listeners_untranslated?.Cut() + langchat_drop_images() return ..() /atom/proc/handle_ricochet(obj/projectile/ricocheting_projectile) diff --git a/code/game/gamemodes/changeling/implements/hivemind.dm b/code/game/gamemodes/changeling/implements/hivemind.dm index 703fa5117aa..472a3f94d91 100644 --- a/code/game/gamemodes/changeling/implements/hivemind.dm +++ b/code/game/gamemodes/changeling/implements/hivemind.dm @@ -39,18 +39,18 @@ to_chat(src, SPAN_DANGER("You can use 'say' to speak with them and the rest of the hivemind.")) to_chat(src, SPAN_DANGER("What you say can only be heard by [ling] and the other members of their local hivemind.")) -/mob/abstract/hivemind/say(message) - message = sanitize_text(message) +/mob/abstract/hivemind/say(text) + text = sanitize_text(text) - if(!message) + if(!text) return - log_say("[changeling_mob] Hivemind/[src.key] : [message]") + log_say("[changeling_mob] Hivemind/[src.key] : [text]") if(src.client?.prefs.muted & (MUTE_DEADCHAT|MUTE_IC)) to_chat(src, SPAN_WARNING("You cannot talk. (Admin Muted)")) return - relay_hivemind(changeling_message_process(message), changeling_mob) + relay_hivemind(changeling_message_process(text), changeling_mob) /mob/abstract/hivemind/emote() to_chat(src, SPAN_WARNING("You cannot emote.")) diff --git a/code/game/objects/items.dm b/code/game/objects/items.dm index a7eec14b0ab..664ad28c2d4 100644 --- a/code/game/objects/items.dm +++ b/code/game/objects/items.dm @@ -938,7 +938,7 @@ GLOBAL_LIST_INIT(slot_flags_enumeration, list( /obj/item/proc/showoff(mob/user) var/list/viewers = get_hearers_in_view(world.view, src) - user.langchat_speech("holds up [src].", viewers, GLOB.all_languages, skip_language_check = TRUE, animation_style = LANGCHAT_FAST_POP, additional_styles = list("langchat_small", "emote")) + user.langchat_speech("holds up [src].", viewers, animation_style = LANGCHAT_FAST_POP, additional_styles = list("langchat_small", "emote")) for (var/mob/M in viewers) if(!user.is_invisible_to(M)) M.show_message("[user] holds up [icon2html(src, M)] [src]. Take a closer look.",1) diff --git a/code/game/objects/items/devices/radio/radio.dm b/code/game/objects/items/devices/radio/radio.dm index a7cb03e57a8..c1312df8383 100644 --- a/code/game/objects/items/devices/radio/radio.dm +++ b/code/game/objects/items/devices/radio/radio.dm @@ -442,7 +442,7 @@ var/global/list/default_interrogation_channels = list( // If we were to send to a channel we don't have, drop it. return null -/obj/item/radio/talk_into(mob/living/M, message, channel, var/say_verb = "says", var/datum/language/speaking = null, var/ignore_restrained) +/obj/item/radio/talk_into(mob/living/M, message, channel, var/say_verb = "says", var/datum/language/speaking = null, var/ignore_restrained, var/datum/say_message/say_message = null) if(!on) return FALSE if(!M || !message) @@ -487,7 +487,7 @@ var/global/list/default_interrogation_channels = list( // Determine the identify information attached to the signal var/datum/weakref/speaker_weakref = WEAKREF(M) - var/datum/signal/subspace/vocal/signal = new(src, connection.frequency, speaker_weakref, speaking, message, say_verb) + var/datum/signal/subspace/vocal/signal = new(src, connection.frequency, speaker_weakref, speaking, message, say_verb, say_message) // All radios attempt to use the subspace system . = signal.send_to_receivers() @@ -594,7 +594,7 @@ var/global/list/default_interrogation_channels = list( /obj/item/radio/borg/list_channels(var/mob/user) return list_secure_channels(user) -/obj/item/radio/borg/talk_into(mob/living/M, message, channel, verb, datum/language/speaking, var/ignore_restrained) +/obj/item/radio/borg/talk_into(mob/living/M, message, channel, verb, datum/language/speaking, var/ignore_restrained, var/datum/say_message/say_message = null) . = ..() if (isrobot(src.loc)) var/mob/living/silicon/robot/R = src.loc diff --git a/code/game/objects/objs.dm b/code/game/objects/objs.dm index 73dbb9594dc..ab491137d9c 100644 --- a/code/game/objects/objs.dm +++ b/code/game/objects/objs.dm @@ -394,7 +394,7 @@ if(mob.client) clients_in_hearers += mob.client if(length(clients_in_hearers)) - langchat_speech(message, hearers, GLOB.all_languages, skip_language_check = TRUE) + langchat_speech(message, hearers) /// Override this to customize the effects an activated signaler has. /obj/proc/do_signaler() diff --git a/code/game/objects/structures/crates_lockers/closets/statue.dm b/code/game/objects/structures/crates_lockers/closets/statue.dm index 4c33224650a..7b34a5fb37b 100644 --- a/code/game/objects/structures/crates_lockers/closets/statue.dm +++ b/code/game/objects/structures/crates_lockers/closets/statue.dm @@ -5,7 +5,7 @@ /mob/statue_mob/send_emote() to_chat(src, "You are unable to move while trapped as a statue.") -/mob/statue_mob/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) +/mob/statue_mob/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) to_chat(src, "You are unable to speak while trapped as a statue.") /obj/structure/closet/statue diff --git a/code/game/objects/structures/machinery/telecomms/broadcasting.dm b/code/game/objects/structures/machinery/telecomms/broadcasting.dm index 271aaff621a..1529dbb950d 100644 --- a/code/game/objects/structures/machinery/telecomms/broadcasting.dm +++ b/code/game/objects/structures/machinery/telecomms/broadcasting.dm @@ -97,12 +97,15 @@ /datum/signal/subspace/vocal var/datum/weakref/speaker var/datum/language/language + /// The speaker's say_message, if any. Null for the announcer, etc. + var/datum/say_message/say_message -/datum/signal/subspace/vocal/New(obj/source, frequency, datum/weakref/speaker, datum/language/language, message, say_verb) +/datum/signal/subspace/vocal/New(obj/source, frequency, datum/weakref/speaker, datum/language/language, message, say_verb, datum/say_message/say_message) src.source = source src.frequency = frequency src.language = language src.speaker = speaker + src.say_message = say_message var/turf/T = get_turf(source) if(isturf(T)) @@ -127,7 +130,7 @@ ) /datum/signal/subspace/vocal/copy() - var/datum/signal/subspace/vocal/copy = new(source, frequency, speaker, language) + var/datum/signal/subspace/vocal/copy = new(source, frequency, speaker, language, say_message = say_message) copy.original = src copy.data = data.Copy() copy.levels = levels @@ -214,12 +217,25 @@ var/compression = data["compression"] + // Carry the original message if untouched in transit, else build one from the flat text. + var/datum/say_message/radio_msg + if(say_message && say_message.to_string() == data["message"]) + radio_msg = say_message.copy() + else + radio_msg = new + radio_msg.speaker = M + radio_msg.verb = data["say_verb"] + radio_msg.collapse_to(language, message) + radio_msg.say_mode = SAYMODE_RADIO + radio_msg.base_clarity = compression ? CLARITY_FAINT : CLARITY_CLEAR + radio_msg.radio_parts = list(part_a, part_b, part_c) + for (var/mob/hearer in receive) if(!hearer) crash_with("null found in the hearers list returned by the spatial grid") continue - hearer.hear_radio(message, data["say_verb"], language, part_a, part_b, part_c, M, !!compression) + hearer.hear_message(radio_msg) // --- This following recording is intended for research and feedback in the use of department radio channels --- diff --git a/code/modules/emotes/emote_mob.dm b/code/modules/emotes/emote_mob.dm index 11909b383b7..173ac37737e 100644 --- a/code/modules/emotes/emote_mob.dm +++ b/code/modules/emotes/emote_mob.dm @@ -170,7 +170,7 @@ audible_message(message, ghost_hearing = do_show_observers) emote_viewers = get_hearers_in_LOS(world.view, src) - langchat_speech(langchat_message, emote_viewers, GLOB.all_languages, skip_language_check = TRUE, additional_styles = list("emote", "langchat_small")) + langchat_speech(langchat_message, emote_viewers, additional_styles = list("emote", "langchat_small")) // Specific mob type exceptions below. /mob/living/silicon/ai/emote(var/act, var/type, var/message) diff --git a/code/modules/hallucinations/mob.dm b/code/modules/hallucinations/mob.dm index cdcefd5cd83..3c1415beafd 100644 --- a/code/modules/hallucinations/mob.dm +++ b/code/modules/hallucinations/mob.dm @@ -1,17 +1,13 @@ -//Hallucinated Hearing -/mob/living/carbon/hear_say(var/message, var/verb = "says", var/datum/language/language, var/alt_name = "",var/italics = 0, var/mob/speaker, var/sound/speech_sound, var/sound_vol) - if(hallucination >= 60 && prob(1)) - var/orig_message = message - message = pick(SShallucinations.hallucinated_phrases) - log_say("Hallucination level changed [orig_message] by [speaker] to [message] for [key_name(src)].") - return ..() +//Hallucinated hearing: a chance to mishear the body entirely +/mob/proc/hallucinate_heard(text, mob/speaker) + return text -/mob/living/carbon/hear_radio(var/message, var/verb="says", var/datum/language/language, var/part_a, var/part_b, var/part_c, var/mob/speaker, var/hard_to_hear = 0, var/vname ="") +/mob/living/carbon/hallucinate_heard(text, mob/speaker) if(hallucination >= 60 && prob(1)) - var/orig_message = message - message = pick(SShallucinations.hallucinated_phrases) - log_say("Hallucination level changed [orig_message] by [speaker] to [message] for [key_name(src)].") - ..() + var/orig_message = text + text = pick(SShallucinations.hallucinated_phrases) + log_say("Hallucination level changed [orig_message] by [speaker] to [text] for [key_name(src)].") + return text //Main handling proc, called in life() /mob/living/carbon/proc/handle_hallucinations() diff --git a/code/modules/heavy_vehicle/mech_interaction.dm b/code/modules/heavy_vehicle/mech_interaction.dm index 304dd00e963..010ef2ffe00 100644 --- a/code/modules/heavy_vehicle/mech_interaction.dm +++ b/code/modules/heavy_vehicle/mech_interaction.dm @@ -699,10 +699,9 @@ GLOBAL_DATUM_INIT(mech_state, /datum/ui_state/default, new()) /mob/living/heavy_vehicle/get_floating_chat_y_offset() return 20 -/mob/living/heavy_vehicle/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "", var/italics = 0, var/mob/speaker = null, var/sound/speech_sound, var/sound_vol) +/mob/living/heavy_vehicle/react_to_message(datum/say_message/msg) if(can_listen()) - addtimer(CALLBACK(src, PROC_REF(handle_hear_say), speaker, message), 0.5 SECONDS) - return ..() + addtimer(CALLBACK(src, PROC_REF(handle_hear_say), msg.speaker, msg.to_string()), 0.5 SECONDS) // heavily commented so it doesn't look like one fat chunk of code, which it still does - Geeves /mob/living/heavy_vehicle/proc/handle_hear_say(var/mob/speaker, var/text) diff --git a/code/modules/mob/abstract/ghost/observer/say.dm b/code/modules/mob/abstract/ghost/observer/say.dm index 986307b3a0a..646a092cee6 100644 --- a/code/modules/mob/abstract/ghost/observer/say.dm +++ b/code/modules/mob/abstract/ghost/observer/say.dm @@ -1,12 +1,12 @@ -/mob/abstract/ghost/observer/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) - if (!message) +/mob/abstract/ghost/observer/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) + if (!text) return - log_say("Ghost/[src.key] : [message]") + log_say("Ghost/[src.key] : [text]") if (src.client) if(src.client.prefs.muted & (MUTE_DEADCHAT|MUTE_IC)) to_chat(src, SPAN_WARNING("You cannot talk in deadchat (muted).")) return - . = src.say_dead(message) + . = src.say_dead(text) diff --git a/code/modules/mob/abstract/ghost/storyteller/storyteller.dm b/code/modules/mob/abstract/ghost/storyteller/storyteller.dm index 1926bf58498..5618b515e68 100644 --- a/code/modules/mob/abstract/ghost/storyteller/storyteller.dm +++ b/code/modules/mob/abstract/ghost/storyteller/storyteller.dm @@ -45,11 +45,11 @@ if(!can_reenter_corpse) SSodyssey.remove_storyteller(src) -/mob/abstract/ghost/storyteller/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) - if (!message) +/mob/abstract/ghost/storyteller/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) + if (!text) return - var/msg = sanitize(message) + var/msg = sanitize(text) if(!msg) return diff --git a/code/modules/mob/abstract/new_player/new_player.dm b/code/modules/mob/abstract/new_player/new_player.dm index b66667ea8c6..d5dd84e8298 100644 --- a/code/modules/mob/abstract/new_player/new_player.dm +++ b/code/modules/mob/abstract/new_player/new_player.dm @@ -431,6 +431,9 @@ INITIALIZE_IMMEDIATE(/mob/abstract/new_player) /mob/abstract/new_player/Move() return TRUE +/mob/abstract/new_player/hear_message(datum/say_message/msg) + return FALSE + /mob/abstract/new_player/proc/close_spawn_windows() src << browse(null, "window=playersetup") //closes the player setup window @@ -465,12 +468,6 @@ INITIALIZE_IMMEDIATE(/mob/abstract/new_player) /mob/abstract/new_player/is_ready() return ready && ..() -/mob/abstract/new_player/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "",var/italics = 0, var/mob/speaker = null) - return - -/mob/abstract/new_player/hear_radio(var/message, var/verb="says", var/datum/language/language=null, var/part_a, var/part_b, var/part_c, var/mob/speaker = null, var/hard_to_hear = 0) - return - /mob/abstract/new_player/MayRespawn() return 1 diff --git a/code/modules/mob/hear_say.dm b/code/modules/mob/hear_say.dm deleted file mode 100644 index 9b353df7e4e..00000000000 --- a/code/modules/mob/hear_say.dm +++ /dev/null @@ -1,313 +0,0 @@ -// At minimum every mob has a hear_say proc. - -/** - * hear_say's return value determines whether or not the mob in question also receives a langchat image. - */ -/mob/proc/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "",var/italics = 0, var/mob/speaker = null, var/sound/speech_sound, var/sound_vol, var/font_size = null) - if(!istype(src, /mob/living/test) && cant_hear()) - return FALSE - - if(speaker && !istype(speaker, /mob/living/test) && (!speaker.client && istype(src,/mob/abstract/ghost/observer) && client.prefs.toggles & CHAT_GHOSTEARS && !(speaker in view(src)))) - //Does the speaker have a client? It's either random stuff that observers won't care about (Experiment 97B says, 'EHEHEHEHEHEHEHE') - //Or someone snoring. So we make it where they won't hear it. - return FALSE - - //make sure the air can transmit speech - hearer's side - var/turf/T = get_turf(src) - var/vacuum_proof = ((language && (language.flags & PRESSUREPROOF)) || isghost(src)) - var/speaker_name = speaker.name - - if(ishuman(speaker)) - var/mob/living/carbon/human/H = speaker - speaker_name = H.GetVoice() - - if(T && !vacuum_proof) //Ghosts can hear even in vacuum. - var/datum/gas_mixture/environment = T.return_air() - var/pressure = SAFE_XGM_PRESSURE(environment) - var/distance_to_speaker = get_dist(speaker, src) - if(pressure < SOUND_MINIMUM_PRESSURE && distance_to_speaker > 1) - // Yeah, this isn't quite realistic to be able to see if someone is talking through, say, an opaque mask, but for gameplay purposes it should help indicate that you're not bugged. - if(distance_to_speaker <= 4 && !italics) - to_chat(src, SPAN_NOTICE("[speaker_name] talks, but you're in a vacuum. Maybe if you were close enough for the sound to transmit through touch...")) - return FALSE - - if (pressure < ONE_ATMOSPHERE*0.4) //sound distortion pressure, to help clue people in that the air is thin, even if it isn't a vacuum yet - italics = 1 - sound_vol *= 0.5 //muffle the sound a bit, so it's like we're actually talking through contact - - if((language && (language.flags & KNOWONLYHEAR)) && !say_understands(speaker, language)) - return TRUE - - if(!vr_mob && (sleeping || stat == UNCONSCIOUS)) - hear_sleep(message) - return FALSE - - //non-verbal languages are garbled if you can't see the speaker. Yes, this includes if they are inside a closet. - if (language && (language.flags & NONVERBAL)) - if((!speaker || (src.sdisabilities & BLIND || src.blinded) || !(speaker in view(src))) && !isghost(src)) - message = stars(message) - - if(!(language && (language.flags & INNATE)) && !say_understands(speaker, language)) // skip understanding checks for INNATE languages - message = language ? language.scramble(message, languages) : stars(message) - - var/accent_icon = speaker.get_accent_icon(language, src) - - if(italics) - message = "[message]" - - var/track = null - if(isghost(src)) - if(speaker_name != speaker.real_name && speaker.real_name) - speaker_name = "[speaker.real_name] ([speaker_name])" - track = "[ghost_follow_link(speaker, src)] " - if((client.prefs.toggles & CHAT_GHOSTEARS) && (get_turf(speaker) in view(src))) - message = "[message]" - - if(isdeaf(src)) - if(!language || !(language.flags & INNATE)) // INNATE is the flag for audible-emote-language, so we don't want to show an "x talks but you cannot hear them" message if it's set - if(speaker == src) - to_chat(src, SPAN_WARNING("You cannot hear yourself speak!")) - return FALSE - else - to_chat(src, "[speaker_name][alt_name] talks but you cannot hear them.") - return FALSE - else - if(language) - if(font_size) - on_hear_say("[track][accent_icon ? accent_icon + " " : ""][speaker_name][alt_name] [language.format_message(message, verb)]") - else - on_hear_say("[track][accent_icon ? accent_icon + " " : ""][speaker_name][alt_name] [language.format_message(message, verb)]") - else - if(font_size) - on_hear_say("[track][accent_icon ? accent_icon + " " : ""][speaker_name][alt_name] [verb], \"[message]\"") - else - on_hear_say("[track][accent_icon ? accent_icon + " " : ""][speaker_name][alt_name] [verb], \"[message]\"") - if (speech_sound && (get_dist(speaker, src) <= world.view && src.z == speaker.z)) - var/turf/source = speaker? get_turf(speaker) : get_turf(src) - playsound(source, speech_sound, sound_vol, vary = TRUE) - return TRUE - -/mob/proc/cant_hear() - if(!client && !vr_mob) - return TRUE - return FALSE - -/mob/living/carbon/cant_hear() - . = ..() - if(.) - var/datum/dionastats/DS = get_dionastats() - if(DS?.nym) - return FALSE - -/mob/living/carbon/alien/diona/cant_hear() - . = ..() - if(. && detached && gestalt) - return FALSE - -/mob/proc/on_hear_say(var/message) - to_chat(src, message) - if(vr_mob) - to_chat(vr_mob, message) - -/mob/living/carbon/on_hear_say(var/message) - ..() - var/datum/dionastats/DS = get_dionastats() - if(DS?.nym) - var/mob/living/carbon/alien/diona/D = DS.nym.resolve() - if(D) - to_chat(D, message) - -/mob/living/carbon/alien/diona/on_hear_say(var/message) - to_chat(src, message) - if(detached && gestalt) - to_chat(gestalt, message) - -/mob/living/silicon/on_hear_say(var/message) - var/time = say_timestamp() - to_chat(src, "[time] [message]") - if(vr_mob) - to_chat(vr_mob, "[time] [message]") - -/mob/proc/hear_radio(var/message, var/verb="says", var/datum/language/language=null, var/part_a, var/part_b, var/part_c, var/mob/speaker = null, var/hard_to_hear = 0) - if(!client && !vr_mob) - return - - if(!vr_mob && (sleeping || stat == UNCONSCIOUS)) - hear_sleep(message) - return - - var/track = null - - //non-verbal languages are garbled if you can't see the speaker. Yes, this includes if they are inside a closet. - if (language && (language.flags & NONVERBAL)) - if (!speaker || (src.sdisabilities & BLIND || src.blinded) || !(speaker in view(src))) - message = stars(message) - - if(!(language && (language.flags & INNATE))) // skip understanding checks for INNATE languages - if(!say_understands(speaker,language)) - if(istype(speaker,/mob/living/simple_animal)) - var/mob/living/simple_animal/S = speaker - if(S.speak && S.speak.len) - message = pick(S.speak) - else - return - else - if(language) - message = language.scramble(message, languages) - else - message = stars(message) - - if(hard_to_hear) - message = stars(message) - - var/speaker_name - if(speaker != null) - speaker_name = speaker.name - else - speaker_name = "Unknown" - - if(ishuman(speaker)) - speaker_name = speaker.GetVoice() - - if(hard_to_hear) - speaker_name = "Unknown" - - var/changed_voice - - var/accent_icon = speaker.get_accent_icon(language, src) - accent_icon = accent_icon ? accent_icon + " " : "" - part_a = replacetext(part_a, "%ACCENT%", accent_icon) - - if(istype(src, /mob/living/silicon/ai) && !hard_to_hear) - var/jobname // the mob's "job" - var/mob/living/carbon/human/impersonating //The crew member being impersonated, if any. - - if (ishuman(speaker)) - var/mob/living/carbon/human/H = speaker - - if(H.wear_mask && istype(H.wear_mask,/obj/item/clothing/mask/gas/voice)) - changed_voice = 1 - var/list/impersonated = list() - var/mob/living/carbon/human/I = impersonated[speaker_name] - if(!I) - for(var/mob/living/carbon/human/M in GLOB.mob_list) - if(M.real_name == speaker_name) - I = M - impersonated[speaker_name] = I - break - - // If I's display name is currently different from the voice name and using an agent ID then don't impersonate - // as this would allow the AI to track I and realize the mismatch. - if(I && !(I.name != speaker_name && I.wear_id && istype(I.wear_id,/obj/item/card/id/syndicate))) - impersonating = I - jobname = impersonating.get_assignment() - else - jobname = "Unknown" - else - jobname = H.get_assignment() - - else if (iscarbon(speaker)) // Nonhuman carbon mob - jobname = "No id" - else if (isAI(speaker)) - jobname = "AI" - else if (isrobot(speaker)) - jobname = "Cyborg" - else if (istype(speaker, /mob/living/silicon/pai)) - jobname = "Personal AI" - else - jobname = "Unknown" - - if(changed_voice) - if(impersonating) - track = "[speaker_name] ([jobname])" - else - track = "[speaker_name] ([jobname])" - else - track = "[speaker_name] ([jobname])" - - if(isghost(src)) - if(speaker != null) - if(speaker_name != speaker.real_name && !isAI(speaker)) //Announce computer and various stuff that broadcasts doesn't use it's real name but AI's can't pretend to be other mobs. - speaker_name = "[speaker.real_name] ([speaker_name])" - track = "[ghost_follow_link(speaker, src)] " - - var/formatted - if(language) - formatted = language.format_message_radio(message, verb) - else - formatted = "[verb], \"[message]\"" - formatted += part_c - if(isdeaf(src)) - if(prob(20)) - to_chat(src, SPAN_WARNING("You feel your headset vibrate but can hear nothing from it!")) - else - on_hear_radio(part_a, speaker_name, track, part_b, formatted, accent_icon) - -/proc/say_timestamp() - return "\[[worldtime2text()]\]" - -/mob/proc/on_hear_radio(part_a, speaker_name, track, part_b, formatted, accent_icon) - to_chat(src, "[part_a][speaker_name][part_b][formatted]") - if(vr_mob) - to_chat(vr_mob, "[part_a][speaker_name][part_b][formatted]") - -/mob/abstract/ghost/observer/on_hear_radio(part_a, speaker_name, track, part_b, formatted) - to_chat(src, "[track][part_a][speaker_name][part_b][formatted]") - -/mob/living/silicon/on_hear_radio(part_a, speaker_name, track, part_b, formatted) - var/time = say_timestamp() - to_chat(src, "[time][part_a][speaker_name][part_b][formatted]") - if(vr_mob) - to_chat(vr_mob, "[time][part_a][speaker_name][part_b][formatted]") - -/mob/living/silicon/ai/on_hear_radio(part_a, speaker_name, track, part_b, formatted) - var/time = say_timestamp() - to_chat(src, "[time][part_a][track][part_b][formatted]") - if(vr_mob) - to_chat(vr_mob, "[time][part_a][track][part_b][formatted]") - -/mob/proc/hear_signlang(var/message, var/verb = "gestures", var/datum/language/language, var/mob/speaker = null, var/list/sign_adv_length = null) - if(!client || !speaker) - return - - if(say_understands(speaker, language)) - message = "[speaker] [verb], \"[message]\"" - else - if (length(sign_adv_length) <= 4) - sign_adv_length = list(" briefly", " a short message", " a message", " a lengthy message", " a very lengthy message") - var/adverb - var/length = length(message) * pick(0.8, 0.9, 1.0, 1.1, 1.2) //Inserts a little fuzziness. - switch(length) - if(0 to 12) adverb = sign_adv_length[1] - if(12 to 30) adverb = sign_adv_length[2] - if(30 to 48) adverb = sign_adv_length[3] - if(48 to 90) adverb = sign_adv_length[4] - else adverb = sign_adv_length[5] - message = "[speaker] [verb][adverb]." - - if(src.status_flags & PASSEMOTES) - for(var/obj/item/holder/H in src.contents) - H.show_message(message) - for(var/mob/living/M in src.contents) - M.show_message(message) - src.show_message(message) - -/mob/proc/hear_sleep(var/message) - if (isdeaf(src)) - return - var/heard = "" - if(prob(15)) - var/list/punctuation = list(",", "!", ".", ";", "?") - var/list/messages = text2list(message, " ") - var/R = rand(1, messages.len) - var/heardword = messages[R] - if(copytext(heardword,1, 1) in punctuation) - heardword = copytext(heardword,2) - if(copytext(heardword,-1) in punctuation) - heardword = copytext(heardword,1,length(heardword)) - heard = "...You hear something about...[heardword]" - - else - heard = "...You almost hear someone talking..." - - to_chat(src, heard) diff --git a/code/modules/mob/language/generic.dm b/code/modules/mob/language/generic.dm index 80e71e190e7..2521693510f 100644 --- a/code/modules/mob/language/generic.dm +++ b/code/modules/mob/language/generic.dm @@ -2,19 +2,16 @@ /datum/language/noise name = LANGUAGE_NOISE desc = "Noises." - key = "" - flags = RESTRICTED | NONGLOBAL | INNATE | NO_TALK_MSG | NO_STUTTER | TCOMSSIM + key = "e" + flags = RESTRICTED | INNATE | NO_TALK_MSG | NO_STUTTER | TCOMSSIM allow_accents = TRUE /datum/language/noise/format_message(message, verb) - return span("message", "[message]") + return span("message", colourize(message)) /datum/language/noise/format_message_plain(message, verb) return message -/datum/language/noise/format_message_radio(message, verb, accent) - return "[message]" - /datum/language/noise/get_talkinto_msg_range(message) // if you make a loud noise (screams etc), you'll be heard from 4 tiles over instead of two return (copytext(message, length(message)) == "!") ? 4 : 2 diff --git a/code/modules/mob/language/language.dm b/code/modules/mob/language/language.dm index 33a2ee7ae5c..3ab0ab85093 100644 --- a/code/modules/mob/language/language.dm +++ b/code/modules/mob/language/language.dm @@ -202,13 +202,14 @@ return input_size /datum/language/proc/format_message(message, verb) - return "[verb], \"[capitalize(message)]\"" + return "[verb], [colourize("\"[capitalize(message)]\"")]" /datum/language/proc/format_message_plain(message, verb) return "[verb], \"[capitalize(message)]\"" -/datum/language/proc/format_message_radio(message, verb) - return "[verb], \"[capitalize(message)]\"" +/// Wraps text in this language's colour span. +/datum/language/proc/colourize(message) + return "[message]" /datum/language/proc/get_talkinto_msg_range(message) // if you yell, you'll be heard from two tiles over instead of one @@ -376,6 +377,22 @@ return prefix in GLOB.config.language_prefixes +/// Caches the trigger regex per prefix-set so it isn't rebuilt each message. +GLOBAL_LIST_EMPTY(language_trigger_regex_cache) + +/// Returns a cached regex matching a language prefix at message start or after whitespace. +/mob/proc/get_language_trigger_regex() + var/list/prefixes = (client?.prefs?.language_prefixes?.len) ? client.prefs.language_prefixes : GLOB.config.language_prefixes + var/cache_key = jointext(prefixes, "") + . = GLOB.language_trigger_regex_cache[cache_key] + if(.) + return + var/list/escaped = list() + for(var/prefix in prefixes) + escaped += "\\[prefix]" + . = regex("(^|\\s)(\[[jointext(escaped, "")]\])", "g") + GLOB.language_trigger_regex_cache[cache_key] = . + /mob/verb/check_languages() set name = "Check Known Languages" set category = "IC.Language" diff --git a/code/modules/mob/living/bot/bot.dm b/code/modules/mob/living/bot/bot.dm index fab90b9aaa9..83d047f4fa9 100644 --- a/code/modules/mob/living/bot/bot.dm +++ b/code/modules/mob/living/bot/bot.dm @@ -163,8 +163,8 @@ return return attack_hand(user) -/mob/living/bot/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) - ..(message, null, "beeps") +/mob/living/bot/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) + ..(text, null, "beeps") /mob/living/bot/Collide(atom/A) if(on && botcard && istype(A, /obj/structure/machinery/door)) diff --git a/code/modules/mob/living/carbon/brain/say.dm b/code/modules/mob/living/carbon/brain/say.dm index 4de289de48a..c713e63e958 100644 --- a/code/modules/mob/living/carbon/brain/say.dm +++ b/code/modules/mob/living/carbon/brain/say.dm @@ -1,16 +1,16 @@ //TODO: Convert this over for languages. -/mob/living/carbon/brain/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/carbon/brain/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if (silent) return if(!(container && istype(container, /obj/item/mmi))) return //No MMI, can't speak, bucko./N else - speaking = parse_language(message) + speaking = parse_language(text) if(speaking) - message = copytext(message, 2+length(speaking.key)) - var/ending = copytext(message, length(message)) - var/pre_ending = copytext(message, length(message) - 1, length(message)) + text = copytext(text, 2+length(speaking.key)) + var/ending = copytext(text, length(text)) + var/pre_ending = copytext(text, length(text) - 1, length(text)) if (speaking) verb = speaking.get_spoken_verb(ending, pre_ending) else @@ -23,14 +23,14 @@ if(prob(10))//10% chane to drop the message entirely return else - message = Gibberish(message, (emp_damage*6))//scrambles the message, gets worse when emp_damage is higher + text = Gibberish(text, (emp_damage*6))//scrambles the message, gets worse when emp_damage is higher if(speaking && speaking.flags & HIVEMIND) - speaking.broadcast(src,trim(message)) + speaking.broadcast(src,trim(text)) return if(istype(container, /obj/item/mmi/radio_enabled)) var/obj/item/mmi/radio_enabled/R = container if(R.radio) - R.radio.hear_talk(src, sanitize(message), verb, speaking) - ..(trim(message), speaking, verb) + R.radio.hear_talk(src, sanitize(text), verb, speaking) + ..(trim(text), speaking, verb) diff --git a/code/modules/mob/living/carbon/human/say.dm b/code/modules/mob/living/carbon/human/say.dm index 759b6deed2a..4b013759ce5 100644 --- a/code/modules/mob/living/carbon/human/say.dm +++ b/code/modules/mob/living/carbon/human/say.dm @@ -65,9 +65,9 @@ return ..() -/mob/living/carbon/human/say(message, datum/language/speaking, verb, alt_name, ghost_hearing, whisper, skip_edit) +/mob/living/carbon/human/say(text, datum/language/speaking, verb, alt_name, ghost_hearing, whisper, skip_edit) // Messaging the user is handled by the species proc. - if(!species.can_speak(src, speaking, message)) + if(!species.can_speak(src, speaking, text)) return FALSE . = ..() @@ -116,7 +116,7 @@ /* ***Deprecated*** - let this be handled at the hear_say or hear_radio proc + let this be handled at the hear_message proc This is left in for robot speaking when humans gain binary channel access until I get around to rewriting robot_talk() proc. There is no language handling build into it however there is at the /mob level so we accept the call @@ -195,21 +195,23 @@ return headsets[headsets[1]] return null -/mob/living/carbon/human/handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper, var/is_singing = FALSE) - if(!whisper && (paralysis || InStasis())) - whisper(message, speaking) +/mob/living/carbon/human/handle_message_mode(datum/say_message/msg, datum/language/primary, list/used_radios) + var/text = msg.to_string() + var/message_mode = msg.message_mode + if(!msg.whisper && (paralysis || InStasis())) + whisper(text, primary, say_verb = TRUE, msg = msg) return TRUE switch(message_mode) if("intercom") for(var/obj/item/radio/intercom/I in view(1)) I.add_fingerprint(src) used_radios += I - I.talk_into(src, message, null, verb, speaking) + I.talk_into(src, text, null, msg.verb, primary, say_message = msg) if("headset") var/obj/item/radio/R = get_radio() if(R) used_radios += R - R.talk_into(src, message, null, verb, speaking) + R.talk_into(src, text, null, msg.verb, primary, say_message = msg) if("right ear") var/obj/item/radio/R var/has_radio = FALSE @@ -221,7 +223,7 @@ has_radio = TRUE if(has_radio) used_radios += R - R.talk_into(src,message,null,verb,speaking) + R.talk_into(src, text, null, msg.verb, primary, say_message = msg) if("left ear") var/obj/item/radio/R var/has_radio = FALSE @@ -233,7 +235,7 @@ has_radio = TRUE if(has_radio) used_radios += R - R.talk_into(src,message,null,verb,speaking) + R.talk_into(src, text, null, msg.verb, primary, say_message = msg) if("wrist") var/obj/item/radio/R var/has_radio = FALSE @@ -245,16 +247,16 @@ has_radio = TRUE if(has_radio) used_radios += R - R.talk_into(src,message,null,verb,speaking) + R.talk_into(src, text, null, msg.verb, primary, say_message = msg) if("whisper") - whisper(message, speaking, is_singing, say_verb = TRUE) + whisper(text, primary, msg.singing, say_verb = TRUE, msg = msg) return TRUE else if(message_mode) var/obj/item/radio/R = get_radio() if(R) used_radios += R - R.talk_into(src, message, message_mode, verb, speaking) + R.talk_into(src, text, message_mode, msg.verb, primary, say_message = msg) /mob/living/carbon/human/handle_speech_sound() var/list/returns = ..() diff --git a/code/modules/mob/living/carbon/slime/say.dm b/code/modules/mob/living/carbon/slime/say.dm index eaefd6206e9..e5bd37a0888 100644 --- a/code/modules/mob/living/carbon/slime/say.dm +++ b/code/modules/mob/living/carbon/slime/say.dm @@ -1,10 +1,10 @@ -/mob/living/carbon/slime/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) - verb = say_quote(message) +/mob/living/carbon/slime/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) + verb = say_quote(text) - if(copytext(message,1,2) == "*") - return emote(copytext(message,2)) + if(copytext(text,1,2) == "*") + return emote(copytext(text,2)) - return ..(message, null, verb) + return ..(text, null, verb) /mob/living/carbon/slime/say_quote(var/text) var/ending = copytext(text, length(text)) @@ -21,16 +21,8 @@ return TRUE return ..() -/mob/living/carbon/slime/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "", var/italics = 0, var/mob/speaker = null, var/sound/speech_sound, var/sound_vol) - if(is_friend(speaker)) +/mob/living/carbon/slime/react_to_message(datum/say_message/msg) + if(is_friend(msg.speaker)) speech_buffer = list() - speech_buffer.Add(speaker) - speech_buffer.Add(lowertext(html_decode(message))) - return ..() - -/mob/living/carbon/slime/hear_radio(var/message, var/verb="says", var/datum/language/language=null, var/part_a, var/part_b, var/part_c, var/mob/speaker = null, var/hard_to_hear = 0, var/vname ="") - if(is_friend(speaker)) - speech_buffer = list() - speech_buffer.Add(speaker) - speech_buffer.Add(lowertext(html_decode(message))) - return ..() + speech_buffer.Add(msg.speaker) + speech_buffer.Add(lowertext(html_decode(msg.to_string()))) diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm index caf2a24e2ba..1f2fa8a625e 100644 --- a/code/modules/mob/living/say.dm +++ b/code/modules/mob/living/say.dm @@ -145,14 +145,16 @@ var/list/channel_to_radio_key = new /mob/living/proc/get_stutter_verbs() return list("stammers", "stutters") -/mob/living/proc/handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper) +/mob/living/proc/handle_message_mode(datum/say_message/msg, datum/language/primary, list/used_radios) + var/text = msg.to_string() + var/message_mode = msg.message_mode if(message_mode == "intercom") for(var/obj/item/radio/intercom/I in view(1, src)) used_radios += I - I.talk_into(src, message, verb, speaking) + I.talk_into(src, text, msg.verb, primary, say_message = msg) - if(message_mode == "whisper" && !whisper) - whisper(message, speaking, say_verb = TRUE) + if(message_mode == "whisper" && !msg.whisper) + whisper(text, primary, say_verb = TRUE, msg = msg) return TRUE return FALSE @@ -187,150 +189,179 @@ var/list/channel_to_radio_key = new return "statement" -/mob/living/say(var/message, var/datum/language/speaking = null, var/verb, var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/// Applies final edits to the message like autohiss, then adds markup. Returns false if msg ends empty. +/mob/living/proc/finalize_say_message(datum/say_message/msg, skip_edit = FALSE) + if(!skip_edit) + for(var/datum/say_segment/segment as anything in msg.segments) + if(segment.language && (segment.language.flags & NO_STUTTER)) + continue + segment.text = handle_autohiss(segment.text, segment.language) + var/list/hsp_params = handle_speech_problems(segment.text, msg.verb, msg.message_mode, msg.message_range) + if(hsp_params) + segment.text = hsp_params[HSP_MSG] || segment.text + msg.verb = hsp_params[HSP_VERB] || msg.verb + msg.message_mode = hsp_params[HSP_MSGMODE] || msg.message_mode + msg.message_range = hsp_params[HSP_MSGRANGE] || msg.message_range + for(var/datum/say_segment/segment as anything in msg.segments) + segment.text = process_chat_markup(segment.text, list("~", "_")) + return length(msg.to_string()) ? TRUE : FALSE + +/// Delivers message to each listener. Returns clients that heard it. +/// Removes delivered mobs from the listeners list. +/mob/living/proc/deliver_say_message(datum/say_message/msg, list/listeners) + var/list/client/heard = list() + for(var/mob/M in listeners) + if(M.hear_message(msg) && M.client) + heard += M.client + listeners -= M + return heard + +/// Builds and animates the speech bubble over the speaker for the given clients. +/mob/living/proc/show_speech_bubble(datum/say_message/msg, message, list/client/show_to) + var/speech_bubble_state = check_speech_punctuation_state(message) + var/speech_state_modifier = get_speech_bubble_state_modifier() + if(speech_bubble_state && speech_state_modifier) + speech_bubble_state = "[speech_state_modifier]_[speech_bubble_state]" + if(!speech_bubble_state) + return + var/image/speech_bubble = image('icons/mob/talk.dmi', src, speech_bubble_state) + adjust_typing_indicator_offsets(speech_bubble) + speech_bubble.layer = layer + speech_bubble.plane = plane + speech_bubble.appearance_flags = RESET_COLOR|RESET_ALPHA + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(animate_speechbubble), speech_bubble, show_to, 30) + +/mob/living/say(var/text, var/datum/language/speaking = null, var/verb, var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(stat) if(stat == DEAD) - return say_dead(message) + return say_dead(text) return if(silent) to_chat(src, SPAN_WARNING("You try to speak, but nothing comes out!")) return - var/message_mode = parse_message_mode(message, "headset") + var/message_mode = parse_message_mode(text, "headset") var/regex/emote = regex("^(\[\\*^\])\[^*\]+$") - if(emote.Find(message)) - if(emote.group[1] == "*") return client_emote(copytext(message, 2)) - if(emote.group[1] == "^") return custom_emote(VISIBLE_MESSAGE, copytext(message,2)) + if(emote.Find(text)) + if(emote.group[1] == "*") return client_emote(copytext(text, 2)) + if(emote.group[1] == "^") return custom_emote(VISIBLE_MESSAGE, copytext(text,2)) //parse the radio code and consume it if (message_mode) if (message_mode == "headset") - message = copytext(message,2) //it would be really nice if the parse procs could do this for us. + text = copytext(text,2) //it would be really nice if the parse procs could do this for us. else - message = copytext(message,3) + text = copytext(text,3) - message = trim(message) - message = formalize_text(message) + text = trim(text) + text = formalize_text(text) - var/had_speaking = !!speaking - //parse the language code and consume it - if(!speaking || speaking.always_parse_language) - speaking = parse_language(message) - if(!had_speaking) - if(speaking) - message = copytext(message,2+length(speaking.key)) - else - speaking = get_default_language() + var/datum/language/forced = (speaking && !speaking.always_parse_language) ? speaking : null + var/datum/say_message/msg = build_say_message(text, forced) - if(speaking) - var/list/speech_mod = speaking.handle_message_mode(message_mode) - speaking = speech_mod[1] + if(!length(msg.to_string())) + return FALSE + + // the first segment's language is the message's primary + var/datum/language/primary = msg.segments[1].language + var/list/speech_mod = primary?.handle_message_mode(message_mode) + if(speech_mod) + primary = speech_mod[1] message_mode = speech_mod[2] - var/is_singing = FALSE - if(length(message) >= 1 && copytext(message, 1, 2) == "%") - message = copytext(message, 2) - if(speaking?.sing_verb) - is_singing = TRUE + if(copytext(msg.segments[1].text, 1, 2) == "%") + msg.segments[1].text = copytext(msg.segments[1].text, 2) + if(primary?.sing_verb) + msg.singing = TRUE + msg.sing_note = pick("\u2669", "\u266A", "\u266B") - // This is broadcast to all mobs with the language, - // irrespective of distance or anything else. - if(speaking && (speaking.flags & HIVEMIND)) - if(speaking.name == LANGUAGE_VAURCA && within_jamming_range(src)) + // hivemind languages reach all speakers regardless of distance + if(msg.single_language?.flags & HIVEMIND) + if(msg.single_language.name == LANGUAGE_VAURCA && within_jamming_range(src)) to_chat(src, SPAN_WARNING("Your head buzzes as your message is blocked with jamming signals.")) return - speaking.broadcast(src,trim(message)) + msg.single_language.broadcast(src, trim(msg.to_string())) return TRUE - if(!verb) - verb = say_quote(message, speaking, is_singing, whisper) + msg.verb = verb || say_quote(msg.to_string(), primary, msg.singing, whisper) var/is_shouting = FALSE - if(speaking) - for(var/verb_to_check in speaking.shout_verb) - if(verb_to_check == verb) + if(primary) + for(var/verb_to_check in primary.shout_verb) + if(verb_to_check == msg.verb) is_shouting = TRUE - continue + break if(is_muzzled()) to_chat(src, SPAN_DANGER("You're muzzled and cannot speak!")) return - message = trim_left(message) - var/message_range = world.view - if(!skip_edit && !(speaking && (speaking.flags & NO_STUTTER))) - message = handle_autohiss(message, speaking) - var/list/hsp_params = handle_speech_problems(message, verb, message_mode, message_range) - if(hsp_params) - message = hsp_params[HSP_MSG] || message - verb = hsp_params[HSP_VERB] || verb - message_mode = hsp_params[HSP_MSGMODE] || message_mode - message_range = hsp_params[HSP_MSGRANGE] || message_range + msg.message_mode = message_mode + msg.message_range = world.view + msg.whisper = whisper + msg.alt_name = alt_name + msg.say_mode = SAYMODE_SPOKEN - if(!message || message == "") + if(!finalize_say_message(msg, skip_edit)) return FALSE - message = process_chat_markup(message, list("~", "_")) - if(is_singing) - var/randomnote = pick("\u2669", "\u266A", "\u266B") - message = "[randomnote] [message] [randomnote]" + if(msg.single_language?.flags & SIGNLANG) + msg.say_mode = SAYMODE_SIGN + msg.verb = pick(msg.single_language.signlang_verb) + return say_signlang(msg) + if(primary && (primary.flags & NONVERBAL) && prob(30)) + custom_emote(VISIBLE_MESSAGE, "[pick(primary.signlang_verb)].") - //handle nonverbal and sign languages here - if (speaking) - if (speaking.flags & NONVERBAL) - if (prob(30)) - src.custom_emote(VISIBLE_MESSAGE, "[pick(speaking.signlang_verb)].") - - if (speaking.flags & SIGNLANG) - return say_signlang(message, pick(speaking.signlang_verb), speaking, speaking.sign_adv_length) + text = msg.to_string() var/list/obj/item/used_radios = new - if(handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper, is_singing)) + if(handle_message_mode(msg, primary, used_radios)) return TRUE var/list/handle_v = handle_speech_sound() - var/sound/speech_sound = handle_v[1] - var/sound_vol = handle_v[2] - var/italics = handle_v[3] + msg.speech_sound = handle_v[1] + msg.sound_vol = handle_v[2] + msg.italics = handle_v[3] + msg.font_size = get_font_size_modifier() //speaking into radios if(length(used_radios)) - italics = TRUE - message_range = 1 - if(speaking) - message_range = speaking.get_talkinto_msg_range(message) - if(!speaking || !(speaking.flags & NO_TALK_MSG)) - var/msg = SPAN_NOTICE("\The [src] talks into \the [used_radios[1]].") + msg.italics = TRUE + msg.message_range = 1 + if(primary) + msg.message_range = primary.get_talkinto_msg_range(text) + if(!primary || !(primary.flags & NO_TALK_MSG)) + var/talk_msg = SPAN_NOTICE("\The [src] talks into \the [used_radios[1]].") for (var/mob/living/L in get_hearers_in_view(5, src) - src) - L.show_message(msg) - if(speech_sound) - sound_vol *= 0.5 + L.show_message(talk_msg) + if(msg.speech_sound) + msg.sound_vol *= 0.5 var/list/listening = list() var/turf/T = get_turf(src) if(whisper) - message_range = 1 - italics = TRUE + msg.message_range = 1 + msg.italics = TRUE if(T) - if(!speaking || !(speaking.flags & PRESSUREPROOF)) + if(!msg.single_language || !(msg.single_language.flags & PRESSUREPROOF)) //make sure the air can transmit speech - speaker's side var/datum/gas_mixture/environment = T.return_air() var/pressure = SAFE_XGM_PRESSURE(environment) if(pressure < SOUND_MINIMUM_PRESSURE) - message_range = 1 + msg.message_range = 1 if (pressure < ONE_ATMOSPHERE*0.4) //sound distortion pressure, to help clue people in that the air is thin, even if it isn't a vacuum yet - italics = TRUE - sound_vol *= 0.5 //muffle the sound a bit, so it's like we're actually talking through contact + msg.italics = TRUE + msg.sound_vol *= 0.5 //muffle the sound a bit, so it's like we're actually talking through contact - listening = get_hearers_in_view(message_range, src) + listening = get_hearers_in_view(msg.message_range, src) - var/sensitive_listener = get_intent_listeners(src, message_range + 1) + var/sensitive_listener = get_intent_listeners(src, msg.message_range + 1) listening = mergelists(listening, sensitive_listener, TRUE) if(client) @@ -342,51 +373,33 @@ var/list/channel_to_radio_key = new if(player_mob.client?.prefs.toggles & CHAT_GHOSTEARS) listening |= player_mob - var/list/hear_clients = list() - for(var/mob/M in listening) - var/heard_say = M.hear_say(message, verb, speaking, alt_name, italics, src, speech_sound, sound_vol, get_font_size_modifier()) - if(heard_say && M.client) - hear_clients += M.client - listening -= M + var/list/hear_clients = deliver_say_message(msg, listening) - var/speech_bubble_state = check_speech_punctuation_state(message) - var/speech_state_modifier = get_speech_bubble_state_modifier() - if(speech_bubble_state && speech_state_modifier) - speech_bubble_state = "[speech_state_modifier]_[speech_bubble_state]" - - var/image/speech_bubble - if(speech_bubble_state) - speech_bubble = image('icons/mob/talk.dmi', src, speech_bubble_state) - adjust_typing_indicator_offsets(speech_bubble) - speech_bubble.layer = layer - speech_bubble.plane = plane - - speech_bubble.appearance_flags = RESET_COLOR|RESET_ALPHA - INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(animate_speechbubble), speech_bubble, hear_clients, 30) + show_speech_bubble(msg, text, hear_clients) var/list/langchat_styles = list() - if(istype(speaking, /datum/language/noise)) + if(istype(primary, /datum/language/noise)) langchat_styles = list("emote", "langchat_small") if(whisper) langchat_styles = list("langchat_italic") if(is_shouting) langchat_styles = list("langchat_yell") - langchat_speech(message, get_hearers_in_view(message_range, src), speaking, additional_styles = langchat_styles) + langchat_say_message(msg, get_hearers_in_view(msg.message_range, src), additional_styles = langchat_styles) - var/bypass_listen_obj = (speaking && (speaking.flags & PASSLISTENOBJ)) + var/bypass_listen_obj = (msg.single_language && (msg.single_language.flags & PASSLISTENOBJ)) if(!bypass_listen_obj) for(var/obj/O as anything in listening) if(O) //It's possible that it could be deleted in the meantime. - INVOKE_ASYNC(O, TYPE_PROC_REF(/obj, hear_talk), src, message, verb, speaking) + INVOKE_ASYNC(O, TYPE_PROC_REF(/obj, hear_talk), src, text, msg.verb, primary) if(mind) - mind.last_words = message + mind.last_words = text if(whisper) - log_whisper("[key_name(src)] : ([get_lang_name(speaking)]) [message]") + log_whisper("[key_name(src)] : [msg.raw_message]") else - log_say("[key_name(src)] : ([get_lang_name(speaking)]) [message]") + log_say("[key_name(src)] : [msg.raw_message]") return TRUE @@ -404,12 +417,12 @@ var/list/channel_to_radio_key = new for(var/client/C in show_to) C.images -= I -/mob/living/proc/say_signlang(var/message, var/verb="gestures", var/datum/language/language, var/list/sign_adv_length) - log_say("[key_name(src)] : ([get_lang_name(language)]) [message]") +/mob/living/proc/say_signlang(datum/say_message/msg) + log_say("[key_name(src)] : ([get_lang_name(msg.single_language)]) [msg.raw_message]") - for (var/mob/O in viewers(src, null)) - O.hear_signlang(message, verb, language, src, sign_adv_length) - return 1 + for(var/mob/O in viewers(src, null)) + O.hear_message(msg) + return TRUE /obj/effect/speech_bubble var/mob/parent diff --git a/code/modules/mob/living/silicon/pai/say.dm b/code/modules/mob/living/silicon/pai/say.dm index 4fce1281981..09de779f4b4 100644 --- a/code/modules/mob/living/silicon/pai/say.dm +++ b/code/modules/mob/living/silicon/pai/say.dm @@ -1,4 +1,4 @@ -/mob/living/silicon/pai/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/silicon/pai/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(silence_time) to_chat(src, "Communication circuits remain uninitialized.") return diff --git a/code/modules/mob/living/silicon/say.dm b/code/modules/mob/living/silicon/say.dm index f8a1e22c218..1fb8d277195 100644 --- a/code/modules/mob/living/silicon/say.dm +++ b/code/modules/mob/living/silicon/say.dm @@ -14,9 +14,11 @@ . = TRUE message = Gibberish(message, damaged - 10) -/mob/living/silicon/robot/handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper) - if(message_mode == "whisper" && !whisper) - whisper(message, speaking, say_verb = TRUE) +/mob/living/silicon/robot/handle_message_mode(datum/say_message/msg, datum/language/primary, list/used_radios) + var/text = msg.to_string() + var/message_mode = msg.message_mode + if(message_mode == "whisper" && !msg.whisper) + whisper(text, primary, say_verb = TRUE, msg = msg) return TRUE if(message_mode) if(!is_component_functioning("radio")) @@ -24,37 +26,40 @@ return 0 if(message_mode == "general") message_mode = null - log_say("[key_name(src)] : [message]") - return common_radio.talk_into(src, message, message_mode, verb, speaking) + log_say("[key_name(src)] : [text]") + return common_radio.talk_into(src, text, message_mode, msg.verb, primary, say_message = msg) /mob/living/silicon/robot/drone/handle_message_mode() return null -/mob/living/silicon/ai/handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper) - if(message_mode == "whisper" && !whisper) - whisper(message, speaking, say_verb = TRUE) +/mob/living/silicon/ai/handle_message_mode(datum/say_message/msg, datum/language/primary, list/used_radios) + var/text = msg.to_string() + var/message_mode = msg.message_mode + if(message_mode == "whisper" && !msg.whisper) + whisper(text, primary, say_verb = TRUE, msg = msg) return TRUE if(message_mode == "department") - log_say("[key_name(src)] : [message]") - return holopad_talk(message, verb, speaking) + return holopad_talk(msg) else if(message_mode) if(ai_radio.disabledAi || ai_restore_power_routine || stat) to_chat(src, SPAN_DANGER("System Error - Transceiver Disabled.")) return FALSE if(message_mode == "general") message_mode = null - log_say("[key_name(src)] : [message]") - return ai_radio.talk_into(src, message, message_mode, verb, speaking) + log_say("[key_name(src)] : [text]") + return ai_radio.talk_into(src, text, message_mode, msg.verb, primary, say_message = msg) -/mob/living/silicon/pai/handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper) +/mob/living/silicon/pai/handle_message_mode(datum/say_message/msg, datum/language/primary, list/used_radios) + var/text = msg.to_string() + var/message_mode = msg.message_mode if(message_mode) - if(message_mode == "whisper" && !whisper) - whisper(message, speaking, say_verb = TRUE) + if(message_mode == "whisper" && !msg.whisper) + whisper(text, primary, say_verb = TRUE, msg = msg) return TRUE if(message_mode == "general") message_mode = null - log_say("[key_name(src)] : [message]") - return radio.talk_into(src, message, message_mode, verb, speaking) + log_say("[key_name(src)] : [text]") + return radio.talk_into(src, text, message_mode, msg.verb, primary, say_message = msg) /mob/living/silicon/say_quote(var/text, var/datum/language/speaking = null, var/singing = FALSE, var/whisper = FALSE) if(singing) @@ -93,19 +98,15 @@ return ..() //For holopads only. Usable by AI. -/mob/living/silicon/ai/proc/holopad_talk(var/message, verb, datum/language/speaking) - log_say("[key_name(src)] : [message]") - message = trim(message) - if(!message) +/mob/living/silicon/ai/proc/holopad_talk(datum/say_message/msg) + log_say("[key_name(src)] : [msg.raw_message]") + if(!length(msg.to_string())) return var/obj/structure/machinery/hologram/holopad/H = src.holo if(H?.active_holograms[src])//If there is a hologram and its master is the user. - // AI can hear their own message, this formats it for them. - if(speaking) - to_chat(src, "Holopad transmitted, [real_name] [speaking.format_message(message, verb)]") - else - to_chat(src, "Holopad transmitted, [real_name] [verb], \"[message]\"") + // AI hears its own message. text_for keeps each language. + to_chat(src, "Holopad transmitted, [real_name] [msg.verb], \"[msg.text_for(src)]\"") //This is so pAI's and people inside lockers/boxes,etc can hear the AI Holopad, the alternative being recursion through contents. //This is much faster. @@ -129,13 +130,12 @@ hearturfs += get_turf(O) listening_obj |= O - for(var/mob/M in GLOB.player_list) if(M.stat == DEAD && M.client?.prefs.toggles & CHAT_GHOSTEARS) - M.hear_say(message, verb, speaking, null, null, src) + M.hear_message(msg) continue if(M.loc && (get_turf(M) in hearturfs)) - M.hear_say(message, verb, speaking, null, null, src) + M.hear_message(msg) else to_chat(src, SPAN_WARNING("No holopad connected.")) return FALSE diff --git a/code/modules/mob/living/simple_animal/borer/borer_captive.dm b/code/modules/mob/living/simple_animal/borer/borer_captive.dm index 367d8694529..776692eecf0 100644 --- a/code/modules/mob/living/simple_animal/borer/borer_captive.dm +++ b/code/modules/mob/living/simple_animal/borer/borer_captive.dm @@ -6,23 +6,23 @@ var/datum/progressbar/resist_bar var/resist_start_time = 0 -/mob/living/captive_brain/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/captive_brain/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(istype(src.loc,/mob/living/simple_animal/borer)) - if(!message) + if(!text) return - log_say("[key_name(src)] : [message]") + log_say("[key_name(src)] : [text]") if(stat == DEAD) - return say_dead(message) + return say_dead(text) var/mob/living/simple_animal/borer/B = src.loc - to_chat(src, "You whisper silently, \"[message]\"") - to_chat(B.host, "The captive mind of [src] whispers, \"[message]\"") + to_chat(src, "You whisper silently, \"[text]\"") + to_chat(B.host, "The captive mind of [src] whispers, \"[text]\"") for(var/mob/M in GLOB.player_list) if(istype(M, /mob/abstract/new_player)) continue else if(M.stat == DEAD && M.client.prefs.toggles & CHAT_GHOSTEARS) - to_chat(M, "The captive mind of [src] whispers, \"[message]\"") + to_chat(M, "The captive mind of [src] whispers, \"[text]\"") /mob/living/captive_brain/Destroy() QDEL_NULL(resist_bar) diff --git a/code/modules/mob/living/simple_animal/borer/say.dm b/code/modules/mob/living/simple_animal/borer/say.dm index d345f6c82e2..dcee8303dcb 100644 --- a/code/modules/mob/living/simple_animal/borer/say.dm +++ b/code/modules/mob/living/simple_animal/borer/say.dm @@ -1,23 +1,23 @@ -/mob/living/simple_animal/borer/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) - message = capitalize(message) +/mob/living/simple_animal/borer/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) + text = capitalize(text) - if(!message) + if(!text) return if(stat == DEAD) - return say_dead(message) + return say_dead(text) if(stat) return - message = formalize_text(message) + text = formalize_text(text) - if(copytext(message, 1, 2) == "*") - return emote(copytext(message, 2)) + if(copytext(text, 1, 2) == "*") + return emote(copytext(text, 2)) - var/datum/language/L = parse_language(message) + var/datum/language/L = parse_language(text) if(L?.flags & HIVEMIND) - L.broadcast(src, trim(copytext(message,3)), src.truename) + L.broadcast(src, trim(copytext(text,3)), src.truename) return if(!host) @@ -30,14 +30,14 @@ to_chat(src, SPAN_NOTICE("There are no viable hosts to speak with.")) else var/mob/living/carbon/human/chosen_sayer = pick(viable_sayers) - log_say("[key_name(src)] : (forcing [key_name(chosen_sayer)]) [message]") - chosen_sayer.say(message) + log_say("[key_name(src)] : (forcing [key_name(chosen_sayer)]) [text]") + chosen_sayer.say(text) return - to_chat(src, "You drop words into [host]'s mind: \"[message]\"") - to_chat(host, "Your own thoughts speak: \"[message]\"") - log_say("[key_name(src)] : (borer whisper -> [key_name(host)]) [message]") + to_chat(src, "You drop words into [host]'s mind: \"[text]\"") + to_chat(host, "Your own thoughts speak: \"[text]\"") + log_say("[key_name(src)] : (borer whisper -> [key_name(host)]) [text]") for(var/mob/M in GLOB.mob_list) if(M.client && M.stat == DEAD && !isnewplayer(M) && (M.client.prefs.toggles & CHAT_GHOSTEARS)) - to_chat(M, "[src.truename] whispers to [host], \"[message]\"") + to_chat(M, "[src.truename] whispers to [host], \"[text]\"") diff --git a/code/modules/mob/living/simple_animal/friendly/spiderbot.dm b/code/modules/mob/living/simple_animal/friendly/spiderbot.dm index fee86ea65df..1fbb14f411f 100644 --- a/code/modules/mob/living/simple_animal/friendly/spiderbot.dm +++ b/code/modules/mob/living/simple_animal/friendly/spiderbot.dm @@ -343,22 +343,24 @@ /mob/living/simple_animal/spiderbot/get_bullet_impact_effect_type(var/def_zone) return BULLET_IMPACT_METAL -/mob/living/simple_animal/spiderbot/handle_message_mode(message_mode, message, verb, speaking, used_radios, alt_name, whisper) +/mob/living/simple_animal/spiderbot/handle_message_mode(datum/say_message/msg, datum/language/primary, list/used_radios) + var/text = msg.to_string() + var/message_mode = msg.message_mode switch(message_mode) if("whisper") - if(!whisper) - whisper(message, speaking, say_verb = TRUE) + if(!msg.whisper) + whisper(text, primary, say_verb = TRUE, msg = msg) return TRUE if("headset") - radio.talk_into(src, message, null, verb, speaking) + radio.talk_into(src, text, null, msg.verb, primary, say_message = msg) used_radios += radio if("intercom") var/turf/T = get_turf(src) for(var/obj/item/radio/intercom/I in view(1, T)) - I.talk_into(src, message, null, verb, speaking) + I.talk_into(src, text, null, msg.verb, primary, say_message = msg) used_radios += I if(message_mode) - radio.talk_into(src, message, message_mode, verb, speaking) + radio.talk_into(src, text, message_mode, msg.verb, primary, say_message = msg) used_radios += radio /mob/living/simple_animal/spiderbot/proc/control_integrated_radio() diff --git a/code/modules/mob/living/simple_animal/hostile/commanded/commanded.dm b/code/modules/mob/living/simple_animal/hostile/commanded/commanded.dm index b83e0ce0f70..b279bdf53cd 100644 --- a/code/modules/mob/living/simple_animal/hostile/commanded/commanded.dm +++ b/code/modules/mob/living/simple_animal/hostile/commanded/commanded.dm @@ -24,17 +24,10 @@ ABSTRACT_TYPE(/mob/living/simple_animal/hostile/commanded) if(!short_name) short_name = name -/mob/living/simple_animal/hostile/commanded/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "", var/italics = 0, var/mob/speaker = null, var/sound/speech_sound, var/sound_vol) - if(thinking_enabled && !stat && ((speaker in friends) || speaker == master)) - command_buffer.Add(speaker) - command_buffer.Add(lowertext(html_decode(message))) - return 0 - -/mob/living/simple_animal/hostile/commanded/hear_radio(var/message, var/verb="says", var/datum/language/language=null, var/part_a, var/part_b, var/part_c, var/mob/speaker = null, var/hard_to_hear = 0) - if(thinking_enabled && !stat && ((speaker in friends) || speaker == master)) - command_buffer.Add(speaker) - command_buffer.Add(lowertext(html_decode(message))) - return 0 +/mob/living/simple_animal/hostile/commanded/react_to_message(datum/say_message/msg) + if(thinking_enabled && !stat && ((msg.speaker in friends) || msg.speaker == master)) + command_buffer.Add(msg.speaker) + command_buffer.Add(lowertext(html_decode(msg.to_string()))) /mob/living/simple_animal/hostile/commanded/can_name(var/mob/living/M) if(master && (M != master)) diff --git a/code/modules/mob/living/simple_animal/hostile/toy/mech.dm b/code/modules/mob/living/simple_animal/hostile/toy/mech.dm index 26282399d89..86bbf2aabb4 100644 --- a/code/modules/mob/living/simple_animal/hostile/toy/mech.dm +++ b/code/modules/mob/living/simple_animal/hostile/toy/mech.dm @@ -80,10 +80,9 @@ return return ..() -/mob/living/simple_animal/hostile/mech/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "", var/italics = 0, var/mob/speaker = null, var/sound/speech_sound, var/sound_vol) +/mob/living/simple_animal/hostile/mech/react_to_message(datum/say_message/msg) if(!seeking_targets) - addtimer(CALLBACK(src, PROC_REF(handle_hear_say), speaker, message), 0.5 SECONDS) - return ..() + addtimer(CALLBACK(src, PROC_REF(handle_hear_say), msg.speaker, msg.to_string()), 0.5 SECONDS) /mob/living/simple_animal/hostile/mech/proc/handle_hear_say(var/mob/speaker, var/text) if(seeking_targets) diff --git a/code/modules/mob/living/simple_animal/parrot.dm b/code/modules/mob/living/simple_animal/parrot.dm index c08243d1235..cd8c9028535 100644 --- a/code/modules/mob/living/simple_animal/parrot.dm +++ b/code/modules/mob/living/simple_animal/parrot.dm @@ -681,7 +681,7 @@ to_chat(src, SPAN_WARNING("There is no perch nearby to sit on.")) return -/mob/living/simple_animal/parrot/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/simple_animal/parrot/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(stat) return @@ -691,40 +691,35 @@ var/message_mode="" - if(copytext(message,1,2) == ";") + if(copytext(text,1,2) == ";") message_mode = "headset" - message = copytext(message,2) + text = copytext(text,2) - if(length(message) >= 2) - var/channel_prefix = copytext(message, 1 ,3) + if(length(text) >= 2) + var/channel_prefix = copytext(text, 1 ,3) message_mode = department_radio_keys[channel_prefix] - if(copytext(message,1,2) == ":") + if(copytext(text,1,2) == ":") var/positioncut = 3 - message = trim(copytext(message,positioncut)) + text = trim(copytext(text,positioncut)) - message = capitalize(trim_left(message)) + text = capitalize(trim_left(text)) if(message_mode) if(message_mode in radiochannels) if(ears && istype(ears,/obj/item/radio)) - ears.talk_into(src,sanitize(message), message_mode, verb, null) + ears.talk_into(src,sanitize(text), message_mode, verb, null) - ..(message) + ..(text) -/mob/living/simple_animal/parrot/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "",var/italics = 0, var/mob/speaker = null) +/mob/living/simple_animal/parrot/react_to_message(datum/say_message/msg) if(prob(50)) - parrot_hear(message) - ..(message,verb,language,alt_name,italics,speaker) - - - -/mob/living/simple_animal/parrot/hear_radio(var/message, var/verb="says", var/datum/language/language=null, var/part_a, var/part_b, var/part_c, var/mob/speaker = null, var/hard_to_hear = 0) - if(prob(50)) - parrot_hear("[pick(available_channels)] [message]") - ..(message,verb,language,part_a,part_b,speaker,hard_to_hear) + if(msg.say_mode == SAYMODE_RADIO) + parrot_hear("[pick(available_channels)] [msg.to_string()]") + else + parrot_hear(msg.to_string()) /mob/living/simple_animal/parrot/proc/parrot_hear(var/message="") diff --git a/code/modules/mob/living/simple_animal/shade.dm b/code/modules/mob/living/simple_animal/shade.dm index c6565006f98..e146f938856 100644 --- a/code/modules/mob/living/simple_animal/shade.dm +++ b/code/modules/mob/living/simple_animal/shade.dm @@ -195,20 +195,19 @@ to_chat(L, SPAN_NOTICE("[pick(nagging_doubts)]")) possession_heard_message = 1 -/mob/living/simple_animal/shade/bluespace/hear_say(var/message, var/verb = "says", var/datum/language/language = null, var/alt_name = "", var/italics = 0, var/mob/speaker = null, var/sound/speech_sound, var/sound_vol) - ..() - if(speaker && speaker != src && !possessive) - last_message_heard = message +/mob/living/simple_animal/shade/bluespace/react_to_message(datum/say_message/msg) + if(msg.speaker && msg.speaker != src && !possessive) + last_message_heard = msg.to_string() message_countdown = min(300, message_countdown + 35) adjustCloneLoss(-2) if(heard_dying_message) heard_dying_message = 0 to_chat(src, SPAN_NOTICE("The soothing echoes of life reinvigorate you.")) -/mob/living/simple_animal/shade/bluespace/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/simple_animal/shade/bluespace/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(!possessive) var/list/words_in_memory = dd_text2List(last_message_heard, " ") - var/list/words_in_message = dd_text2List(message, " ") + var/list/words_in_message = dd_text2List(text, " ") for(var/word1 in words_in_message) var/valid = 0 for(var/word2 in words_in_memory) @@ -218,8 +217,8 @@ valid = 1 break if(!valid) - message = replacetext(message, word1, pick(words_in_memory)) - message = slur(message,15) + text = replacetext(text, word1, pick(words_in_memory)) + text = slur(text,15) ..() else to_chat(src, SPAN_WARNING("You cannot muster a voice when possessing another!")) diff --git a/code/modules/mob/living/simple_animal/simple_animal.dm b/code/modules/mob/living/simple_animal/simple_animal.dm index b1f58f52965..bae3ce58457 100644 --- a/code/modules/mob/living/simple_animal/simple_animal.dm +++ b/code/modules/mob/living/simple_animal/simple_animal.dm @@ -876,7 +876,7 @@ /mob/living/simple_animal/proc/reset_sound_time() sound_time = TRUE -/mob/living/simple_animal/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/simple_animal/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(speak_emote.len) verb = pick(speak_emote) @@ -887,7 +887,7 @@ make_noise(sound_chance) var/can_ghosts_hear = client ? GHOSTS_ALL_HEAR : ONLY_GHOSTS_IN_VIEW - ..(message, speaking, verb, ghost_hearing = can_ghosts_hear) + ..(text, speaking, verb, ghost_hearing = can_ghosts_hear) /mob/living/simple_animal/get_speech_ending(verb, var/ending) return verb diff --git a/code/modules/mob/living/whisper.dm b/code/modules/mob/living/whisper.dm index cc5e294edf2..39900bb2d7c 100644 --- a/code/modules/mob/living/whisper.dm +++ b/code/modules/mob/living/whisper.dm @@ -14,7 +14,7 @@ whisper(message) -/mob/living/whisper(var/message, var/datum/language/speaking, var/is_singing = FALSE, var/say_verb = FALSE) +/mob/living/whisper(var/text, var/datum/language/speaking, var/is_singing = FALSE, var/say_verb = FALSE, var/datum/say_message/msg = null) if(is_muzzled()) to_chat(src, SPAN_DANGER("You're muzzled and cannot speak!")) return @@ -23,49 +23,90 @@ to_chat(src, SPAN_WARNING("You try to speak, but nothing comes out!")) return - var/had_speaking = !!speaking - speaking = speaking ? speaking : parse_language(message) + // A reroute from say() hands us its already-built say_message. + // Otherwise, build one. + var/from_say = !isnull(msg) + if(!msg) + var/datum/language/forced = (speaking && !speaking.always_parse_language) ? speaking : null + msg = build_say_message(text, forced) - if(!had_speaking) - if(speaking) - message = copytext_char(message, 2 + length(speaking.key)) - else - speaking = get_default_language() + if(!length(msg.to_string())) + return - if(length_char(message) >= 1 && copytext_char(message, 1, 2) == "%") - if(speaking?.sing_verb) - is_singing = TRUE + var/datum/language/primary = msg.segments[1].language - var/message_range = 1 - var/eavesdropping_range = 2 - var/watching_range = 5 + if(!from_say) + if(copytext(msg.segments[1].text, 1, 2) == "%") + msg.segments[1].text = copytext(msg.segments[1].text, 2) + if(primary?.sing_verb) + msg.singing = TRUE + msg.sing_note = pick("\u2669", "\u266A", "\u266B") + if(is_singing && primary?.sing_verb) + msg.singing = TRUE + + // Hivemind has no distance. It always broadcasts. + if(msg.single_language?.flags & HIVEMIND) + if(msg.single_language.name == LANGUAGE_VAURCA && within_jamming_range(src)) + to_chat(src, SPAN_WARNING("Your head buzzes as your message is blocked with jamming signals.")) + return + msg.single_language.broadcast(src, trim(msg.to_string())) + return TRUE var/whisper_text = "whispers" var/not_heard = "[whisper_text] something" - if(speaking) - if(speaking.whisper_verb) - whisper_text = pick(speaking.whisper_verb) + if(primary) + if(primary.whisper_verb) + whisper_text = pick(primary.whisper_verb) not_heard = "[whisper_text] something" else var/adverb = pick("quietly", "softly") - var/speak_text = pick(speaking.speech_verb) - if(is_singing && speaking?.sing_verb) - speak_text = pick(speaking.sing_verb) + var/speak_text = pick(primary.speech_verb) + if(msg.singing && primary.sing_verb) + speak_text = pick(primary.sing_verb) whisper_text = "[speak_text] [adverb]" not_heard = "[speak_text] something [adverb]" - //Check to see who hears the full whisper message, and who just gets the not_heard message + msg.whisper = TRUE + msg.say_mode = SAYMODE_SPOKEN + msg.verb = whisper_text + msg.italics = TRUE + msg.message_range = 1 + msg.alt_name = "" + + if(!from_say && !finalize_say_message(msg, say_verb)) + return + + var/list/handle_v = handle_speech_sound() + msg.speech_sound = handle_v[1] + msg.sound_vol = handle_v[2] + if(handle_v[3]) + msg.italics = TRUE + msg.font_size = get_font_size_modifier() + + // Sign language is visual and whole-message. It routes separately. + if(msg.single_language?.flags & SIGNLANG) + msg.say_mode = SAYMODE_SIGN + msg.verb = pick(msg.single_language.signlang_verb) + return say_signlang(msg) + + var/eavesdropping_range = 2 + var/watching_range = 5 + + // Inner ring is everybody who clearly hears the whisper. + var/list/inner = get_hearers_in_view(msg.message_range, src) + inner = mergelists(inner, get_intent_listeners(src, msg.message_range + 1), TRUE) + + // Outer ring is for those further away. var/list/eavesdropping = list() var/list/watching = list() var/list/observers = list() - var/list/all_in_range = hearers(watching_range, src) - for(var/mob/M as anything in all_in_range) - // If they got sensitive hearing enabled, increase the whisper hearing range by one + for(var/mob/M as anything in hearers(watching_range, src)) + if(M in inner) + continue var/sensitive = astype(M, /mob/living/carbon/human)?.is_listening() ? 1 : 0 - //Preventing duplicate messages to ghostear'd observers if(M.stat == DEAD && M.client && (M.client.prefs.toggles & CHAT_GHOSTEARS)) continue - if(get_dist(src, M) <= (message_range + sensitive)) //In range to hear + if(get_dist(src, M) <= (msg.message_range + sensitive)) continue else if(get_dist(src, M) <= (eavesdropping_range + sensitive)) if(M.stat == DEAD && M.client) @@ -78,18 +119,43 @@ else watching += M - if(length(observers)) //For ghosts who do NOT have ghost ears. They will see the whole message if nearby, no *s or not_heard messages. - for(var/mob/M in observers) - M.hear_say(message, "whispers", speaking, "", TRUE, src) + // Ghost ears hear all speech. They hear it from any distance. + if(client) + for(var/mob/player_mob in GLOB.player_list) + if(!player_mob || player_mob.stat != DEAD || (player_mob in inner)) + continue + if(player_mob.client?.prefs.toggles & CHAT_GHOSTEARS) + inner |= player_mob + msg.base_clarity = CLARITY_CLEAR + var/list/hear_clients = deliver_say_message(msg, inner) + + // Nearby ghosts without ghost ears hear it clearly. + for(var/mob/M in observers) + M.hear_message(msg) + + // Eavesdroppers can only hear the message faintly. if(length(eavesdropping)) - var/new_message = stars(message) + var/datum/say_message/faint = msg.copy() + faint.base_clarity = CLARITY_FAINT for(var/mob/M in eavesdropping) - M.hear_say(new_message, "whispers", speaking, "", TRUE, src) + M.hear_message(faint) if(length(watching)) var/rendered = "[src.name] [not_heard]." - for (var/mob/M in watching) + for(var/mob/M in watching) M.show_message(rendered, 2) - say(message, speaking, whisper_text, whisper = TRUE, skip_edit = say_verb) + show_speech_bubble(msg, msg.to_string(), hear_clients) + langchat_say_message(msg, get_hearers_in_view(msg.message_range, src), additional_styles = list("langchat_italic")) + + if(!(msg.single_language && (msg.single_language.flags & PASSLISTENOBJ))) + for(var/obj/O as anything in inner) + if(O) + INVOKE_ASYNC(O, TYPE_PROC_REF(/obj, hear_talk), src, msg.to_string(), msg.verb, primary) + + if(mind) + mind.last_words = msg.to_string() + + log_whisper("[key_name(src)] : [msg.raw_message]") + return TRUE diff --git a/code/modules/mob/say.dm b/code/modules/mob/say.dm index 29457b9d15b..f888c7b4574 100644 --- a/code/modules/mob/say.dm +++ b/code/modules/mob/say.dm @@ -1,4 +1,4 @@ -/mob/proc/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) +/mob/proc/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE) return ///what clients use to speak. when you type a message into the chat bar in say mode, this is the first thing that goes off serverside. @@ -88,7 +88,7 @@ /* ***Deprecated*** - let this be handled at the hear_say or hear_radio proc + let this be handled at the hear_message proc This is left in for robot speaking when humans gain binary channel access until I get around to rewriting robot_talk() proc. There is no language handling build into it however there is at the /mob level so we accept the call @@ -187,3 +187,84 @@ return L else return null + +/// Splits a message into per-language /datum/say_segment, switching language at each spoken prefix. +/// A forced language takes the whole message with no prefix parsing. +/mob/living/proc/build_say_message(message, datum/language/forced_language) + RETURN_TYPE(/datum/say_message) + var/datum/say_message/say_message = new + say_message.speaker = src + say_message.raw_message = message + + if(forced_language) + say_message.collapse_to(forced_language, message) + return say_message + + var/datum/language/current = get_default_language() + if(copytext(message, 1, 2) == "!") + current = GLOB.all_languages[LANGUAGE_NOISE] + message = trim_left(copytext(message, 2)) + + var/regex/trigger = get_language_trigger_regex() + trigger.next = 1 + + var/segment_start = 1 + var/list/datum/say_segment/segments = list() + + while(trigger.Find(message)) + var/lead = trigger.group[1] //empty at the start of the message, otherwise the whitespace before the prefix + var/prefix_pos = trigger.index + length(lead) + var/key_pos = prefix_pos + 1 + + var/key_len = 2 + var/datum/language/found = resolve_language_key(copytext(message, key_pos, key_pos + 2)) + if(!found) + key_len = 1 + found = resolve_language_key(copytext(message, key_pos, key_pos + 1)) + if(!found) + continue //not a language we can speak, leave it as literal text and keep scanning + + var/before = copytext(message, segment_start, prefix_pos) + + var/after = key_pos + key_len + if(copytext(message, after, after + 1) == " ") + after++ + + // If LANG_NO_MULTILANG is present, it must be first. If first, + // set the whole message to that language. If not, skip it. + if(found.flags & LANG_NO_MULTILANG) + if(!length(segments) && !length(before)) + say_message.collapse_to(found, copytext(message, after)) + return say_message + continue + + segments += new /datum/say_segment(before, current) + segment_start = after + current = found + trigger.next = after + + segments += new /datum/say_segment(copytext(message, segment_start), current) + say_message.segments = clean_segments(segments) + say_message.single_language = (length(say_message.segments) == 1) ? say_message.segments[1].language : null + return say_message + +/// Returns the language for a prefix key if we can speak it, otherwise null. +/mob/proc/resolve_language_key(key) + RETURN_TYPE(/datum/language) + if(!key) + return null + var/datum/language/found = GLOB.language_keys[lowertext(key)] + return (istype(found) && can_speak(found)) ? found : null + +/// Drops empty segments and merges adjacent ones sharing a language. +/proc/clean_segments(list/datum/say_segment/segments) + var/list/datum/say_segment/cleaned = list() + for(var/datum/say_segment/segment as anything in segments) + if(!length(segment.text)) + continue + var/datum/say_segment/last = cleaned.len ? cleaned[cleaned.len] : null + if(last && last.language == segment.language) + last.text += segment.text + else + cleaned += segment + return cleaned diff --git a/code/modules/mob/say_message/hear_message.dm b/code/modules/mob/say_message/hear_message.dm new file mode 100644 index 00000000000..fa837fd8d55 --- /dev/null +++ b/code/modules/mob/say_message/hear_message.dm @@ -0,0 +1,291 @@ +/// Resolves how clearly this listener perceives the message. +/mob/proc/get_clarity(datum/say_message/msg) + var/mob/speaker = msg.speaker + + if(msg.say_mode == SAYMODE_SIGN) + if(isghost(src)) + return msg.base_clarity + if((src.sdisabilities & BLIND) || src.blinded || !speaker || !(speaker in view(src))) + return CLARITY_NONE + return msg.base_clarity + + if(msg.say_mode != SAYMODE_RADIO && !(msg.single_language?.flags & PRESSUREPROOF) && !isghost(src)) + var/turf/T = get_turf(src) + if(T) + var/datum/gas_mixture/air = T.return_air() + var/pressure = SAFE_XGM_PRESSURE(air) + if(pressure < SOUND_MINIMUM_PRESSURE && get_dist(speaker, src) > 1) + if(get_dist(speaker, src) <= 4 && !msg.italics) + to_chat(src, SPAN_NOTICE("[speaker?.name] talks, but you're in a vacuum. Maybe if you were close enough for the sound to transmit through touch...")) + return CLARITY_NONE + + // vr_mob is a Psionic mob in the dream. They hear clearly even when not conscious. + if(!vr_mob && (sleeping || stat == UNCONSCIOUS)) + return CLARITY_DROWSY + + if(isdeaf(src)) + // INNATE is an audible emote. Gives no 'cannot hear' message. + if(!(msg.single_language?.flags & INNATE)) + if(msg.say_mode == SAYMODE_RADIO) + if(prob(20)) + to_chat(src, SPAN_WARNING("You feel your headset vibrate but can hear nothing from it!")) + else if(speaker == src) + to_chat(src, SPAN_WARNING("You cannot hear yourself speak!")) + else + to_chat(src, "[speaker?.name][msg.alt_name] talks but you cannot hear them.") + return CLARITY_NONE + return msg.base_clarity + + return msg.base_clarity + +/// Whether a message can be shown to this mob at all. +/mob/proc/has_chat_sink() + return client || vr_mob + +/mob/living/carbon/has_chat_sink() + . = ..() + if(.) + return TRUE + var/datum/dionastats/DS = get_dionastats() + return DS?.nym ? TRUE : FALSE + +/mob/living/carbon/alien/diona/has_chat_sink() + . = ..() + if(.) + return TRUE + return (detached && gestalt) ? TRUE : FALSE + +/// Builds the correct envelope around the body depending on SAYMODE. +/mob/proc/format_envelope(datum/say_message/msg, muffled = FALSE, clarity = CLARITY_CLEAR) + switch(msg.say_mode) + if(SAYMODE_SIGN) + return format_envelope_sign(msg, msg.text_for(src, clarity)) + if(SAYMODE_RADIO) + return format_envelope_radio(msg, clarity) + else + return format_envelope_spoken(msg, clarity, muffled) + +/// Wraps a say envelope around the message body. +/mob/proc/format_envelope_spoken(datum/say_message/msg, clarity = CLARITY_CLEAR, muffled = FALSE) + var/list/rendered = msg.render_body(src, clarity) + var/body = rendered[1] + if(!length(body)) + return "" + var/emote_led = rendered[2] + + var/mob/speaker = msg.speaker + var/speaker_name = speaker?.name + if(ishuman(speaker)) + var/mob/living/carbon/human/H = speaker + speaker_name = H.GetVoice() + + var/datum/language/accent_language + for(var/datum/say_segment/segment as anything in msg.segments) + if(segment.language?.allow_accents) + accent_language = segment.language + break + var/accent_icon = accent_language ? speaker?.get_accent_icon(accent_language, src) : null + + if(msg.italics || muffled) + body = "[body]" + + var/track = null + if(isghost(src)) + if(speaker_name != speaker.real_name && speaker.real_name) + speaker_name = "[speaker.real_name] ([speaker_name])" + track = "[ghost_follow_link(speaker, src)] " + if((client.prefs.toggles & CHAT_GHOSTEARS) && (get_turf(speaker) in view(src))) + body = "[body]" + + var/leading = emote_led ? "" : "[msg.verb], " + var/font_open = msg.font_size ? "" : "" + var/font_close = msg.font_size ? "" : "" + return "[track][accent_icon ? accent_icon + " " : ""][font_open][speaker_name][msg.alt_name] [leading][body][font_close]" + +/// Wraps a radio envelope around the message body. +/mob/proc/format_envelope_radio(datum/say_message/msg, clarity = CLARITY_CLEAR) + var/mob/speaker = msg.speaker + var/hard_to_hear = (msg.base_clarity == CLARITY_FAINT) + var/list/radio_parts = msg.radio_parts + var/part_a = LAZYACCESS(radio_parts, 1) + var/part_b = LAZYACCESS(radio_parts, 2) + var/part_c = LAZYACCESS(radio_parts, 3) + + var/speaker_name = speaker ? speaker.name : "Unknown" + if(ishuman(speaker)) + speaker_name = speaker.GetVoice() + if(hard_to_hear) + speaker_name = "Unknown" + + var/datum/language/language = msg.single_language + var/accent_icon = speaker?.get_accent_icon(language, src) + accent_icon = accent_icon ? accent_icon + " " : "" + part_a = replacetext(part_a, "%ACCENT%", accent_icon) + + var/track = null + if(istype(src, /mob/living/silicon/ai) && !hard_to_hear) + var/changed_voice + var/jobname + var/mob/living/carbon/human/impersonating + + if(ishuman(speaker)) + var/mob/living/carbon/human/H = speaker + if(H.wear_mask && istype(H.wear_mask, /obj/item/clothing/mask/gas/voice)) + changed_voice = 1 + var/mob/living/carbon/human/I + for(var/mob/living/carbon/human/M in GLOB.mob_list) + if(M.real_name == speaker_name) + I = M + break + if(I && !(I.name != speaker_name && I.wear_id && istype(I.wear_id, /obj/item/card/id/syndicate))) + impersonating = I + jobname = impersonating.get_assignment() + else + jobname = "Unknown" + else + jobname = H.get_assignment() + else if(iscarbon(speaker)) + jobname = "No id" + else if(isAI(speaker)) + jobname = "AI" + else if(isrobot(speaker)) + jobname = "Cyborg" + else if(istype(speaker, /mob/living/silicon/pai)) + jobname = "Personal AI" + else + jobname = "Unknown" + + if(changed_voice) + if(impersonating) + track = "[speaker_name] ([jobname])" + else + track = "[speaker_name] ([jobname])" + else + track = "[speaker_name] ([jobname])" + + if(isghost(src)) + if(speaker != null) + if(speaker_name != speaker.real_name && !isAI(speaker)) + speaker_name = "[speaker.real_name] ([speaker_name])" + track = "[ghost_follow_link(speaker, src)] " + + var/list/rendered = msg.render_body(src, clarity) + var/body = rendered[1] + if(!length(body)) + return "" + var/leading = rendered[2] ? "" : "[msg.verb], " + var/formatted = "[leading][body][part_c]" + + if(istype(src, /mob/living/silicon/ai) && track && !hard_to_hear) + return "[part_a][track][part_b][formatted]" + if(isghost(src)) + return "[track][part_a][speaker_name][part_b][formatted]" + return "[part_a][speaker_name][part_b][formatted]" + +/mob/proc/format_envelope_sign(datum/say_message/msg, body) + var/mob/speaker = msg.speaker + if(say_understands(speaker, msg.single_language)) + return "[speaker] [msg.verb], \"[body]\"" + var/list/sign_adv_length = msg.single_language?.sign_adv_length + if(length(sign_adv_length) <= 4) + sign_adv_length = list(" briefly", " a short message", " a message", " a lengthy message", " a very lengthy message") + var/adverb + var/length = length(body) * pick(0.8, 0.9, 1.0, 1.1, 1.2) // a little fuzziness + switch(length) + if(0 to 12) adverb = sign_adv_length[1] + if(12 to 30) adverb = sign_adv_length[2] + if(30 to 48) adverb = sign_adv_length[3] + if(48 to 90) adverb = sign_adv_length[4] + else adverb = sign_adv_length[5] + return "[speaker] [msg.verb][adverb]." + +/// Handles a mob receiving a say. +/mob/proc/hear_message(datum/say_message/msg) + var/clarity = get_clarity(msg) + if(clarity == CLARITY_NONE) + return FALSE + . = FALSE + if(has_chat_sink()) + display_message(msg, clarity) + . = TRUE + react_to_message(msg) + +/// Noop by default. Overriden by clientless mobs that react to speech. +/mob/proc/react_to_message(datum/say_message/msg) + return + +/// Renders the body for this listener, then decorates and outputs it. +/mob/proc/display_message(datum/say_message/msg, clarity) + var/mob/speaker = msg.speaker + // Ghosts skip clientless out-of-view chatter. + if(msg.say_mode != SAYMODE_RADIO && isghost(src) && speaker && !speaker.client && (client?.prefs.toggles & CHAT_GHOSTEARS) && !(speaker in view(src))) + return + + if(clarity == CLARITY_DROWSY) + var/drowsy = msg.text_for(src, clarity) + if(length(drowsy)) + on_hear_message(drowsy) + return + + // In thin air, non-pressureproof languages are muffled. + var/muffled = FALSE + var/sound_vol = msg.sound_vol + if(msg.say_mode != SAYMODE_RADIO && !isghost(src) && !(msg.single_language?.flags & PRESSUREPROOF)) + var/turf/T = get_turf(src) + if(T) + var/datum/gas_mixture/air = T.return_air() + if(SAFE_XGM_PRESSURE(air) < ONE_ATMOSPHERE * 0.4) + muffled = TRUE + sound_vol *= 0.5 + + var/envelope = format_envelope(msg, muffled, clarity) + if(!length(envelope)) + return + on_hear_message(envelope) + // This is a special case for internal mobs like cortical borers. + // They see through the eyes of their host so we must relay the message. + if(msg.say_mode == SAYMODE_SIGN && (status_flags & PASSEMOTES)) + for(var/obj/item/holder/H in contents) + H.show_message(envelope) + for(var/mob/living/M in contents) + M.show_message(envelope) + if(msg.say_mode != SAYMODE_RADIO) + play_speech_sound(msg, sound_vol) + +/// Plays the speech sound for a given message. +/mob/proc/play_speech_sound(datum/say_message/msg, sound_vol) + var/mob/speaker = msg.speaker + if(msg.speech_sound && (get_dist(speaker, src) <= world.view && src.z == speaker?.z)) + var/turf/source = speaker ? get_turf(speaker) : get_turf(src) + playsound(source, msg.speech_sound, sound_vol, vary = TRUE) + +/proc/say_timestamp() + return "\[[worldtime2text()]\]" + +/// The output sink for a given message. +/mob/proc/on_hear_message(message) + to_chat(src, message) + if(vr_mob) + to_chat(vr_mob, message) + +/// Dionae require special treatment. Messages should be forwarded to the nymph. +/mob/living/carbon/on_hear_message(message) + ..() + var/datum/dionastats/DS = get_dionastats() + if(DS?.nym) + var/mob/living/carbon/alien/diona/D = DS.nym.resolve() + if(D) + to_chat(D, message) + +/// A detached gestalt should be forwarded heard messages. +/mob/living/carbon/alien/diona/on_hear_message(message) + to_chat(src, message) + if(detached && gestalt) + to_chat(gestalt, message) + +/// Silicons receive a timestamp with heard messages. +/mob/living/silicon/on_hear_message(message) + var/time = say_timestamp() + to_chat(src, "[time] [message]") + if(vr_mob) + to_chat(vr_mob, "[time] [message]") diff --git a/code/modules/mob/say_message/say_message.dm b/code/modules/mob/say_message/say_message.dm new file mode 100644 index 00000000000..2bbf3421d37 --- /dev/null +++ b/code/modules/mob/say_message/say_message.dm @@ -0,0 +1,187 @@ +/// A complete audible message made of a list of /datum/say_segment. +/datum/say_message + /// The mob saying the message. + var/mob/speaker + + /// Name shown instead of the speaker's real name, if any. + var/alt_name = "" + + /// The raw string that was typed. Used in the admin log. + var/raw_message + + /// The list of segments that make up the message body. + var/list/datum/say_segment/segments = list() + + /// The sole language of this message, or null. + var/datum/language/single_language + + /// Which verb to use. Determined by punctuation and language of the first piece. + var/verb = "says" + + /// Is the message being whispered? + var/whisper = FALSE + + /// Is the message being sung? + var/singing = FALSE + + /// The musical note bracketing a sung message. + var/sing_note + + /// Should the message render as italic? + var/italics = FALSE + + /// Optional font size override for the rendered message. + var/font_size + + /// Optional range limit for hearing the message. + var/message_range + + /// The requested mode for this message. + var/message_mode + + /// Sound played to listeners alongside the message, if any. + var/sound/speech_sound + + /// Volume of speech_sound. + var/sound_vol + + /// How the message is reaching the listener. + var/say_mode = SAYMODE_SPOKEN + + /// Initial clarity. Most of the time this is clear. Sometimes the radio will degrade it. + var/base_clarity = CLARITY_CLEAR + + /// Radio envelope parts. This must live here for performance reasons pending a telecomms rewrite. + var/list/radio_parts + +/// Renders each segment as the listener perceives it. Returns list(text, is_emote) per segment. +/datum/say_message/proc/render_pieces(mob/listener, clarity = CLARITY_CLEAR) + var/list/pieces = list() + var/cap_next = TRUE + for(var/datum/say_segment/segment as anything in segments) + var/is_emote = (segment.language?.flags & INNATE) ? TRUE : FALSE + var/rendered = segment.plain_text_for(listener, speaker) + if(clarity == CLARITY_FAINT && !is_emote) + rendered = length(rendered) ? stars(rendered) : "" + rendered = trim(rendered) // Strip whitespace for quoting. We'll re-add it later. + if(!length(rendered)) + continue + if(is_emote) + cap_next = TRUE // emote re-arms caps for the next spoken run + else if(cap_next) + rendered = capitalize(rendered) + cap_next = FALSE + if(segment.language) + rendered = segment.language.colourize(rendered) + pieces += list(list(rendered, is_emote)) + return pieces + +/// Quotes the given text if it's not an emote. +/proc/quote_run(text, emote) + text = trim(text) + if(!length(text)) + return "" + return emote ? text : "\"[text]\"" + +/// Renders the body. Speech is quoted. Returns list(body, leads_with_emote). +/datum/say_message/proc/render_body(mob/listener, clarity = CLARITY_CLEAR) + var/list/pieces = render_pieces(listener, clarity) + if(!length(pieces)) + return list("", FALSE) + + var/leads_with_emote = pieces[1][2] + var/list/runs = list() + var/list/cur = list() + var/cur_emote = pieces[1][2] + for(var/list/piece in pieces) + if(length(cur) && piece[2] != cur_emote) + var/q = quote_run(jointext(cur, " "), cur_emote) + if(length(q)) + runs += q + cur = list() + cur_emote = piece[2] + cur += piece[1] + var/last = quote_run(jointext(cur, " "), cur_emote) + if(length(last)) + runs += last + + var/body = listener.hallucinate_heard(jointext(runs, " "), speaker) + if(singing && length(body)) + body = "[sing_note] [body] [sing_note]" + return list(body, leads_with_emote) + +/// Returns the complete message as the given listener perceives it. +/datum/say_message/proc/text_for(mob/listener, clarity = CLARITY_CLEAR) + if(clarity == CLARITY_DROWSY) + return drowsy_text_for(listener) + + var/list/out = list() + for(var/list/piece in render_pieces(listener, clarity)) + out += piece[1] + if(!length(out)) + return "" + var/body = listener.hallucinate_heard(jointext(out, ""), speaker) + if(singing && length(body)) + body = "[sing_note] [body] [sing_note]" + return body + +/// Returns the message as a bare perceived string. +/datum/say_message/proc/plain_text_for(mob/listener) + var/list/plain = list() + for(var/datum/say_segment/segment as anything in segments) + plain += segment.plain_text_for(listener, speaker) + return jointext(plain, "") + +/// Returns the message as decorated text for drowsy listeners. +/datum/say_message/proc/drowsy_text_for(mob/listener) + if(isdeaf(listener)) + return "" + if(!prob(15)) + return "...You almost hear someone talking..." + + var/list/messages = text2list(plain_text_for(listener), " ") + if(!length(messages)) + return "...You almost hear someone talking..." + + var/list/punctuation = list(",", "!", ".", ";", "?") + var/heardword = messages[rand(1, messages.len)] + if(copytext(heardword, 1, 1) in punctuation) + heardword = copytext(heardword, 2) + if(copytext(heardword, -1) in punctuation) + heardword = copytext(heardword, 1, length(heardword)) + return "...You hear something about...[heardword]" + +/// Returns a flat string with languages stripped out. +/datum/say_message/proc/to_string() + var/list/out = list() + for(var/datum/say_segment/segment as anything in segments) + out += segment.text + return jointext(out, "") + +/// Collapses the whole message into a single segment in the given language. +/datum/say_message/proc/collapse_to(datum/language/language, text) + single_language = language + segments = list(new /datum/say_segment(text, language)) + +/// Returns a shallow copy. +/datum/say_message/proc/copy() + var/datum/say_message/copy = new + copy.speaker = speaker + copy.alt_name = alt_name + copy.raw_message = raw_message + copy.segments = segments + copy.single_language = single_language + copy.verb = verb + copy.whisper = whisper + copy.singing = singing + copy.sing_note = sing_note + copy.italics = italics + copy.font_size = font_size + copy.message_range = message_range + copy.message_mode = message_mode + copy.speech_sound = speech_sound + copy.sound_vol = sound_vol + copy.say_mode = say_mode + copy.base_clarity = base_clarity + copy.radio_parts = radio_parts + return copy diff --git a/code/modules/mob/say_message/say_segment.dm b/code/modules/mob/say_message/say_segment.dm new file mode 100644 index 00000000000..7635e053464 --- /dev/null +++ b/code/modules/mob/say_message/say_segment.dm @@ -0,0 +1,25 @@ +/// A single contiguous segment of spoken text in a single language. +/datum/say_segment + /// The segment's text. + var/text + + /// The language this segment is spoken in. + var/datum/language/language + +/// Constructs a new segment of spoken text in the given language. +/datum/say_segment/New(text, datum/language/language) + . = ..() + src.text = text + src.language = language + +/// Returns the segment as the given listener perceives it. +/datum/say_segment/proc/plain_text_for(mob/listener, mob/speaker) + if(language && (language.flags & KNOWONLYHEAR) && !listener.say_understands(speaker, language)) + return "" + var/rendered = text + if(language && (language.flags & NONVERBAL)) + if((!speaker || (listener.sdisabilities & BLIND) || listener.blinded || !(speaker in view(listener))) && !isghost(listener)) + rendered = stars(rendered) + if(!(language && (language.flags & INNATE)) && !listener.say_understands(speaker, language)) + rendered = language ? language.scramble(rendered, listener.languages) : stars(rendered) + return rendered diff --git a/code/modules/shareddream/brainghost.dm b/code/modules/shareddream/brainghost.dm index 26eea0f9602..6e831839049 100644 --- a/code/modules/shareddream/brainghost.dm +++ b/code/modules/shareddream/brainghost.dm @@ -80,11 +80,11 @@ awaken_impl(TRUE) body.handle_shared_dreaming(TRUE) -/mob/living/brain_ghost/say(var/message, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) +/mob/living/brain_ghost/say(var/text, var/datum/language/speaking = null, var/verb="says", var/alt_name="", var/ghost_hearing = GHOSTS_ALL_HEAR, var/whisper = FALSE, var/skip_edit = FALSE) if(!istype(body) || body.stat!=UNCONSCIOUS) return if(prob(20)) // 1/5 chance to mumble out anything you say in the dream. - var/list/words = text2list(replacetext(message, "\[^a-zA-Z]*$", ""), " ") + var/list/words = text2list(replacetext(text, "\[^a-zA-Z]*$", ""), " ") var/word_count = rand(1, words.len) // How many words to mumble out from within the sentance var/words_start = rand(1, words.len - (word_count - 1)) // Where the chunk of said words should start. @@ -97,7 +97,7 @@ body.say(mumble_message) // Mumble in Nral Malic body.set_stat(UNCONSCIOUS) // Toggled before anything else can happen. Ideally. - ..(message, speaking, verb="says", alt_name="") + ..(text, speaking, verb="says", alt_name="") /mob/living/brain_ghost/get_floating_chat_y_offset() diff --git a/code/unit_tests/mob_tests.dm b/code/unit_tests/mob_tests.dm index 1420dd455a8..ea46246e9b5 100644 --- a/code/unit_tests/mob_tests.dm +++ b/code/unit_tests/mob_tests.dm @@ -18,11 +18,14 @@ /mob/living/test var/heard = FALSE -/mob/living/test/on_hear_say(var/message) - . = ..(message) +// Clientless test mobs still need to reach the output sink to record a hearing. +/mob/living/test/has_chat_sink() + return TRUE + +/mob/living/test/on_hear_message(message) + ..() if(message) heard = TRUE - return . /datum/unit_test/mob_hear name = "MOB: Living mobs test for mob's speech" diff --git a/html/changelogs/lewis-saycode.yml b/html/changelogs/lewis-saycode.yml new file mode 100644 index 00000000000..308c68b7623 --- /dev/null +++ b/html/changelogs/lewis-saycode.yml @@ -0,0 +1,13 @@ +author: Llywelwyn + +delete-after: True + +changes: + - rscadd: "Adds code-switching: you can now speak in multiple languages in the same message." + - rscadd: "Adds audible emotes to the language list. They can be triggered with ,e." + - rscdel: "Removed SSrunechat." + - refactor: "Rewrote langchat in order to support multiple languages and partial comprehension." + - refactor: "Rewrote a vast majority of all saycode and the code responsible for displaying saytext to clients." + - bugfix: "Sleeping mobs are no longer able to understand all languages." + - bugfix: "Langchat now correctly shows the appropriate comprehension for all viewers rather than all viewers sharing the comprehension of the last viewer." + - bugfix: "Languages which are supposed to be invisible when not understood no longer appear as scrambled overhead text." diff --git a/maps/away/away_site/quarantined_outpost/quarantined_outpost_holopads.dm b/maps/away/away_site/quarantined_outpost/quarantined_outpost_holopads.dm index 396456db382..e64ee875507 100644 --- a/maps/away/away_site/quarantined_outpost/quarantined_outpost_holopads.dm +++ b/maps/away/away_site/quarantined_outpost/quarantined_outpost_holopads.dm @@ -122,9 +122,13 @@ sleep(loop_sleep_time) continue - hologram.langchat_speech("[I]", get_hearers_in_view(7, src), skip_language_check = TRUE) + hologram.langchat_speech("[I]", get_hearers_in_view(7, src)) + var/datum/say_message/msg = new + msg.speaker = holo_icon + msg.verb = things_to_say[I] + msg.collapse_to(GLOB.all_languages[language], "[I]") for(var/mob/living/L in get_hearers_in_view(7, src)) - L.hear_say("[I]", "[things_to_say[I]]", GLOB.all_languages[language], speaker = holo_icon) + L.hear_message(msg) if(soundblock) playsound(get_turf(src), text2path(soundblock), 60, FALSE) sleep(loop_sleep_time) diff --git a/maps/away/away_site/quarantined_outpost/quarantined_outpost_objects.dm b/maps/away/away_site/quarantined_outpost/quarantined_outpost_objects.dm index 6f942e5f955..e5be5e30c97 100644 --- a/maps/away/away_site/quarantined_outpost/quarantined_outpost_objects.dm +++ b/maps/away/away_site/quarantined_outpost/quarantined_outpost_objects.dm @@ -248,9 +248,13 @@ GLOBAL_LIST_EMPTY(trackables_pool) "AAaargh!" ) var/chosen_sentence = pick(mimic_sentences) - langchat_speech("[chosen_sentence]", get_hearers_in_view(7, src), skip_language_check = TRUE) + langchat_speech("[chosen_sentence]", get_hearers_in_view(7, src)) + var/datum/say_message/msg = new + msg.speaker = src + msg.verb = "says" + msg.collapse_to(GLOB.all_languages[language], "[chosen_sentence]") for(var/mob/living/L in get_hearers_in_view(7, src)) - L.hear_say("[chosen_sentence]", "says", GLOB.all_languages[language], speaker = src) + L.hear_message(msg) /mob/living/simple_animal/hostile/revivable/abomination/on_attack_mob(var/mob/hit_mob, var/obj/item/organ/external/limb) . = ..()