Files
Bubberstation/code/datums/brain_damage/imaginary_friend.dm
T
_0StevenandGitHub 089c6a8f94 Refactors say modes and custom say verbs. Extends custom say verbs to more situations, forwards more spans. (#92127)
## About The Pull Request

Oh man, so this entire pr started because of two things:
1. A kinda hacky fix to #92123 that got closed a good while ago.
2. A borg I know mentioning you can't do custom say verbs over robotic
talk.

Which subsequently led me down this rabbit hole of say modes and custom
say verbs.
So! The most wide-reaching thing this does is merge the custom say
verb/radio emote logic that used to be specialcased in
`compose_message(...)` into `say_quote(...)`, renaming this to
`generate_messagepart(...)` with its new functionality. This means
things that don't use the exact same chain as living things talking
normally can still generate custom say verbs if given that message
modifier.

Then, we split up say modes into a "can we do this" and "try to do this"
check to reduce conflicts (like #92123), and forward more of our data to
the latter. This allows us to then edit the say modes to actually make
use of that data, and with the previous addition of
`generate_messagepart(...)` allow for custom say verbs to be used.

In doing this I realized the logging was kind of awkward and all over
the place, so we create the new logging helper `log_sayverb_talk(...)`
which handles selecting how we should log things based on the given
message modifiers.

For better or worse I forgot about this pr for a few weeks, so I don't
perfectly remember all the details, but those are the big key parts.
## Why It's Good For The Game

Fixes #92123.

I think custom say verbs are some of the best flavour we have for
talking over radio, and any situation benefits from that being possible.
It's great to be able to tap your microphone, and it's hilarious for an
AI to be able to emote beaming an image directly into the heads of their
borgs over robotic talk.

The rest is mostly cleanup.
2025-08-12 16:24:50 -05:00

545 lines
20 KiB
Plaintext

#define IMAGINARY_FRIEND_RANGE 9
#define IMAGINARY_FRIEND_SPEECH_RANGE IMAGINARY_FRIEND_RANGE
#define IMAGINARY_FRIEND_EXTENDED_SPEECH_RANGE 999
/datum/brain_trauma/special/imaginary_friend
name = "Imaginary Friend"
desc = "Patient can see and hear an imaginary person."
scan_desc = "partial schizophrenia"
gain_text = span_notice("You feel in good company, for some reason.")
lose_text = span_warning("You feel lonely again.")
var/mob/eye/imaginary_friend/friend
var/friend_initialized = FALSE
/datum/brain_trauma/special/imaginary_friend/on_gain()
var/mob/living/M = owner
if(M.stat == DEAD || !M.client)
return FALSE
. = ..()
make_friend()
get_ghost()
/datum/brain_trauma/special/imaginary_friend/on_life(seconds_per_tick, times_fired)
if(get_dist(owner, friend) > 9)
friend.recall()
if(!friend)
qdel(src)
return
if(!friend.client && friend_initialized)
addtimer(CALLBACK(src, PROC_REF(reroll_friend)), 1 MINUTES)
/datum/brain_trauma/special/imaginary_friend/on_death()
..()
qdel(src) //friend goes down with the ship
/datum/brain_trauma/special/imaginary_friend/on_lose()
..()
QDEL_NULL(friend)
//If the friend goes afk, make a brand new friend. Plenty of fish in the sea of imagination.
/datum/brain_trauma/special/imaginary_friend/proc/reroll_friend()
if(friend.client) //reconnected
return
friend_initialized = FALSE
QDEL_NULL(friend)
make_friend()
get_ghost()
/datum/brain_trauma/special/imaginary_friend/proc/make_friend()
friend = new(get_turf(owner))
/// Tries a poll for the imaginary friend
/datum/brain_trauma/special/imaginary_friend/proc/get_ghost()
var/mob/chosen_one = SSpolling.poll_ghosts_for_target(
question = "Do you want to play as [span_danger("[owner.real_name]'s")] [span_notice("imaginary friend")]?",
check_jobban = ROLE_PAI,
poll_time = 20 SECONDS,
checked_target = owner,
ignore_category = POLL_IGNORE_IMAGINARYFRIEND,
alert_pic = owner,
role_name_text = "imaginary friend",
)
add_friend(chosen_one)
/// Yay more friends!
/datum/brain_trauma/special/imaginary_friend/proc/add_friend(mob/dead/observer/ghost)
if(isnull(ghost))
qdel(src)
return
friend.PossessByPlayer(ghost.ckey)
friend.attach_to_owner(owner)
friend.setup_appearance()
friend_initialized = TRUE
friend.log_message("became [key_name(owner)]'s split personality.", LOG_GAME)
message_admins("[ADMIN_LOOKUPFLW(friend)] became [ADMIN_LOOKUPFLW(owner)]'s split personality.")
/mob/eye/imaginary_friend
name = "imaginary friend"
real_name = "imaginary friend"
move_on_shuttle = TRUE
desc = "A wonderful yet fake friend."
sight = NONE
mouse_opacity = MOUSE_OPACITY_ICON
see_invisible = SEE_INVISIBLE_LIVING
invisibility = INVISIBILITY_MAXIMUM
has_emotes = TRUE
var/icon/human_image
var/image/current_image
var/hidden = FALSE
var/move_delay = 0
var/mob/living/owner
var/bubble_icon = "default"
/// Whether our host and other imaginary friends can hear us only when nearby or practically anywhere.
var/extended_message_range = TRUE
/mob/eye/imaginary_friend/Login()
. = ..()
if(!. || !client)
return FALSE
if(owner)
greet()
Show()
/mob/eye/imaginary_friend/proc/greet()
to_chat(src, span_notice("<b>You are the imaginary friend of [owner]!</b>"))
to_chat(src, span_notice("You are absolutely loyal to your friend, no matter what."))
to_chat(src, span_notice("You cannot directly influence the world around you, but you can see what [owner] cannot."))
/**
* Arguments:
* * imaginary_friend_owner - The living mob that owns the imaginary friend.
* * appearance_from_prefs - If this is a valid set of prefs, the appearance of the imaginary friend is based on these prefs.
*/
/mob/eye/imaginary_friend/Initialize(mapload)
. = ..()
var/static/list/grantable_actions = list(
/datum/action/innate/imaginary_join,
/datum/action/innate/imaginary_hide,
)
grant_actions_by_list(grantable_actions)
/// Links this imaginary friend to the provided mob
/mob/eye/imaginary_friend/proc/attach_to_owner(mob/living/imaginary_friend_owner)
owner = imaginary_friend_owner
if(!owner.imaginary_group)
owner.imaginary_group = list(owner)
owner.imaginary_group += src
greet()
/// Copies appearance from passed player prefs, or randomises them if none are provided
/mob/eye/imaginary_friend/proc/setup_appearance(datum/preferences/appearance_from_prefs = null)
if(appearance_from_prefs)
INVOKE_ASYNC(src, PROC_REF(setup_friend_from_prefs), appearance_from_prefs)
else
INVOKE_ASYNC(src, PROC_REF(setup_friend))
/// Randomise friend name and appearance
/mob/eye/imaginary_friend/proc/setup_friend()
gender = pick(MALE, FEMALE)
real_name = generate_random_name_species_based(gender, FALSE, /datum/species/human)
name = real_name
human_image = get_flat_human_icon(null, pick(SSjob.joinable_occupations))
Show()
/**
* Sets up the imaginary friend's name and look using a set of datum preferences.
*
* Arguments:
* * appearance_from_prefs - If this is a valid set of prefs, the appearance of the imaginary friend is based on the currently selected character in them. Otherwise, it's random.
*/
/mob/eye/imaginary_friend/proc/setup_friend_from_prefs(datum/preferences/appearance_from_prefs)
if(!istype(appearance_from_prefs))
stack_trace("Attempted to create imaginary friend appearance from null prefs. Using random appearance.")
setup_friend()
return
real_name = appearance_from_prefs.read_preference(/datum/preference/name/real_name)
name = real_name
// Determine what job is marked as 'High' priority.
var/datum/job/appearance_job
var/highest_pref = 0
for(var/job in appearance_from_prefs.job_preferences)
var/this_pref = appearance_from_prefs.job_preferences[job]
if(this_pref > highest_pref)
appearance_job = SSjob.get_job(job)
highest_pref = this_pref
if(!appearance_job)
appearance_job = SSjob.get_job(JOB_ASSISTANT)
if(istype(appearance_job, /datum/job/ai))
human_image = icon('icons/mob/silicon/ai.dmi', icon_state = resolve_ai_icon(appearance_from_prefs.read_preference(/datum/preference/choiced/ai_core_display)), dir = SOUTH)
else if(istype(appearance_job, /datum/job/cyborg))
human_image = icon('icons/mob/silicon/robots.dmi', icon_state = "robot")
else
human_image = get_flat_human_icon(null, appearance_job, appearance_from_prefs)
Show()
/// Returns all member clients of the imaginary_group
/mob/eye/imaginary_friend/proc/group_clients()
var/group_clients = list()
for(var/mob/person as anything in owner.imaginary_group)
if(person.client)
group_clients += person.client
return group_clients
/mob/eye/imaginary_friend/proc/Show()
if(!client || !owner) //nobody home
return
var/list/friend_clients = group_clients() - src.client
//Remove old image from group
remove_image_from_clients(current_image, friend_clients)
//Generate image from the static icon and the current dir
current_image = image(human_image, src, , MOB_LAYER, dir=src.dir)
current_image.override = TRUE
current_image.name = name
if(hidden)
current_image.alpha = 150
//Add new image to owner and friend
if(!hidden)
add_image_to_clients(current_image, friend_clients)
src.client.images |= current_image
/mob/eye/imaginary_friend/Destroy()
if(owner?.client)
owner.client.images.Remove(human_image)
if(client)
client.images.Remove(human_image)
owner.imaginary_group -= src
return ..()
/mob/eye/imaginary_friend/Hear(message, atom/movable/speaker, datum/language/message_language, raw_message, radio_freq, freq_name, freq_color, list/spans, list/message_mods = list(), message_range)
if (safe_read_pref(client, /datum/preference/toggle/enable_runechat) && (safe_read_pref(client, /datum/preference/toggle/enable_runechat_non_mobs) || ismob(speaker)))
create_chat_message(speaker, message_language, raw_message, spans)
to_chat(src, compose_message(speaker, message_language, raw_message, radio_freq, freq_name, freq_color, spans, message_mods))
/mob/eye/imaginary_friend/send_speech(message, range = IMAGINARY_FRIEND_SPEECH_RANGE, obj/source = src, bubble_type = bubble_icon, list/spans = list(), datum/language/message_language = null, list/message_mods = list(), forced = null)
message = get_message_mods(message, message_mods)
if(message_mods[RADIO_EXTENSION] == MODE_ADMIN)
SSadmin_verbs.dynamic_invoke_verb(client, /datum/admin_verb/cmd_admin_say, message)
return
if(message_mods[RADIO_EXTENSION] == MODE_DEADMIN)
SSadmin_verbs.dynamic_invoke_verb(client, /datum/admin_verb/dsay, message)
return
if(check_emote(message, forced))
return
message = check_for_custom_say_emote(message, message_mods)
message = capitalize(message)
if(message_mods[MODE_SING])
var/randomnote = pick("♩", "♪", "♫")
message = "[randomnote] [capitalize(message)] [randomnote]"
spans |= SPAN_SINGING
if(extended_message_range)
range = IMAGINARY_FRIEND_EXTENDED_SPEECH_RANGE
var/eavesdrop_range = 0
if(!(message_mods[MODE_CUSTOM_SAY_ERASE_INPUT]))
if(message_mods[WHISPER_MODE] == MODE_WHISPER)
spans |= SPAN_ITALICS
eavesdrop_range = EAVESDROP_EXTRA_RANGE
range = WHISPER_RANGE
log_sayverb_talk(message, message_mods, tag = "imaginary friend", forced_by = forced)
var/messagepart = generate_messagepart(message, spans, message_mods)
var/rendered = "[span_name("[name]")] [messagepart]"
var/dead_rendered = "[span_name("[name] (Imaginary friend of [owner])")] [messagepart]"
var/language = message_language || owner.get_selected_language()
Hear(rendered, src, language, message, null, null, null, spans, message_mods) // We always hear what we say
var/group = owner.imaginary_group - src // The people in our group don't, so we have to exclude ourselves not to hear twice
for(var/mob/person in group)
person.Hear(null, src, language, message, null, null, null, spans, message_mods, range)
// Speech bubble, but only for those who have runechat off
var/list/speech_bubble_recipients = list()
for(var/mob/user as anything in (group + src)) // Add ourselves back in
if((safe_read_pref(user.client, /datum/preference/toggle/enable_runechat) || (SSlag_switch.measures[DISABLE_RUNECHAT] && !HAS_TRAIT(src, TRAIT_BYPASS_MEASURES))))
speech_bubble_recipients.Add(user.client)
var/image/bubble = image('icons/mob/effects/talk.dmi', src, "[bubble_type][say_test(message)]", FLY_LAYER)
SET_PLANE_EXPLICIT(bubble, ABOVE_GAME_PLANE, src)
bubble.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA
INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(flick_overlay_global), bubble, speech_bubble_recipients, 3 SECONDS)
LAZYADD(update_on_z, bubble)
addtimer(CALLBACK(src, PROC_REF(clear_saypopup), bubble), 3.5 SECONDS)
var/turf/center_turf = get_turf(src)
if(!center_turf)
return
for(var/mob/dead_player in GLOB.dead_mob_list)
if(dead_player.z != z || get_dist(src, dead_player) > 7)
if(eavesdrop_range)
if(!(get_chat_toggles(dead_player.client) & CHAT_GHOSTWHISPER))
continue
else if(!(get_chat_toggles(dead_player.client) & CHAT_GHOSTEARS))
continue
var/link = FOLLOW_LINK(dead_player, owner)
to_chat(dead_player, "[link] [dead_rendered]")
/mob/eye/imaginary_friend/proc/clear_saypopup(image/say_popup)
LAZYREMOVE(update_on_z, say_popup)
/mob/eye/imaginary_friend/whisper(message, bubble_type, list/spans = list(), sanitize = TRUE, datum/language/language, ignore_spam = FALSE, forced, filterproof)
if(!message)
return
say("#[message]", bubble_type, spans, sanitize, language, ignore_spam, forced, filterproof)
/datum/emote/imaginary_friend
mob_type_allowed_typecache = /mob/eye/imaginary_friend
// We have to create our own since we can only show emotes to ourselves and our owner
/datum/emote/imaginary_friend/run_emote(mob/user, params, type_override, intentional = FALSE)
user.log_talk(message, LOG_EMOTE)
if(!can_run_emote(user, FALSE, intentional))
return FALSE
var/msg = select_message_type(user, message, intentional)
if(params && message_param)
msg = select_param(user, params)
msg = replace_pronoun(user, msg)
if(!msg)
return TRUE
var/mob/eye/imaginary_friend/friend = user
var/dchatmsg = "[span_bold("[friend] (Imaginary friend of [friend.owner])")] [msg]"
message = "[span_name("[user]")] [msg]"
var/user_turf = get_turf(user)
if (user.client)
for(var/mob/ghost as anything in GLOB.dead_mob_list)
if(!ghost.client || isnewplayer(ghost))
continue
if(get_chat_toggles(ghost.client) & CHAT_GHOSTSIGHT && !(ghost in viewers(user_turf, null)))
ghost.show_message("[FOLLOW_LINK(ghost, user)] [dchatmsg]")
for(var/mob/person in friend.owner.imaginary_group)
to_chat(person, message)
if(safe_read_pref(person.client, /datum/preference/toggle/enable_runechat))
person.create_chat_message(friend, raw_message = msg, runechat_flags = EMOTE_MESSAGE)
return TRUE
/datum/emote/imaginary_friend/point
key = "point"
key_third_person = "points"
message = "points."
message_param = "points at %t."
/datum/emote/imaginary_friend/point/run_emote(mob/eye/imaginary_friend/friend, params, type_override, intentional)
message_param = initial(message_param) // reset
return ..()
/datum/emote/imaginary_friend/custom
key = "me"
key_third_person = "custom"
message = null
/datum/emote/imaginary_friend/custom/can_run_emote(mob/user, status_check, intentional)
return ..() && intentional
/datum/emote/imaginary_friend/custom/run_emote(mob/user, params, type_override = null, intentional = FALSE)
if(!can_run_emote(user, TRUE, intentional))
return FALSE
if(is_banned_from(user.ckey, "Emote"))
to_chat(user, span_boldwarning("You cannot send custom emotes (banned)."))
return FALSE
else if(QDELETED(user))
return FALSE
else if(user.client && user.client.prefs.muted & MUTE_IC)
to_chat(user, span_boldwarning("You cannot send IC messages (muted)."))
return FALSE
else if(!params)
message = copytext(sanitize(input("Choose an emote to display.") as text|null), 1, MAX_MESSAGE_LEN)
else
message = params
. = ..()
message = null
/datum/emote/imaginary_friend/custom/replace_pronoun(mob/user, message)
return message
// Another snowflake proc, when will they end... should have refactored it differently
/mob/eye/imaginary_friend/point_at(atom/pointed_atom)
if(!isturf(loc))
return
if (pointed_atom in src)
create_point_bubble(pointed_atom)
return
var/turf/tile = get_turf(pointed_atom)
if (!tile)
return
var/turf/our_tile = get_turf(src)
var/obj/visual = image('icons/hud/screen_gen.dmi', our_tile, "arrow", FLY_LAYER)
INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(flick_overlay_global), visual, group_clients(), 2.5 SECONDS)
animate(visual, pixel_x = (tile.x - our_tile.x) * ICON_SIZE_X + pointed_atom.pixel_x, pixel_y = (tile.y - our_tile.y) * ICON_SIZE_Y + pointed_atom.pixel_y, time = 1.7, easing = EASE_OUT)
/mob/eye/imaginary_friend/create_thinking_indicator()
if(active_thinking_indicator || active_typing_indicator || !HAS_TRAIT(src, TRAIT_THINKING_IN_CHARACTER))
return FALSE
active_thinking_indicator = image('icons/mob/effects/talk.dmi', src, "[bubble_icon]3", TYPING_LAYER)
add_image_to_clients(active_thinking_indicator, group_clients())
/mob/eye/imaginary_friend/remove_thinking_indicator()
if(!active_thinking_indicator)
return FALSE
remove_image_from_clients(active_thinking_indicator, group_clients())
active_thinking_indicator = null
/mob/eye/imaginary_friend/create_typing_indicator()
if(active_typing_indicator || active_thinking_indicator || !HAS_TRAIT(src, TRAIT_THINKING_IN_CHARACTER))
return FALSE
active_typing_indicator = image('icons/mob/effects/talk.dmi', src, "[bubble_icon]0", TYPING_LAYER)
add_image_to_clients(active_typing_indicator, group_clients())
/mob/eye/imaginary_friend/remove_typing_indicator()
if(!active_typing_indicator)
return FALSE
remove_image_from_clients(active_typing_indicator, group_clients())
active_typing_indicator = null
/mob/eye/imaginary_friend/remove_all_indicators()
REMOVE_TRAIT(src, TRAIT_THINKING_IN_CHARACTER, CURRENTLY_TYPING_TRAIT)
remove_thinking_indicator()
remove_typing_indicator()
/mob/eye/imaginary_friend/Move(NewLoc, Dir = 0)
if(world.time < move_delay)
return FALSE
setDir(Dir)
if(get_dist(src, owner) > 9)
recall()
move_delay = world.time + 10
return FALSE
abstract_move(NewLoc)
move_delay = world.time + 1
/mob/eye/imaginary_friend/setDir(newdir)
. = ..()
Show() // The image does not actually update until Show() gets called
/mob/eye/imaginary_friend/proc/recall()
if(!owner || loc == owner)
return FALSE
abstract_move(owner)
/datum/action/innate/imaginary_join
name = "Join"
desc = "Join your owner, following them from inside their mind."
button_icon = 'icons/mob/actions/actions_minor_antag.dmi'
background_icon_state = "bg_revenant"
overlay_icon_state = "bg_revenant_border"
button_icon_state = "join"
/datum/action/innate/imaginary_join/Activate()
var/mob/eye/imaginary_friend/I = owner
I.recall()
/datum/action/innate/imaginary_hide
name = "Hide"
desc = "Hide yourself from your owner's sight."
button_icon = 'icons/mob/actions/actions_minor_antag.dmi'
background_icon_state = "bg_revenant"
overlay_icon_state = "bg_revenant_border"
button_icon_state = "hide"
/datum/action/innate/imaginary_hide/proc/update_status()
var/mob/eye/imaginary_friend/I = owner
if(I.hidden)
name = "Show"
desc = "Become visible to your owner."
button_icon_state = "unhide"
else
name = "Hide"
desc = "Hide yourself from your owner's sight."
button_icon_state = "hide"
build_all_button_icons()
/datum/action/innate/imaginary_hide/Activate()
var/mob/eye/imaginary_friend/fake_friend = owner
fake_friend.hidden = !fake_friend.hidden
fake_friend.Show()
build_all_button_icons(UPDATE_BUTTON_NAME|UPDATE_BUTTON_ICON)
/datum/action/innate/imaginary_hide/update_button_name(atom/movable/screen/movable/action_button/button, force)
var/mob/eye/imaginary_friend/fake_friend = owner
if(fake_friend.hidden)
name = "Show"
desc = "Become visible to your owner."
else
name = "Hide"
desc = "Hide yourself from your owner's sight."
return ..()
/datum/action/innate/imaginary_hide/apply_button_icon(atom/movable/screen/movable/action_button/current_button, force = FALSE)
var/mob/eye/imaginary_friend/fake_friend = owner
if(fake_friend.hidden)
button_icon_state = "unhide"
else
button_icon_state = "hide"
return ..()
//down here is the trapped mind
//like imaginary friend but a lot less imagination and more like mind prison//
/datum/brain_trauma/special/imaginary_friend/trapped_owner
name = "Trapped Victim"
desc = "Patient appears to be targeted by an invisible entity."
gain_text = ""
lose_text = ""
random_gain = FALSE
/datum/brain_trauma/special/imaginary_friend/trapped_owner/make_friend()
friend = new /mob/eye/imaginary_friend/trapped(get_turf(owner), src)
/datum/brain_trauma/special/imaginary_friend/trapped_owner/reroll_friend() //no rerolling- it's just the last owner's hell
if(friend.client) //reconnected
return
friend_initialized = FALSE
QDEL_NULL(friend)
qdel(src)
/datum/brain_trauma/special/imaginary_friend/trapped_owner/get_ghost() //no randoms
return
/mob/eye/imaginary_friend/trapped
name = "figment of imagination?"
real_name = "figment of imagination?"
desc = "The previous host of this body."
/mob/eye/imaginary_friend/trapped/greet()
to_chat(src, span_notice(span_bold("You have managed to hold on as a figment of the new host's imagination!")))
to_chat(src, span_notice("All hope is lost for you, but at least you may interact with your host. You do not have to be loyal to them."))
to_chat(src, span_notice("You cannot directly influence the world around you, but you can see what the host cannot."))
/mob/eye/imaginary_friend/trapped/setup_friend()
real_name = "[owner.real_name]?"
name = real_name
human_image = icon('icons/mob/simple/lavaland/lavaland_monsters.dmi', icon_state = "curseblob")
#undef IMAGINARY_FRIEND_RANGE
#undef IMAGINARY_FRIEND_SPEECH_RANGE
#undef IMAGINARY_FRIEND_EXTENDED_SPEECH_RANGE