[MIRROR] TGUI Say (#8771)

Co-authored-by: ShadowLarkens <shadowlarkens@gmail.com>
Co-authored-by: Kashargul <KashL@t-online.de>
This commit is contained in:
CHOMPStation2
2024-08-22 04:50:21 -07:00
committed by GitHub
parent 81f6b67f6b
commit 2f4c193f47
50 changed files with 1924 additions and 142 deletions

View File

@@ -0,0 +1,9 @@
// Used to direct channels to speak into.
#define SAY_CHANNEL "Say"
#define RADIO_CHANNEL "Radio"
#define ME_CHANNEL "Me"
#define OOC_CHANNEL "OOC"
#define ADMIN_CHANNEL "Admin"
#define LOOC_CHANNEL "LOOC"
#define WHIS_CHANNEL "Whis"
#define SUBTLE_CHANNEL "Subtle"

View File

@@ -5,59 +5,118 @@
#define ADD_TRAIT(target, trait, source) \
do { \
var/list/_L; \
if (!target.status_traits) { \
target.status_traits = list(); \
_L = target.status_traits; \
if (!target._status_traits) { \
target._status_traits = list(); \
_L = target._status_traits; \
_L[trait] = list(source); \
SEND_SIGNAL(target, SIGNAL_ADDTRAIT(trait), trait); \
} else { \
_L = target.status_traits; \
_L = target._status_traits; \
if (_L[trait]) { \
_L[trait] |= list(source); \
} else { \
_L[trait] = list(source); \
SEND_SIGNAL(target, SIGNAL_ADDTRAIT(trait), trait); \
} \
} \
} while (0)
#define REMOVE_TRAIT(target, trait, sources) \
do { \
var/list/_L = target.status_traits; \
var/list/_L = target._status_traits; \
var/list/_S; \
if (sources && !islist(sources)) { \
_S = list(sources); \
} else { \
_S = sources\
}; \
if (_L && _L[trait]) { \
if (_L?[trait]) { \
for (var/_T in _L[trait]) { \
if ((!_S && (_T != ROUNDSTART_TRAIT)) || (_T in _S)) { \
_L[trait] -= _T \
} \
};\
if (!length(_L[trait])) { \
_L -= trait \
_L -= trait; \
SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(trait), trait); \
}; \
if (!length(_L)) { \
target.status_traits = null \
target._status_traits = null \
}; \
} \
} while (0)
#define REMOVE_TRAIT_NOT_FROM(target, trait, sources) \
do { \
var/list/_traits_list = target._status_traits; \
var/list/_sources_list; \
if (sources && !islist(sources)) { \
_sources_list = list(sources); \
} else { \
_sources_list = sources\
}; \
if (_traits_list?[trait]) { \
for (var/_trait_source in _traits_list[trait]) { \
if (!(_trait_source in _sources_list)) { \
_traits_list[trait] -= _trait_source \
} \
};\
if (!length(_traits_list[trait])) { \
_traits_list -= trait; \
SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(trait), trait); \
}; \
if (!length(_traits_list)) { \
target._status_traits = null \
}; \
} \
} while (0)
#define REMOVE_TRAITS_NOT_IN(target, sources) \
do { \
var/list/_L = target.status_traits; \
var/list/_L = target._status_traits; \
var/list/_S = sources; \
if (_L) { \
for (var/_T in _L) { \
_L[_T] &= _S;\
if (!length(_L[_T])) { \
_L -= _T } \
};\
if (!length(_L)) { \
target.status_traits = null\
_L -= _T; \
SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T), _T); \
}; \
};\
if (!length(_L)) { \
target._status_traits = null\
};\
}\
} while (0)
#define HAS_TRAIT(target, trait) (target.status_traits ? (target.status_traits[trait] ? TRUE : FALSE) : FALSE)
#define HAS_TRAIT_FROM(target, trait, source) (target.status_traits ? (target.status_traits[trait] ? (source in target.status_traits[trait]) : FALSE) : FALSE)
#define HAS_TRAIT_FROM_ONLY(target, trait, source) (HAS_TRAIT(target, trait) && (source in target.status_traits[trait]) && (length(target.status_traits[trait]) == 1))
#define HAS_TRAIT_NOT_FROM(target, trait, source) (HAS_TRAIT(target, trait) && (length(target.status_traits[trait] - source) > 0))
#define REMOVE_TRAITS_IN(target, sources) \
do { \
var/list/_L = target._status_traits; \
var/list/_S = sources; \
if (sources && !islist(sources)) { \
_S = list(sources); \
} else { \
_S = sources\
}; \
if (_L) { \
for (var/_T in _L) { \
_L[_T] -= _S;\
if (!length(_L[_T])) { \
_L -= _T; \
SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T)); \
}; \
};\
if (!length(_L)) { \
target._status_traits = null\
};\
}\
} while (0)
#define HAS_TRAIT(target, trait) (target._status_traits?[trait] ? TRUE : FALSE)
#define HAS_TRAIT_FROM(target, trait, source) (HAS_TRAIT(target, trait) && (source in target._status_traits[trait]))
#define HAS_TRAIT_FROM_ONLY(target, trait, source) (HAS_TRAIT(target, trait) && (source in target._status_traits[trait]) && (length(target._status_traits[trait]) == 1))
#define HAS_TRAIT_NOT_FROM(target, trait, source) (HAS_TRAIT(target, trait) && (length(target._status_traits[trait] - source) > 0))
/// Returns a list of trait sources for this trait. Only useful for wacko cases and internal futzing
/// You should not be using this
#define GET_TRAIT_SOURCES(target, trait) (target._status_traits?[trait] || list())
/// Returns the amount of sources for a trait. useful if you don't want to have a "thing counter" stuck around all the time
#define COUNT_TRAIT_SOURCES(target, trait) length(GET_TRAIT_SOURCES(target, trait))
/// A simple helper for checking traits in a mob's mind
#define HAS_MIND_TRAIT(target, trait) (HAS_TRAIT(target, trait) || (target.mind ? HAS_TRAIT(target.mind, trait) : FALSE))

View File

@@ -0,0 +1,11 @@
// This file contains all of the "static" define strings that tie to a trait.
// WARNING: The sections here actually matter in this file as it's tested by CI. Please do not toy with the sections."
// BEGIN TRAIT DEFINES
/*
Remember to update _globalvars/traits.dm if you're adding/removing/renaming traits.
*/
/// Trait given to a mob that is currently thinking (giving off the "thinking" icon), used in an IC context
#define TRAIT_THINKING_IN_CHARACTER "currently_thinking_IC"

View File

@@ -0,0 +1,7 @@
// This file contains all of the trait sources, or all of the things that grant traits.
// Several things such as `type` or `REF(src)` may be used in the ADD_TRAIT() macro as the "source", but this file contains all of the defines for immutable static strings.
/// cannot be removed without admin intervention
// #define ROUNDSTART_TRAIT "roundstart" //CHOMPRemove, already exists in traits
/// This trait comes from when a mob is currently typing.
#define CURRENTLY_TYPING_TRAIT "currently_typing"

View File

@@ -5,6 +5,7 @@
*/
GLOBAL_LIST_INIT(traits_by_type, list(
/mob = list(
"TRAIT_THINKING_IN_CHARACTER" = TRAIT_THINKING_IN_CHARACTER,
/*
"TRAIT_BLIND" = TRAIT_BLIND,
*/

View File

@@ -0,0 +1,14 @@
// This file should contain every single global trait in the game in a type-based list, as well as any additional trait-related information that's useful to have on a global basis.
// This file is used in linting, so make sure to add everything alphabetically and what-not.
// Do consider adding your trait entry to the similar list in `admin_tooling.dm` if you want it to be accessible to admins (which is probably the case for 75% of traits).
// Please do note that there is absolutely no bearing on what traits are added to what subtype of `/datum`, this is just an easily referenceable list sorted by type.
// The only thing that truly matters about traits is the code that is built to handle the traits, and where that code is located. Nothing else.
/* CHOMPRemove, see traits.dm
GLOBAL_LIST_INIT(traits_by_type, list(
/mob = list(
"TRAIT_THINKING_IN_CHARACTER" = TRAIT_THINKING_IN_CHARACTER,
)
))
*/

45
code/_helpers/traits.dm Normal file
View File

@@ -0,0 +1,45 @@
#define TRAIT_CALLBACK_ADD(target, trait, source) CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(___TraitAdd), ##target, ##trait, ##source)
#define TRAIT_CALLBACK_REMOVE(target, trait, source) CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(___TraitRemove), ##target, ##trait, ##source)
///DO NOT USE ___TraitAdd OR ___TraitRemove as a replacement for ADD_TRAIT / REMOVE_TRAIT defines. To be used explicitly for callback.
/proc/___TraitAdd(target, trait, source)
if(!target || !trait || !source)
return
if(islist(target))
for(var/datum/listed_target in target)
ADD_TRAIT(listed_target, trait, source)
return
ASSERT(isdatum(target), "Invalid target used in TRAIT_CALLBACK_ADD! Expected a datum reference, got [target] instead.")
var/datum/datum_target = target
ADD_TRAIT(datum_target, trait, source)
///DO NOT USE ___TraitAdd OR ___TraitRemove as a replacement for ADD_TRAIT / REMOVE_TRAIT defines. To be used explicitly for callback.
/proc/___TraitRemove(target, trait, source)
if(!target || !trait || !source)
return
if(islist(target))
for(var/datum/listed_target in target)
REMOVE_TRAIT(listed_target, trait, source)
return
ASSERT(isdatum(target), "Invalid target used in TRAIT_CALLBACK_REMOVE! Expected a datum reference, got [target] instead.")
var/datum/datum_target = target
REMOVE_TRAIT(datum_target, trait, source)
/// Proc that handles adding multiple traits to a target via a list. Must have a common source and target.
/datum/proc/add_traits(list/list_of_traits, source)
ASSERT(islist(list_of_traits), "Invalid arguments passed to add_traits! Invoked on [src] with [list_of_traits], source being [source].")
for(var/trait in list_of_traits)
ADD_TRAIT(src, trait, source)
/// Proc that handles removing multiple traits from a target via a list. Must have a common source and target.
/datum/proc/remove_traits(list/list_of_traits, source)
ASSERT(islist(list_of_traits), "Invalid arguments passed to remove_traits! Invoked on [src] with [list_of_traits], source being [source].")
for(var/trait in list_of_traits)
REMOVE_TRAIT(src, trait, source)

View File

@@ -23,6 +23,8 @@
/// Active timers with this datum as the target
var/list/_active_timers // CHOMPEdit
/// Status traits attached to this datum. associative list of the form: list(trait name (string) = list(source1, source2, source3,...))
var/list/_status_traits
/**
* Components attached to this datum

View File

@@ -0,0 +1,28 @@
/// Maps icon names to ref values
/datum/asset/json/icon_ref_map
name = "icon_ref_map"
early = TRUE
/datum/asset/json/icon_ref_map/generate()
var/list/data = list() //"icons/obj/drinks.dmi" => "[0xc000020]"
//var/start = "0xc000000"
var/value = 0
while(TRUE)
value += 1
var/ref = "\[0xc[num2text(value,6,16)]\]"
var/mystery_meat = locate(ref)
if(isicon(mystery_meat))
if(!isfile(mystery_meat)) // Ignore the runtime icons for now
continue
var/path = get_icon_dmi_path(mystery_meat) //Try to get the icon path
if(path)
data[path] = ref
else if(mystery_meat)
continue; //Some other non-icon resource, ogg/json/whatever
else //Out of resources end this, could also try to end this earlier as soon as runtime generated icons appear but eh
break;
return data

View File

@@ -1,12 +1,12 @@
/datum/asset/simple/tgui
// keep_local_name = TRUE
keep_local_name = TRUE
assets = list(
"tgui.bundle.js" = file("tgui/public/tgui.bundle.js"),
"tgui.bundle.css" = file("tgui/public/tgui.bundle.css"),
)
/datum/asset/simple/tgui_panel
// keep_local_name = TRUE
keep_local_name = TRUE
assets = list(
"tgui-panel.bundle.js" = file("tgui/public/tgui-panel.bundle.js"),
"tgui-panel.bundle.css" = file("tgui/public/tgui-panel.bundle.css"),

View File

@@ -132,6 +132,9 @@
asset_cache_preload_data(href_list["asset_cache_preload_data"])
return
if(href_list["commandbar_typing"])
handle_commandbar_typing(href_list)
switch(href_list["_src_"])
if("holder") hsrc = holder
if("mentorholder") hsrc = (check_rights(R_ADMIN, 0) ? holder : mentorholder)
@@ -204,6 +207,8 @@
stat_panel.subscribe(src, .proc/on_stat_panel_message)
// Instantiate tgui panel
tgui_say = new(src, "tgui_say")
initialize_commandbar_spy()
tgui_panel = new(src, "browseroutput")
GLOB.tickets.ClientLogin(src) // CHOMPedit - Tickets System
@@ -243,6 +248,7 @@
addtimer(CALLBACK(src, PROC_REF(check_panel_loaded)), 30 SECONDS)
// Initialize tgui panel
tgui_say.initialize()
tgui_panel.initialize()
connection_time = world.time

View File

@@ -240,7 +240,13 @@ var/list/_client_preferences_by_type
/datum/client_preference/show_typing_indicator/toggled(var/mob/preference_mob, var/enabled)
if(!enabled)
preference_mob.set_typing_indicator(FALSE)
preference_mob.client?.stop_thinking()
/datum/client_preference/show_typing_indicator_subtle
description ="Typing indicator (subtle)"
key = "SHOW_TYPING_SUBTLE"
enabled_description = "Show"
disabled_description = "Hide"
/datum/client_preference/show_ooc
description ="OOC chat"
@@ -423,6 +429,19 @@ var/list/_client_preferences_by_type
enabled_description = "Automatic"
disabled_description = "Manual Only"
/datum/client_preference/tgui_say
description = "TGUI Say: Use TGUI For Say Input"
key = "TGUI_SAY"
enabled_by_default = TRUE
enabled_description = "Yes"
disabled_description = "No"
/datum/client_preference/tgui_say_light
description = "TGUI Say: Use Light Mode"
key = "TGUI_SAY_LIGHT_MODE"
enabled_by_default = FALSE
enabled_description = "Yes"
disabled_description = "No"
/********************
* Staff Preferences *

View File

@@ -0,0 +1,77 @@
#define IC_VERBS list("say", "me", "whisper", "subtle")
/client/var/commandbar_thinking = FALSE
/client/var/commandbar_typing = FALSE
/client/proc/initialize_commandbar_spy()
src << output('html/typing_indicator.html', "commandbar_spy")
/client/proc/handle_commandbar_typing(href_list)
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator))
return
if(length(href_list["verb"]) < 1 || !(lowertext(href_list["verb"]) in IC_VERBS) || text2num(href_list["argument_length"]) < 1)
if(commandbar_typing)
commandbar_typing = FALSE
stop_typing()
if(commandbar_thinking)
commandbar_thinking = FALSE
stop_thinking()
return
if(!commandbar_thinking)
commandbar_thinking = TRUE
start_thinking(href_list["verb"])
if(!commandbar_typing)
commandbar_typing = TRUE
start_typing(href_list["verb"])
/** Sets the mob as "thinking" - with indicator and the TRAIT_THINKING_IN_CHARACTER trait */
/client/proc/start_thinking(channel)
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator))
return FALSE
if(channel == "Whis" || channel == "Subtle" || channel == "whisper" || channel == "subtle")
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle))
return FALSE
ADD_TRAIT(mob, TRAIT_THINKING_IN_CHARACTER, CURRENTLY_TYPING_TRAIT)
mob.create_thinking_indicator()
/** Removes typing/thinking indicators and flags the mob as not thinking */
/client/proc/stop_thinking(channel)
mob?.remove_all_indicators()
/**
* Handles the user typing. After a brief period of inactivity,
* signals the client mob to revert to the "thinking" icon.
*/
/client/proc/start_typing(channel)
var/mob/client_mob = mob
client_mob.remove_thinking_indicator()
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator) || !HAS_TRAIT(client_mob, TRAIT_THINKING_IN_CHARACTER))
return FALSE
if(channel == "Whis" || channel == "Subtle" || channel == "whisper" || channel == "subtle")
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle))
return FALSE
client_mob.create_typing_indicator()
addtimer(CALLBACK(src, PROC_REF(stop_typing), channel), 5 SECONDS, TIMER_UNIQUE | TIMER_OVERRIDE | TIMER_STOPPABLE)
/**
* Callback to remove the typing indicator after a brief period of inactivity.
* If the user was typing IC, the thinking indicator is shown.
*/
/client/proc/stop_typing(channel)
if(isnull(mob))
return FALSE
var/mob/client_mob = mob
client_mob.remove_typing_indicator()
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator) || !HAS_TRAIT(client_mob, TRAIT_THINKING_IN_CHARACTER))
return FALSE
if(channel == "Whis" || channel == "Subtle" || channel == "whisper" || channel == "subtle")
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle))
return FALSE
client_mob.create_thinking_indicator()
#undef IC_VERBS

View File

@@ -53,33 +53,6 @@
return loc
/mob/living/carbon/brain/set_typing_indicator(var/state)
if(isturf(loc))
return ..()
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator))
loc.cut_overlay(typing_indicator, TRUE)
return
var/cur_bubble_appearance = custom_speech_bubble
if(!cur_bubble_appearance || cur_bubble_appearance == "default")
cur_bubble_appearance = speech_bubble_appearance()
if(!typing_indicator || cur_typing_indicator != cur_bubble_appearance)
init_typing_indicator("[cur_bubble_appearance]_typing")
if(state && !typing)
add_overlay(typing_indicator, TRUE)
typing = TRUE
typing_indicator_active = typing_indicator
else if(typing)
cut_overlay(typing_indicator_active, TRUE)
typing = FALSE
if(typing_indicator_active != typing_indicator)
qdel(typing_indicator_active)
typing_indicator_active = null
return state
// Vorestation edit start
/mob/living/carbon/brain/verb/backup_ping()

View File

@@ -988,8 +988,8 @@
return
cut_overlays()
if(typing) //CHOMPAdd, needed as we don't have priority overlays anymore
add_overlay(typing_indicator, TRUE) //CHOMPAdd, needed as we don't have priority overlays anymore
add_overlay(active_thinking_indicator)
add_overlay(active_typing_indicator)
handle_status_indicators() //CHOMPAdd, needed as we don't have priority overlays anymore
icon = sprite_datum.sprite_icon

View File

@@ -219,10 +219,6 @@
var/get_rig_stats = 0 //Moved from computer.dm
var/typing
var/obj/effect/decal/typing_indicator
var/obj/effect/decal/typing_indicator_active
var/cur_typing_indicator
var/custom_speech_bubble = "default"
var/low_priority = FALSE //Skip processing life() if there's just no players on this Z-level

View File

@@ -1,9 +1,10 @@
/mob/proc/say(var/message, var/datum/language/speaking = null, var/whispering = 0)
return
/mob/verb/whisper(message as text)
/mob/verb/whisper(message as text) //CHOMPEdit
set name = "Whisper"
set category = "IC.Subtle" //CHOMPEdit
// set category = "IC.Subtle" //CHOMPEdit
set hidden = 1
//VOREStation Addition Start
if(forced_psay)
psay(message)
@@ -12,18 +13,18 @@
usr.say(message,whispering=1)
/mob/verb/say_verb(message as text)
/mob/verb/say_verb(message as text) //CHOMPEdit
set name = "Say"
set category = "IC.Chat" //CHOMPEdit
// set category = "IC.Chat" //CHOMPEdit
set instant = TRUE // CHOMPEdit
set hidden = 1
//VOREStation Addition Start
if(forced_psay)
psay(message)
return
//VOREStation Addition End
set_typing_indicator(FALSE)
client?.stop_thinking()
// CHOMPEdit Start
//queue this message because verbs are scheduled to process after SendMaps in the tick and speech is pretty expensive when it happens.
//by queuing this for next tick the mc can compensate for its cost instead of having speech delay the start of the next tick
@@ -31,9 +32,11 @@
QUEUE_OR_CALL_VERB_FOR(VERB_CALLBACK(src, TYPE_PROC_REF(/mob, say), message), SSspeech_controller)
// CHOMPEdit End
/mob/verb/me_verb(message as message)
/mob/verb/me_verb(message as message) //CHOMPEdit
set name = "Me"
set category = "IC.Chat" //CHOMPEdit
// set category = "IC.Chat" //CHOMPEdit
set desc = "Emote to nearby people (and your pred/prey)"
set hidden = 1
if(say_disabled) //This is here to try to identify lag problems
to_chat(usr, span_red("Speech is currently admin-disabled."))
@@ -52,7 +55,7 @@
message = sanitize_or_reflect(message,src) //VOREStation Edit - Reflect too-long messages (within reason)
//VOREStation Edit End
set_typing_indicator(FALSE)
client?.stop_thinking()
if(use_me)
custom_emote(usr.emote_type, message)
else

View File

@@ -7,3 +7,85 @@
ultimate_mob = to_check
to_check = to_check.loc
return ultimate_mob
/mob/verb/say_verb_ch()
set name = "Say CH"
set category = "IC.Chat"
client?.start_thinking()
client?.start_typing()
var/message = tgui_input_text(usr, "Speak to people in sight.\nType your message:", "Say")
client?.stop_thinking()
if(message)
say_verb(message)
/mob/verb/me_verb_ch()
set name = "Me CH"
set category = "IC.Chat"
set desc = "Emote to nearby people (and your pred/prey)"
client?.start_thinking()
client?.start_typing()
var/message = tgui_input_text(usr, "Emote to people in sight (and your pred/prey).\nType your message:", "Emote", multiline = TRUE)
client?.stop_thinking()
if(message)
me_verb(message)
/mob/verb/whisper_ch()
set name = "Whisper CH"
set category = "IC.Subtle"
var/message = tgui_input_text(usr, "Speak to nearby people.\nType your message:", "Whisper")
if(message)
whisper(message)
/mob/verb/me_verb_subtle_ch()
set name = "Subtle CH"
set category = "IC.Subtle"
set desc = "Emote to nearby people (and your pred/prey)"
var/message = tgui_input_text(usr, "Emote to nearby people (and your pred/prey).\nType your message:", "Subtle", multiline = TRUE)
if(message)
me_verb_subtle(message)
/mob/verb/me_verb_subtle_custom_ch()
set name = "Subtle (Custom) CH"
set category = "IC.Subtle"
set desc = "Emote to nearby people, with ability to choose which specific portion of people you wish to target."
var/message = tgui_input_text(usr, "Emote to nearby people, with ability to choose which specific portion of people you wish to target.\nType your message:", "Subtle (Custom)", multiline = TRUE)
if(message)
me_verb_subtle_custom(message)
/mob/verb/psay_ch()
set name = "Psay CH"
set category = "IC.Subtle"
var/message = tgui_input_text(usr, "Talk to people affected by complete absorbed or dominate predator/prey.\nType your message:", "Psay")
if(message)
psay(message)
/mob/verb/pme_ch()
set name = "Pme CH"
set category = "IC.Subtle"
var/message = tgui_input_text(usr, "Emote to people affected by complete absorbed or dominate predator/prey.\nType your message:", "Psay")
if(message)
pme(message)
/mob/living/verb/player_narrate_ch()
set name = "Narrate (Player) CH"
set category = "IC.Chat"
var/message = tgui_input_text(usr, "Narrate an action or event! An alternative to emoting, for when your emote shouldn't start with your name!\nType your message:", "Psay")
if(message)
player_narrate(message)

View File

@@ -2,10 +2,11 @@
////////////////////SUBTLE COMMAND////////////////////
//////////////////////////////////////////////////////
/mob/verb/me_verb_subtle(message as message) //This would normally go in say.dm
/mob/verb/me_verb_subtle(message as message) //This would normally go in say.dm //CHOMPEdit
set name = "Subtle"
set category = "IC.Subtle" //CHOMPEdit
// set category = "IC.Subtle" //CHOMPEdit
set desc = "Emote to nearby people (and your pred/prey)"
set hidden = 1
if(say_disabled) //This is here to try to identify lag problems
to_chat(usr, "Speech is currently admin-disabled.")
@@ -18,15 +19,15 @@
if(!message)
return
set_typing_indicator(FALSE)
client?.stop_thinking()
if(use_me)
usr.emote_vr("me",4,message)
else
usr.emote_vr(message)
/mob/verb/me_verb_subtle_custom(message as message) // Literally same as above but with mode_selection set to true
/mob/verb/me_verb_subtle_custom(message as message) // Literally same as above but with mode_selection set to true //CHOMPEdit
set name = "Subtle (Custom)"
set category = "IC.Subtle" //CHOMPEdit
// set category = "IC.Subtle" //CHOMPEdit
set desc = "Emote to nearby people, with ability to choose which specific portion of people you wish to target."
if(say_disabled) //This is here to try to identify lag problems
@@ -40,7 +41,7 @@
if(!message)
return
set_typing_indicator(FALSE)
client?.stop_thinking()
if(use_me)
usr.emote_vr("me",4,message,TRUE)
else
@@ -259,8 +260,8 @@
///// PSAY /////
/mob/verb/psay(message as text)
set category = "IC.Subtle" //CHOMPEdit
/mob/verb/psay(message as text) //CHOMPEdit
// set category = "IC.Subtle" //CHOMPEdit
set name = "Psay"
set desc = "Talk to people affected by complete absorbed or dominate predator/prey."
@@ -365,8 +366,8 @@
///// PME /////
/mob/verb/pme(message as message)
set category = "IC.Subtle" //CHOMPEdit
/mob/verb/pme(message as message) //CHOMPEdit
// set category = "IC.Subtle" //CHOMPEdit
set name = "Pme"
set desc = "Emote to people affected by complete absorbed or dominate predator/prey."
@@ -469,8 +470,8 @@
M.forced_psay = FALSE
M.me_verb(message)
/mob/living/verb/player_narrate(message as message)
set category = "IC.Chat" //CHOMPEdit
/mob/living/verb/player_narrate(message as message) //CHOMPEdit
// set category = "IC.Chat" //CHOMPEdit
set name = "Narrate (Player)"
set desc = "Narrate an action or event! An alternative to emoting, for when your emote shouldn't start with your name!"

View File

@@ -1,4 +1,4 @@
/proc/generate_speech_bubble(var/bubble_loc, var/speech_state, var/set_layer = FLOAT_LAYER)
/proc/generate_speech_bubble(bubble_loc, speech_state, set_layer = FLOAT_LAYER)
var/image/I = image('icons/mob/talk_vr.dmi', bubble_loc, speech_state, set_layer) //VOREStation Edit - talk_vr.dmi instead of talk.dmi for right-side icons
I.appearance_flags |= (RESET_COLOR|PIXEL_SCALE) //VOREStation Edit
/* //VOREStation Removal Start
@@ -11,76 +11,69 @@
*/ //VOREStation Removal Start
return I
/mob/proc/init_typing_indicator(var/set_state = "typing")
if(typing_indicator)
qdel(typing_indicator)
typing_indicator = null
typing_indicator = new
typing_indicator.appearance = generate_speech_bubble(null, set_state)
typing_indicator.appearance_flags |= (RESET_COLOR|PIXEL_SCALE) //VOREStation Edit
/mob/verb/say_wrapper()
set name = "Say verb"
set category = "IC.TGUI Say" //CHOMPEdit
/mob/proc/set_typing_indicator(var/state) //Leaving this here for mobs.
if(!is_preference_enabled(/datum/client_preference/show_typing_indicator))
if(typing_indicator)
cut_overlay(typing_indicator, TRUE)
if(is_preference_enabled(/datum/client_preference/tgui_say))
winset(src, null, "command=[client.tgui_say_create_open_command(SAY_CHANNEL)]")
return
var/cur_bubble_appearance = custom_speech_bubble
if(!cur_bubble_appearance || cur_bubble_appearance == "default")
cur_bubble_appearance = speech_bubble_appearance()
if(!typing_indicator || cur_typing_indicator != cur_bubble_appearance)
init_typing_indicator("[cur_bubble_appearance]_typing")
if(state && !typing)
add_overlay(typing_indicator, TRUE)
typing = TRUE
typing_indicator_active = typing_indicator
else if(typing)
cut_overlay(typing_indicator_active, TRUE)
typing = FALSE
if(typing_indicator_active != typing_indicator)
qdel(typing_indicator_active)
typing_indicator_active = null
return state
/mob/verb/say_wrapper()
set name = ".Say"
set hidden = 1
set_typing_indicator(TRUE)
client?.start_thinking()
client?.start_typing()
var/message = tgui_input_text(usr, "Type your message:", "Say")
set_typing_indicator(FALSE)
client?.stop_thinking()
if(message)
say_verb(message)
/mob/verb/me_wrapper()
set name = ".Me"
set hidden = 1
set name = "Me verb"
set category = "IC.TGUI Say" //CHOMPEdit
set_typing_indicator(TRUE)
if(is_preference_enabled(/datum/client_preference/tgui_say))
winset(src, null, "command=[client.tgui_say_create_open_command(ME_CHANNEL)]")
return
client?.start_thinking()
client?.start_typing()
var/message = tgui_input_text(usr, "Type your message:", "Emote", multiline = TRUE)
set_typing_indicator(FALSE)
client?.stop_thinking()
if(message)
me_verb(message)
// No typing indicators here, but this is the file where the wrappers are, so...
/mob/verb/whisper_wrapper()
set name = ".Whisper"
set hidden = 1
set name = "Whisper verb"
set category = "IC.TGUI Say" //CHOMPEdit
if(is_preference_enabled(/datum/client_preference/tgui_say))
winset(src, null, "command=[client.tgui_say_create_open_command(WHIS_CHANNEL)]")
return
if(is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle))
client?.start_thinking()
client?.start_typing()
var/message = tgui_input_text(usr, "Type your message:", "Whisper")
client?.stop_thinking()
if(message)
whisper(message)
/mob/verb/subtle_wrapper()
set name = ".Subtle"
set hidden = 1
set name = "Subtle verb"
set category = "IC.TGUI Say" //CHOMPEdit
set desc = "Emote to nearby people (and your pred/prey)"
if(is_preference_enabled(/datum/client_preference/tgui_say))
winset(src, null, "command=[client.tgui_say_create_open_command(SUBTLE_CHANNEL)]")
return
if(is_preference_enabled(/datum/client_preference/show_typing_indicator_subtle))
client?.start_thinking()
client?.start_typing()
var/message = tgui_input_text(usr, "Type your message:", "Subtle", multiline = TRUE)
client?.stop_thinking()
if(message)
me_verb_subtle(message)

View File

@@ -126,6 +126,8 @@
/datum/asset/simple/namespaced/fontawesome))
flush_queue |= window.send_asset(get_asset_datum(
/datum/asset/simple/namespaced/tgfont))
flush_queue |= window.send_asset(get_asset_datum(
/datum/asset/json/icon_ref_map))
for(var/datum/asset/asset in src_object.ui_assets(user))
flush_queue |= window.send_asset(asset)
if (flush_queue)

View File

@@ -119,11 +119,11 @@
html = replacetextEx(html, "<!-- tgui:inline-html -->", inline_html)
// Inject inline JS
if (inline_js)
inline_js = "<script>\n'use strict';\n[inline_js]\n</script>"
inline_js = "<script>\n'use strict';\n[isfile(inline_js) ? file2text(inline_js) : inline_js]\n</script>"
html = replacetextEx(html, "<!-- tgui:inline-js -->", inline_js)
// Inject inline CSS
if (inline_css)
inline_css = "<style>\n[inline_css]\n</style>"
inline_css = "<style>\n[isfile(inline_css) ? file2text(inline_css) : inline_css]\n</style>"
html = replacetextEx(html, "<!-- tgui:inline-css -->", inline_css)
// Open the window
client << browse(html, "window=[id];[options]")
@@ -146,6 +146,9 @@
inline_html = initial_inline_html,
inline_js = initial_inline_js,
inline_css = initial_inline_css)
// Resend assets
for(var/datum/asset/asset in sent_assets)
send_asset(asset)
/**
* public

View File

@@ -0,0 +1,129 @@
/** Assigned say modal of the client */
/client/var/datum/tgui_say/tgui_say
/**
* Creates a JSON encoded message to open TGUI say modals properly.
*
* Arguments:
* channel - The channel to open the modal in.
* Returns:
* string - A JSON encoded message to open the modal.
*/
/client/proc/tgui_say_create_open_command(channel)
var/message = TGUI_CREATE_MESSAGE("open", list(
channel = channel,
))
return "\".output tgui_say.browser:update [message]\""
/**
* The tgui say modal. This initializes an input window which hides until
* the user presses one of the speech hotkeys. Once something is entered, it will
* delegate the speech to the proper channel.
*/
/datum/tgui_say
/// The user who opened the window
var/client/client
/// Injury phrases to blurt out
var/list/hurt_phrases = list("GACK!", "GLORF!", "OOF!", "AUGH!", "OW!", "URGH!", "HRNK!")
/// Max message length
var/max_length = MAX_MESSAGE_LEN
/// The modal window
var/datum/tgui_window/window
/// Boolean for whether the tgui_say was opened by the user.
var/window_open
/** Creates the new input window to exist in the background. */
/datum/tgui_say/New(client/client, id)
src.client = client
window = new(client, id)
winset(client, "tgui_say", "size=1,1;is-visible=0;")
window.subscribe(src, PROC_REF(on_message))
window.is_browser = TRUE
/**
* After a brief period, injects the scripts into
* the window to listen for open commands.
*/
/datum/tgui_say/proc/initialize()
set waitfor = FALSE
// Sleep to defer initialization to after client constructor
sleep(3 SECONDS)
window.initialize(
strict_mode = TRUE,
fancy = TRUE,
inline_css = file("tgui/public/tgui-say.bundle.css"),
inline_js = file("tgui/public/tgui-say.bundle.js"),
);
/**
* Ensures nothing funny is going on window load.
* Minimizes the window, sets max length, closes all
* typing and thinking indicators. This is triggered
* as soon as the window sends the "ready" message.
*/
/datum/tgui_say/proc/load()
window_open = FALSE
winset(client, "tgui_say", "pos=410,400;size=360,30;is-visible=0;")
window.send_message("props", list(
lightMode = client.is_preference_enabled(/datum/client_preference/tgui_say_light),
maxLength = max_length,
))
stop_thinking()
return TRUE
/**
* Sets the window as "opened" server side, though it is already
* visible to the user. We do this to set local vars &
* start typing (if enabled and in an IC channel). Logs the event.
*
* Arguments:
* payload - A list containing the channel the window was opened in.
*/
/datum/tgui_say/proc/open(payload)
if(!payload?["channel"])
CRASH("No channel provided to an open TGUI-Say")
window_open = TRUE
if(payload["channel"] != OOC_CHANNEL && payload["channel"] != ADMIN_CHANNEL)
start_thinking()
return TRUE
/**
* Closes the window serverside. Closes any open chat bubbles
* regardless of preference. Logs the event.
*/
/datum/tgui_say/proc/close()
window_open = FALSE
stop_thinking()
/**
* The equivalent of ui_act, this waits on messages from the window
* and delegates actions.
*/
/datum/tgui_say/proc/on_message(type, payload)
if(type == "ready")
load()
return TRUE
if(type == "open")
open(payload)
return TRUE
if(type == "close")
close()
return TRUE
if(type == "thinking")
if(payload["visible"] == TRUE)
start_thinking(payload["channel"])
return TRUE
if(payload["visible"] == FALSE)
stop_thinking(payload["channel"])
return TRUE
return FALSE
if(type == "typing")
start_typing(payload["channel"])
return TRUE
if(type == "entry" || type == "force")
handle_entry(type, payload)
return TRUE
return FALSE

View File

@@ -0,0 +1,106 @@
/**
* Alters text when players are injured.
* Adds text, trims left and right side
*
* Arguments:
* payload - a string list containing entry & channel
* Returns:
* string - the altered entry
*/
/datum/tgui_say/proc/alter_entry(payload)
var/entry = payload["entry"]
/// No OOC leaks
if(!entry || payload["channel"] == OOC_CHANNEL || payload["channel"] == ME_CHANNEL || payload["channel"] == LOOC_CHANNEL)
return pick(hurt_phrases)
/// Random trimming for larger sentences
if(length(entry) > 50)
entry = trim(entry, rand(40, 50))
else
/// Otherwise limit trim to just last letter
if(length(entry) > 1)
entry = trim(entry, length(entry))
return entry + "-" + pick(hurt_phrases)
/**
* Delegates the speech to the proper channel.
*
* Arguments:
* entry - the text to broadcast
* channel - the channel to broadcast in
* Returns:
* boolean - on success or failure
*/
/datum/tgui_say/proc/delegate_speech(entry, channel)
switch(channel)
if(SAY_CHANNEL)
client.mob.say_verb(entry)
return TRUE
if(RADIO_CHANNEL)
client.mob.say_verb(";" + entry)
return TRUE
if(ME_CHANNEL)
client.mob.me_verb(entry)
return TRUE
if(WHIS_CHANNEL)
client.mob.whisper(entry)
return TRUE
if(SUBTLE_CHANNEL)
client.mob.me_verb_subtle(entry)
return TRUE
if(OOC_CHANNEL)
client.ooc(entry)
return TRUE
if(LOOC_CHANNEL)
client.looc(entry)
return TRUE
if(ADMIN_CHANNEL)
if(check_rights(R_ADMIN, show_msg = FALSE))
client.cmd_admin_say(entry)
return TRUE
return FALSE
/**
* Force say handler.
* Sends a message to the say modal to send its current value.
*/
// /datum/tgui_say/proc/force_say()
// window.send_message("force")
// stop_typing()
/**
* Makes the player force say what's in their current input box.
*/
// /mob/living/carbon/human/proc/force_say()
// if(stat != CONSCIOUS || !client?.tgui_say?.window_open)
// return FALSE
// client.tgui_say.force_say()
// if(client.typing_indicators)
// log_speech_indicators("[key_name(client)] FORCED to stop typing, indicators enabled.")
// else
// log_speech_indicators("[key_name(client)] FORCED to stop typing, indicators DISABLED.")
// SEND_SIGNAL(src, COMSIG_HUMAN_FORCESAY)
/**
* Handles text entry and forced speech.
*
* Arguments:
* type - a string "entry" or "force" based on how this function is called
* payload - a string list containing entry & channel
* Returns:
* boolean - success or failure
*/
/datum/tgui_say/proc/handle_entry(type, payload)
if(!payload?["channel"] || !payload["entry"])
CRASH("[usr] entered in a null payload to the chat window.")
if(length(payload["entry"]) > max_length)
CRASH("[usr] has entered more characters than allowed into a TGUI-Say")
if(type == "entry")
delegate_speech(payload["entry"], payload["channel"])
return TRUE
if(type == "force")
var/target_channel = payload["channel"]
if(target_channel == ME_CHANNEL || target_channel == OOC_CHANNEL || target_channel == LOOC_CHANNEL)
target_channel = SAY_CHANNEL // No ooc leaks - this is fine because alter_entry will override this completely
delegate_speech(alter_entry(payload), target_channel)
return TRUE
return FALSE

View File

@@ -0,0 +1,75 @@
/mob
///the icon currently used for the typing indicator's bubble
var/mutable_appearance/active_typing_indicator
///the icon currently used for the thinking indicator's bubble
var/mutable_appearance/active_thinking_indicator
/** Creates a thinking indicator over the mob. Note: Prefs are checked in /client/proc/start_thinking() */
/mob/proc/create_thinking_indicator()
if(active_thinking_indicator || active_typing_indicator || stat != CONSCIOUS || !HAS_TRAIT(src, TRAIT_THINKING_IN_CHARACTER))
return FALSE
var/cur_bubble_appearance = custom_speech_bubble
if(!cur_bubble_appearance || cur_bubble_appearance == "default")
cur_bubble_appearance = speech_bubble_appearance()
active_thinking_indicator = mutable_appearance('icons/mob/talk_vr.dmi', "[cur_bubble_appearance]_thinking", FLOAT_LAYER)
active_thinking_indicator.appearance_flags |= (RESET_COLOR|PIXEL_SCALE)
add_overlay(active_thinking_indicator)
/** Removes the thinking indicator over the mob. */
/mob/proc/remove_thinking_indicator()
if(!active_thinking_indicator)
return FALSE
cut_overlay(active_thinking_indicator)
active_thinking_indicator = null
/** Creates a typing indicator over the mob. Note: Prefs are checked in /client/proc/start_typing() */
/mob/proc/create_typing_indicator()
if(active_typing_indicator || active_thinking_indicator || stat != CONSCIOUS || !HAS_TRAIT(src, TRAIT_THINKING_IN_CHARACTER))
return FALSE
var/cur_bubble_appearance = custom_speech_bubble
if(!cur_bubble_appearance || cur_bubble_appearance == "default")
cur_bubble_appearance = speech_bubble_appearance()
active_typing_indicator = mutable_appearance('icons/mob/talk_vr.dmi', "[cur_bubble_appearance]_typing", ABOVE_MOB_LAYER)
active_typing_indicator.appearance_flags |= (RESET_COLOR|PIXEL_SCALE)
add_overlay(active_typing_indicator)
/** Removes the typing indicator over the mob. */
/mob/proc/remove_typing_indicator()
if(!active_typing_indicator)
return FALSE
cut_overlay(active_typing_indicator)
active_typing_indicator = null
/** Removes any indicators and marks the mob as not speaking IC. */
/mob/proc/remove_all_indicators()
REMOVE_TRAIT(src, TRAIT_THINKING_IN_CHARACTER, CURRENTLY_TYPING_TRAIT)
remove_thinking_indicator()
remove_typing_indicator()
/mob/set_stat(new_stat)
. = ..()
if(.)
remove_all_indicators()
/mob/Logout()
remove_all_indicators()
return ..()
/** Sets the mob as "thinking" - with indicator and the TRAIT_THINKING_IN_CHARACTER trait */
/datum/tgui_say/proc/start_thinking(channel)
if(!window_open)
return FALSE
return client.start_thinking(channel)
/** Removes typing/thinking indicators and flags the mob as not thinking */
/datum/tgui_say/proc/stop_thinking(channel)
return client.stop_thinking(channel)
/**
* Handles the user typing. After a brief period of inactivity,
* signals the client mob to revert to the "thinking" icon.
*/
/datum/tgui_say/proc/start_typing(channel)
if(!window_open)
return FALSE
return client.start_typing(channel)

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
</head>
<body>
<script>
lastseentypedtext = "";
function getoutput() {
setTimeout(getoutput, 1000);
window.location = "byond://winget?callback=checkoutput&id=:Input&property=text";
}
function checkoutput(props) {
if (typeof props !== 'object')
return;
if (typeof props.text !== 'string' && !(props.text instanceof String))
return;
var text = props.text;
if (text == lastseentypedtext)
return;
lastseentypedtext = text;
var words = text.split(" ");
var verb = words[0];
var argument = "";
var argument_length = -1;
if (words.length >= 2) {
words.splice(0, 1)
argument = words.join(" ");
argument_length = argument.length;
}
if (argument_length > 0 && argument[0] == "\"")
argument_length -= 1;
window.location = "byond://?commandbar_typing=1&verb="+encodeURIComponent(verb)+"&argument_length="+argument_length;
}
setTimeout(getoutput, 2000);
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -124,10 +124,10 @@ macro "borghotkeymode"
command = "a-intent left"
elem
name = "5"
command = ".me"
command = "Me-verb"
elem
name = "6"
command = ".Subtle"
command = "Subtle-verb"
elem
name = "A"
command = "KeyDown A"
@@ -178,7 +178,7 @@ macro "borghotkeymode"
command = "KeyUp S"
elem
name = "T"
command = ".say"
command = "Say-verb"
elem "w_key"
name = "W"
command = "KeyDown W"
@@ -193,10 +193,10 @@ macro "borghotkeymode"
command = ".northeast"
elem
name = "Y"
command = ".Whisper"
command = "Whisper-verb"
elem
name = "CTRL+Y"
command = ".Whisper"
command = "Whisper-verb"
elem
name = "Z"
command = "Activate-Held-Object"
@@ -244,10 +244,10 @@ macro "borghotkeymode"
command = ".screenshot"
elem
name = "F3"
command = ".say"
command = "Say-verb"
elem
name = "F4"
command = ".me"
command = "Me-verb"
elem
name = "F5"
command = "asay"
@@ -474,10 +474,10 @@ macro "macro"
command = ".screenshot"
elem
name = "F3"
command = ".say"
command = "Say-verb"
elem
name = "F4"
command = ".me"
command = "Me-verb"
elem
name = "F5"
command = "asay"
@@ -626,10 +626,10 @@ macro "hotkeymode"
command = "a-intent harm"
elem
name = "5"
command = ".me"
command = "Me-verb"
elem
name = "6"
command = ".Subtle"
command = "Subtle-verb"
elem
name = "A"
command = "KeyDown A"
@@ -692,7 +692,7 @@ macro "hotkeymode"
command = "KeyUp S"
elem
name = "T"
command = ".say"
command = "Say-verb"
elem "w_key"
name = "W"
command = "KeyDown W"
@@ -707,10 +707,10 @@ macro "hotkeymode"
command = ".northeast"
elem
name = "Y"
command = ".Whisper"
command = "Whisper-verb"
elem
name = "CTRL+Y"
command = ".Whisper"
command = "Whisper-verb"
elem
name = "Z"
command = "Activate-Held-Object"
@@ -767,10 +767,10 @@ macro "hotkeymode"
command = ".screenshot"
elem
name = "F3"
command = ".say"
command = "Say-verb"
elem
name = "F4"
command = ".me"
command = "Me-verb"
elem
name = "F5"
command = "asay"
@@ -991,10 +991,10 @@ macro "borgmacro"
command = ".screenshot"
elem
name = "F3"
command = ".say"
command = "Say-verb"
elem
name = "F4"
command = ".me"
command = "Me-verb"
elem
name = "F5"
command = "asay"
@@ -1230,6 +1230,15 @@ window "mainwindow"
anchor2 = -1,-1
is-visible = false
saved-params = ""
elem "commandbar_spy"
type = BROWSER
is-default = false
pos = 0,0
size = 200x200
anchor1 = -1,-1
anchor2 = -1,-1
is-visible = false
saved-params = ""
window "mapwindow"
elem "mapwindow"
@@ -1579,4 +1588,22 @@ window "statwindow"
anchor1 = 0,0
anchor2 = 100,100
is-visible = false
window "tgui_say"
elem "tgui_say"
type = MAIN
pos = 410,400
size = 360x30
anchor1 = 50,50
anchor2 = 50,50
is-visible = false
saved-params = ""
statusbar = false
can-minimize = false
elem "browser"
type = BROWSER
pos = 0,0
size = 360x30
anchor1 = 0,0
anchor2 = 0,0
saved-params = ""

View File

@@ -0,0 +1,61 @@
import { ChannelIterator } from './ChannelIterator';
describe('ChannelIterator', () => {
let channelIterator: ChannelIterator;
beforeEach(() => {
channelIterator = new ChannelIterator();
});
it('should cycle through channels properly', () => {
expect(channelIterator.current()).toBe('Say');
expect(channelIterator.next()).toBe('Radio');
expect(channelIterator.next()).toBe('Me');
expect(channelIterator.next()).toBe('Whis');
expect(channelIterator.next()).toBe('Subtle');
expect(channelIterator.next()).toBe('LOOC');
expect(channelIterator.next()).toBe('OOC');
expect(channelIterator.next()).toBe('Say'); // Admin is blacklisted so it should be skipped
});
it('should cycle through channels backwards properly', () => {
expect(channelIterator.current()).toBe('Say');
expect(channelIterator.prev()).toBe('OOC'); // Admin is blacklisted so it should be skipped
expect(channelIterator.prev()).toBe('LOOC');
expect(channelIterator.prev()).toBe('Subtle');
expect(channelIterator.prev()).toBe('Whis');
expect(channelIterator.prev()).toBe('Me');
expect(channelIterator.prev()).toBe('Radio');
expect(channelIterator.prev()).toBe('Say');
});
it('should set a channel properly', () => {
channelIterator.set('OOC');
expect(channelIterator.current()).toBe('OOC');
});
it('should return true when current channel is "Say"', () => {
channelIterator.set('Say');
expect(channelIterator.isSay()).toBe(true);
});
it('should return false when current channel is not "Say"', () => {
channelIterator.set('Radio');
expect(channelIterator.isSay()).toBe(false);
});
it('should return true when current channel is visible', () => {
channelIterator.set('Say');
expect(channelIterator.isVisible()).toBe(true);
});
it('should return false when current channel is not visible', () => {
channelIterator.set('OOC');
expect(channelIterator.isVisible()).toBe(false);
});
it('should not leak a message from a blacklisted channel', () => {
channelIterator.set('Admin');
expect(channelIterator.next()).toBe('Admin');
});
});

View File

@@ -0,0 +1,89 @@
export type Channel =
| 'Say'
| 'Radio'
| 'Me'
| 'Whis'
| 'Subtle'
| 'LOOC'
| 'OOC'
| 'Admin';
/**
* ### ChannelIterator
* Cycles a predefined list of channels,
* skipping over blacklisted ones,
* and providing methods to manage and query the current channel.
*/
export class ChannelIterator {
private index: number = 0;
private readonly channels: Channel[] = [
'Say',
'Radio',
'Me',
'Whis',
'Subtle',
'LOOC',
'OOC',
'Admin',
];
private readonly blacklist: Channel[] = ['Admin'];
private readonly quiet: Channel[] = ['OOC', 'LOOC', 'Admin'];
private readonly multiline: Channel[] = ['Me', 'Subtle'];
public next(): Channel {
if (this.blacklist.includes(this.channels[this.index])) {
return this.channels[this.index];
}
for (let index = 1; index <= this.channels.length; index++) {
let nextIndex = (this.index + index) % this.channels.length;
if (!this.blacklist.includes(this.channels[nextIndex])) {
this.index = nextIndex;
break;
}
}
return this.channels[this.index];
}
public prev(): Channel {
if (this.blacklist.includes(this.channels[this.index])) {
return this.channels[this.index];
}
for (let index = 1; index <= this.channels.length; index++) {
let nextIndex =
(this.index - index + this.channels.length) % this.channels.length;
if (!this.blacklist.includes(this.channels[nextIndex])) {
this.index = nextIndex;
break;
}
}
return this.channels[this.index];
}
public set(channel: Channel): void {
this.index = this.channels.indexOf(channel) || 0;
}
public current(): Channel {
return this.channels[this.index];
}
public isSay(): boolean {
return this.channels[this.index] === 'Say';
}
public isMultiline(): boolean {
return this.multiline.includes(this.channels[this.index]);
}
public isVisible(): boolean {
return !this.quiet.includes(this.channels[this.index]);
}
public reset(): void {
this.index = 0;
}
}

View File

@@ -0,0 +1,50 @@
import { ChatHistory } from './ChatHistory';
describe('ChatHistory', () => {
let chatHistory: ChatHistory;
beforeEach(() => {
chatHistory = new ChatHistory();
});
it('should add a message to the history', () => {
chatHistory.add('Hello');
expect(chatHistory.getOlderMessage()).toEqual('Hello');
});
it('should retrieve older and newer messages', () => {
chatHistory.add('Hello');
chatHistory.add('World');
expect(chatHistory.getOlderMessage()).toEqual('World');
expect(chatHistory.getOlderMessage()).toEqual('Hello');
expect(chatHistory.getNewerMessage()).toEqual('World');
expect(chatHistory.getNewerMessage()).toBeNull();
expect(chatHistory.getOlderMessage()).toEqual('World');
});
it('should limit the history to 5 messages', () => {
for (let i = 1; i <= 6; i++) {
chatHistory.add(`Message ${i}`);
}
expect(chatHistory.getOlderMessage()).toEqual('Message 6');
for (let i = 5; i >= 2; i--) {
expect(chatHistory.getOlderMessage()).toEqual(`Message ${i}`);
}
expect(chatHistory.getOlderMessage()).toBeNull();
});
it('should handle temp message correctly', () => {
chatHistory.saveTemp('Temp message');
expect(chatHistory.getTemp()).toEqual('Temp message');
expect(chatHistory.getTemp()).toBeNull();
});
it('should reset correctly', () => {
chatHistory.add('Hello');
chatHistory.getOlderMessage();
chatHistory.reset();
expect(chatHistory.isAtLatest()).toBe(true);
expect(chatHistory.getOlderMessage()).toEqual('Hello');
});
});

View File

@@ -0,0 +1,59 @@
/**
* ### ChatHistory
* A class to manage a chat history,
* maintaining a maximum of five messages and supporting navigation,
* temporary message storage, and query operations.
*/
export class ChatHistory {
private messages: string[] = [];
private index: number = -1; // Initialize index at -1
private temp: string | null = null;
public add(message: string): void {
this.messages.unshift(message);
this.index = -1; // Reset index
if (this.messages.length > 5) {
this.messages.pop();
}
}
public getIndex(): number {
return this.index + 1;
}
public getOlderMessage(): string | null {
if (this.messages.length === 0 || this.index >= this.messages.length - 1) {
return null;
}
this.index++;
return this.messages[this.index];
}
public getNewerMessage(): string | null {
if (this.index <= 0) {
this.index = -1;
return null;
}
this.index--;
return this.messages[this.index];
}
public isAtLatest(): boolean {
return this.index === -1;
}
public saveTemp(message: string): void {
this.temp = message;
}
public getTemp(): string | null {
const temp = this.temp;
this.temp = null;
return temp;
}
public reset(): void {
this.index = -1;
this.temp = null;
}
}

View File

@@ -0,0 +1,389 @@
import { KEY } from 'common/keys';
import { BooleanLike } from 'common/react';
import { Component, createRef, RefObject } from 'react';
import { dragStartHandler } from 'tgui/drag';
import {
removeAllSkiplines,
sanitizeMultiline,
} from 'tgui/interfaces/TextInputModal';
import { Channel, ChannelIterator } from './ChannelIterator';
import { ChatHistory } from './ChatHistory';
import { LINE_LENGTHS, RADIO_PREFIXES, WINDOW_SIZES } from './constants';
import { windowClose, windowOpen, windowSet } from './helpers';
import { byondMessages } from './timers';
type ByondOpen = {
channel: Channel;
};
type ByondProps = {
maxLength: number;
lightMode: BooleanLike;
};
type State = {
buttonContent: string | number;
size: WINDOW_SIZES;
};
const CHANNEL_REGEX = /^:\w\s|^,b\s/;
export class TguiSay extends Component<{}, State> {
private channelIterator: ChannelIterator;
private chatHistory: ChatHistory;
private currentPrefix: keyof typeof RADIO_PREFIXES | null;
private innerRef: RefObject<HTMLTextAreaElement>;
private lightMode: boolean;
private maxLength: number;
private messages: typeof byondMessages;
state: State;
constructor(props: never) {
super(props);
this.channelIterator = new ChannelIterator();
this.chatHistory = new ChatHistory();
this.currentPrefix = null;
this.innerRef = createRef();
this.lightMode = false;
this.maxLength = 1024;
this.messages = byondMessages;
this.state = {
buttonContent: '',
size: WINDOW_SIZES.small,
};
this.handleArrowKeys = this.handleArrowKeys.bind(this);
this.handleBackspaceDelete = this.handleBackspaceDelete.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleEnter = this.handleEnter.bind(this);
this.handleForceSay = this.handleForceSay.bind(this);
this.handleIncrementChannel = this.handleIncrementChannel.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleProps = this.handleProps.bind(this);
this.reset = this.reset.bind(this);
this.setSize = this.setSize.bind(this);
this.setValue = this.setValue.bind(this);
}
componentDidMount() {
Byond.subscribeTo('props', this.handleProps);
Byond.subscribeTo('force', this.handleForceSay);
Byond.subscribeTo('open', this.handleOpen);
}
handleArrowKeys(direction: KEY.PageUp | KEY.PageDown) {
const currentValue = this.innerRef.current?.value;
if (direction === KEY.PageUp) {
if (this.chatHistory.isAtLatest() && currentValue) {
// Save current message to temp history if at the most recent message
this.chatHistory.saveTemp(currentValue);
}
// Try to get the previous message, fall back to the current value if none
const prevMessage = this.chatHistory.getOlderMessage();
if (prevMessage) {
this.setState({ buttonContent: this.chatHistory.getIndex() });
this.setSize(prevMessage.length);
this.setValue(prevMessage);
}
} else {
const nextMessage =
this.chatHistory.getNewerMessage() || this.chatHistory.getTemp() || '';
const buttonContent = this.chatHistory.isAtLatest()
? this.channelIterator.current()
: this.chatHistory.getIndex();
this.setState({ buttonContent });
this.setSize(nextMessage.length);
this.setValue(nextMessage);
}
}
handleBackspaceDelete() {
const typed = this.innerRef.current?.value;
// User is on a chat history message
if (!this.chatHistory.isAtLatest()) {
this.chatHistory.reset();
this.setState({
buttonContent: this.currentPrefix ?? this.channelIterator.current(),
});
// Empty input, resets the channel
} else if (
!!this.currentPrefix &&
this.channelIterator.isSay() &&
typed?.length === 0
) {
this.currentPrefix = null;
this.setState({ buttonContent: this.channelIterator.current() });
}
this.setSize(typed?.length);
}
handleClose() {
const current = this.innerRef.current;
if (current) {
current.blur();
}
this.reset();
this.chatHistory.reset();
this.channelIterator.reset();
this.currentPrefix = null;
windowClose();
}
handleEnter() {
const prefix = this.currentPrefix ?? '';
const value = this.innerRef.current?.value;
if (value?.length && value.length < this.maxLength) {
this.chatHistory.add(value);
// Everything can be multiline, but only emotes get passed that way to the game
const sanitizedValue = this.channelIterator.isMultiline()
? sanitizeMultiline(value)
: removeAllSkiplines(value);
Byond.sendMessage('entry', {
channel: this.channelIterator.current(),
entry: this.channelIterator.isSay()
? prefix + sanitizedValue
: sanitizedValue,
});
}
this.handleClose();
}
handleForceSay() {
const currentValue = this.innerRef.current?.value;
// Only force say if we're on a visible channel and have typed something
if (!currentValue || !this.channelIterator.isVisible()) return;
const prefix = this.currentPrefix ?? '';
const grunt = this.channelIterator.isSay()
? prefix + currentValue
: currentValue;
this.messages.forceSayMsg(grunt);
this.reset();
}
handleIncrementChannel() {
this.currentPrefix = null;
this.channelIterator.next();
// If we've looped onto a quiet channel, tell byond to hide thinking indicators
if (!this.channelIterator.isVisible()) {
this.messages.channelIncrementMsg(false, this.channelIterator.current());
} else {
this.messages.channelIncrementMsg(true, this.channelIterator.current());
}
this.setState({ buttonContent: this.channelIterator.current() });
}
handleDecrementChannel() {
this.currentPrefix = null;
this.channelIterator.prev();
// If we've looped onto a quiet channel, tell byond to hide thinking indicators
if (!this.channelIterator.isVisible()) {
this.messages.channelIncrementMsg(false, this.channelIterator.current());
} else {
this.messages.channelIncrementMsg(true, this.channelIterator.current());
}
this.setState({ buttonContent: this.channelIterator.current() });
}
handleInput() {
const typed = this.innerRef.current?.value;
// If we're typing, send the message
if (this.channelIterator.isVisible()) {
this.messages.typingMsg(this.channelIterator.current());
}
this.setSize(typed?.length);
// Is there a value? Is it long enough to be a prefix?
if (!typed || typed.length < 3) {
return;
}
if (!CHANNEL_REGEX.test(typed)) {
return;
}
// Is it a valid prefix?
const prefix = typed
.slice(0, 3)
?.toLowerCase() as keyof typeof RADIO_PREFIXES;
if (!RADIO_PREFIXES[prefix] || prefix === this.currentPrefix) {
return;
}
this.channelIterator.set('Say');
this.currentPrefix = prefix;
this.setState({ buttonContent: RADIO_PREFIXES[prefix] });
this.setValue(typed.slice(3));
}
handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
const currentValue = this.innerRef.current?.value;
switch (event.key) {
case KEY.PageUp:
case KEY.PageDown:
// Allow moving between lines if there are newlines
/* if (currentValue?.includes('\n')) {
break;
} */
event.preventDefault();
this.handleArrowKeys(event.key);
break;
case KEY.Delete:
case KEY.Backspace:
this.handleBackspaceDelete();
break;
case KEY.Enter:
// Allow inputting newlines
if (event.shiftKey) {
break;
}
event.preventDefault();
this.handleEnter();
break;
case KEY.Tab:
event.preventDefault();
if (event.shiftKey) {
this.handleDecrementChannel();
} else {
this.handleIncrementChannel();
}
break;
case KEY.Escape:
this.handleClose();
break;
}
}
handleOpen = (data: ByondOpen) => {
setTimeout(() => {
this.innerRef.current?.focus();
}, 0);
const { channel } = data;
// Catches the case where the modal is already open
if (this.channelIterator.isSay()) {
this.channelIterator.set(channel);
}
this.setState({ buttonContent: this.channelIterator.current() });
windowOpen(this.channelIterator.current());
};
handleProps = (data: ByondProps) => {
const { maxLength, lightMode } = data;
this.maxLength = maxLength;
this.lightMode = !!lightMode;
};
reset() {
this.setValue('');
this.setSize();
this.setState({
buttonContent: this.channelIterator.current(),
});
}
setSize(length = 0) {
let newSize: WINDOW_SIZES;
const currentValue = this.innerRef.current?.value;
if (currentValue?.includes('\n')) {
newSize = WINDOW_SIZES.large;
} else if (length > LINE_LENGTHS.medium) {
newSize = WINDOW_SIZES.large;
} else if (length <= LINE_LENGTHS.medium && length > LINE_LENGTHS.small) {
newSize = WINDOW_SIZES.medium;
} else {
newSize = WINDOW_SIZES.small;
}
if (this.state.size !== newSize) {
this.setState({ size: newSize });
windowSet(newSize);
}
}
setValue(value: string) {
const textArea = this.innerRef.current;
if (textArea) {
textArea.value = value;
}
}
render() {
const theme =
(this.lightMode && 'lightMode') ||
(this.currentPrefix && RADIO_PREFIXES[this.currentPrefix]) ||
this.channelIterator.current();
return (
<div className={`window window-${theme} window-${this.state.size}`}>
<Dragzone position="top" theme={theme} />
<div className="center">
<Dragzone position="left" theme={theme} />
<div className="input">
<button
className={`button button-${theme}`}
onClick={this.handleIncrementChannel}
type="button"
>
{this.state.buttonContent}
</button>
<textarea
className={`textarea textarea-${theme}`}
maxLength={this.maxLength}
onInput={this.handleInput}
onKeyDown={this.handleKeyDown}
ref={this.innerRef}
/>
</div>
<Dragzone position="right" theme={theme} />
</div>
<Dragzone position="bottom" theme={theme} />
</div>
);
}
}
const Dragzone = ({ theme, position }: { theme: string; position: string }) => {
// Horizontal or vertical?
const location =
position === 'left' || position === 'right' ? 'vertical' : 'horizontal';
return (
<div
className={`dragzone-${location} dragzone-${position} dragzone-${theme}`}
onMouseDown={dragStartHandler}
/>
);
};

View File

@@ -0,0 +1,37 @@
/** Window sizes in pixels */
export enum WINDOW_SIZES {
small = 30,
medium = 50,
large = 70,
width = 360,
}
/** Line lengths for autoexpand */
export enum LINE_LENGTHS {
small = 22,
medium = 45,
}
/**
* Radio prefixes.
* Displays the name in the left button, tags a css class.
*/
export const RADIO_PREFIXES = {
':a ': 'EVA',
',b ': '010',
':c ': 'Cmd',
':e ': 'Eng',
':g ': 'Cas',
':h ': 'Dept',
':i ': 'Int',
':k ': 'ERT',
':m ': 'Med',
':n ': 'Sci',
':p ': 'AI',
':s ': 'Sec',
':t ': 'Merc',
':u ': 'Sup',
':v ': 'Srv',
':x ': 'Rai',
':y ': 'ITV',
} as const;

Binary file not shown.

View File

@@ -0,0 +1,46 @@
import { Channel } from './ChannelIterator';
import { WINDOW_SIZES } from './constants';
/**
* Once byond signals this via keystroke, it
* ensures window size, visibility, and focus.
*/
export const windowOpen = (channel: Channel) => {
setWindowVisibility(true);
Byond.sendMessage('open', { channel });
};
/**
* Resets the state of the window and hides it from user view.
* Sending "close" logs it server side.
*/
export const windowClose = () => {
setWindowVisibility(false);
Byond.winset('map', {
focus: true,
});
Byond.sendMessage('close');
};
/**
* Modifies the window size.
*/
export const windowSet = (size = WINDOW_SIZES.small) => {
let sizeStr = `${WINDOW_SIZES.width}x${size}`;
Byond.winset('tgui_say.browser', {
size: sizeStr,
});
Byond.winset('tgui_say', {
size: sizeStr,
});
};
/** Helper function to set window size and visibility */
const setWindowVisibility = (visible: boolean) => {
Byond.winset('tgui_say', {
'is-visible': visible,
size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
});
};

View File

@@ -0,0 +1,18 @@
import './styles/main.scss';
import { createRoot, Root } from 'react-dom/client';
import { TguiSay } from './TguiSay';
let reactRoot: Root | null = null;
document.onreadystatechange = function () {
if (document.readyState !== 'complete') return;
if (!reactRoot) {
const root = document.getElementById('react-root');
reactRoot = createRoot(root!);
}
reactRoot.render(<TguiSay />);
};

View File

@@ -0,0 +1,14 @@
{
"private": true,
"name": "tgui-say",
"version": "1.0.0",
"dependencies": {
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"common": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tgui": "workspace:*",
"tgui-polyfill": "workspace:*"
}
}

View File

@@ -0,0 +1,29 @@
@use 'sass:color';
@use './colors.scss';
.button {
align-items: center;
background-color: colors.$button;
border-radius: 0.3rem;
border: thin solid;
display: flex;
flex-grow: 1;
font-family: inherit;
font-size: 0.9rem;
font-weight: bold;
justify-content: center;
padding: 0;
width: 1rem;
&:hover {
background-color: lighten(colors.$button, 10%);
}
}
.button-lightMode {
background-color: colors.$lightBorder;
border: none;
color: black;
&:hover {
background-color: colors.$lightHover;
}
}

View File

@@ -0,0 +1,46 @@
@use 'sass:map';
$background: #131313;
$button: #1f1f1f;
$lightMode: #ffffff;
$lightBorder: #bbbbbb;
$lightHover: #eaeaea;
$_channel_map: (
// Radio (EVA, Cas, Int use default color)
'010': #1e90ff,
'Cmd': #57b8f0,
'Eng': #fcdf03,
'Dept': #1ecc43,
'ERT': #5c5c8a,
'Med': #57f09e,
'Sci': #c68cfa,
'AI': #d65d95,
'Sec': #dd3535,
'Merc': #8f4a4b,
'Sup': #b88646,
'Srv': #6ca729,
'Rai': #8f4a4b,
'ITV': #8a8a8a,
// Modes
'LOOC': #3a9696,
'Me': #5975da,
'OOC': #cca300,
'Radio': #1ecc43,
'Say': #a4bad6,
'Whis': #7c7fd9,
'Subtle': #6366bd
);
$channel_keys: map.keys($_channel_map) !default;
$channel-map: ();
@each $channel in $channel_keys {
$channel-map: map-merge(
$channel-map,
(
$channel: map.get($_channel_map, $channel),
)
);
}

View File

@@ -0,0 +1,14 @@
.center {
display: flex;
flex: 1 1;
height: 100%;
width: 100%;
}
.input {
display: flex;
flex: 1 1;
height: 100%;
width: 100%;
font-family: 'Consolas', monospace;
}

View File

@@ -0,0 +1,39 @@
@use 'sass:color';
@use './colors.scss';
$dragSize: 0.6rem;
$borderSize: 0.2rem;
.dragzone-horizontal {
border-left: $borderSize solid;
border-right: $borderSize solid;
color: transparent;
width: 100%;
height: $dragSize;
}
.dragzone-left {
border-left: $borderSize solid;
}
.dragzone-right {
border-right: $borderSize solid;
}
.dragzone-vertical {
color: transparent;
height: 100%;
width: $dragSize;
}
.dragzone-top {
border-top: $borderSize solid;
}
.dragzone-bottom {
border-bottom: $borderSize solid;
}
/** Lightmode static theme */
.dragzone-lightMode {
border-color: colors.$lightBorder;
}

View File

@@ -0,0 +1,70 @@
@use 'sass:meta';
@use 'sass:color';
@use './colors.scss';
// Core styles
@include meta.load-css('~tgui/styles/reset.scss');
// Atomic styles
@include meta.load-css('~tgui/styles/atomic/text.scss');
// External styles
@include meta.load-css('~tgui/styles/components/TextArea.scss');
// Local styles
@include meta.load-css('./button.scss');
@include meta.load-css('./content.scss');
@include meta.load-css('./dragzone.scss');
@include meta.load-css('./textarea.scss');
@include meta.load-css('./window.scss');
@keyframes gradient {
0% {
background-position: 0 0;
}
100% {
background-position: 100% 0;
}
}
@each $channel, $color in colors.$channel-map {
$darkened: darken($color, 20%);
.button-#{$channel} {
border-color: darken($color, 10%);
color: $color;
&:hover {
border-color: lighten($color, 10%);
color: lighten($color, 5%);
}
}
.dragzone-#{$channel} {
border-color: $darkened;
}
.textarea-#{$channel} {
color: $color;
}
.window-#{$channel} {
&:after {
animation: gradient 10s linear infinite;
background: linear-gradient(
to right,
darken($color, 35%),
$color,
lighten($color, 10%),
$color,
darken($color, 35%)
);
background-position: 0% 0%;
background-size: 500% auto;
bottom: 0px;
content: '';
height: 2px;
left: 0px;
position: absolute;
right: 0px;
z-index: 999;
}
}
}

View File

@@ -0,0 +1,11 @@
.textarea {
align-items: center;
background: transparent;
border: none;
display: flex;
flex-grow: 5;
font-family: inherit;
font-size: 1.1rem;
overflow: hidden;
margin: 0 0 0 0.4rem;
}

View File

@@ -0,0 +1,29 @@
@use 'sass:color';
@use './colors.scss';
.window {
background-color: colors.$background;
display: flex;
flex-direction: column;
max-width: 360px;
height: 100%;
width: 100%;
overflow: hidden;
}
.window-lightMode {
background-color: colors.$lightMode;
}
/** Window sizes */
.window-30 {
height: 30px;
}
.window-50 {
height: 50px;
}
.window-70 {
height: 70px;
}

View File

@@ -0,0 +1,23 @@
import { debounce, throttle } from 'common/timer';
const SECONDS = 1000;
/** Timers: Prevents overloading the server, throttles messages */
export const byondMessages = {
// Debounce: Prevents spamming the server
channelIncrementMsg: debounce(
(visible: boolean, channel: string) =>
Byond.sendMessage('thinking', { visible, channel }),
0.4 * SECONDS,
),
forceSayMsg: debounce(
(entry: string) => Byond.sendMessage('force', { entry, channel: 'Say' }),
1 * SECONDS,
true,
),
// Throttle: Prevents spamming the server
typingMsg: throttle(
(channel: string) => Byond.sendMessage('typing', { channel }),
4 * SECONDS,
),
} as const;

View File

@@ -22,7 +22,7 @@ export const sanitizeMultiline = (toSanitize: string) => {
};
export const removeAllSkiplines = (toSanitize: string) => {
return toSanitize.replace(/[\r\n]+/, '');
return toSanitize.replace(/[\r\n]+/, ' ');
};
export const TextInputModal = (props) => {

View File

@@ -33,6 +33,7 @@ module.exports = (env = {}, argv) => {
entry: {
tgui: ['./packages/tgui-polyfill', './packages/tgui'],
'tgui-panel': ['./packages/tgui-polyfill', './packages/tgui-panel'],
'tgui-say': ['./packages/tgui-polyfill', './packages/tgui-say'],
},
output: {
path: argv.useTmpFolder

View File

@@ -1453,6 +1453,25 @@ __metadata:
languageName: node
linkType: hard
"@types/react-dom@npm:^18.2.24":
version: 18.3.0
resolution: "@types/react-dom@npm:18.3.0"
dependencies:
"@types/react": "npm:*"
checksum: 10c0/6c90d2ed72c5a0e440d2c75d99287e4b5df3e7b011838cdc03ae5cd518ab52164d86990e73246b9d812eaf02ec351d74e3b4f5bd325bf341e13bf980392fd53b
languageName: node
linkType: hard
"@types/react@npm:*":
version: 18.3.3
resolution: "@types/react@npm:18.3.3"
dependencies:
"@types/prop-types": "npm:*"
csstype: "npm:^3.0.2"
checksum: 10c0/fe455f805c5da13b89964c3d68060cebd43e73ec15001a68b34634604a78140e6fc202f3f61679b9d809dde6d7a7c2cb3ed51e0fd1462557911db09879b55114
languageName: node
linkType: hard
"@types/react@npm:^18.2.74":
version: 18.3.2
resolution: "@types/react@npm:18.3.2"
@@ -8276,6 +8295,20 @@ __metadata:
languageName: unknown
linkType: soft
"tgui-say@workspace:packages/tgui-say":
version: 0.0.0-use.local
resolution: "tgui-say@workspace:packages/tgui-say"
dependencies:
"@types/react": "npm:^18.2.74"
"@types/react-dom": "npm:^18.2.24"
common: "workspace:*"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
tgui: "workspace:*"
tgui-polyfill: "workspace:*"
languageName: unknown
linkType: soft
"tgui-workspace@workspace:.":
version: 0.0.0-use.local
resolution: "tgui-workspace@workspace:."

View File

@@ -138,6 +138,7 @@
#include "code\__defines\species_languages.dm"
#include "code\__defines\species_languages_vr.dm"
#include "code\__defines\species_languages_YW.dm"
#include "code\__defines\speech_channels.dm"
#include "code\__defines\spells.dm"
#include "code\__defines\sprite_sheets.dm"
#include "code\__defines\sprite_sheets_ch.dm"
@@ -174,6 +175,8 @@
#include "code\__defines\dcs\signals_ch\signals_subsystem.dm"
#include "code\__defines\dcs\signals_ch\signals_mob\signals_mob_main_ch.dm"
#include "code\__defines\traits\_traits.dm"
#include "code\__defines\traits\declarations.dm"
#include "code\__defines\traits\sources.dm"
#include "code\_global_vars\_regexes.dm"
#include "code\_global_vars\bitfields.dm"
#include "code\_global_vars\configuration_ch.dm"
@@ -186,6 +189,7 @@
#include "code\_global_vars\lists\mapping.dm"
#include "code\_global_vars\lists\misc.dm"
#include "code\_global_vars\lists\species.dm"
#include "code\_global_vars\traits\_traits.dm"
#include "code\_helpers\_global_objects.dm"
#include "code\_helpers\_global_objects_vr.dm"
#include "code\_helpers\_lists.dm"
@@ -216,6 +220,7 @@
#include "code\_helpers\string_lists.dm"
#include "code\_helpers\text.dm"
#include "code\_helpers\time.dm"
#include "code\_helpers\traits.dm"
#include "code\_helpers\turfs.dm"
#include "code\_helpers\type2type.dm"
#include "code\_helpers\typelists.dm"
@@ -2041,6 +2046,7 @@
#include "code\modules\asset_cache\asset_list_items.dm"
#include "code\modules\asset_cache\assets\chat.dm"
#include "code\modules\asset_cache\assets\fontawesome.dm"
#include "code\modules\asset_cache\assets\icon_ref_map.dm"
#include "code\modules\asset_cache\assets\jquery.dm"
#include "code\modules\asset_cache\assets\tgfont.dm"
#include "code\modules\asset_cache\assets\tgui.dm"
@@ -2198,6 +2204,7 @@
#include "code\modules\client\verbs\ignore.dm"
#include "code\modules\client\verbs\ooc.dm"
#include "code\modules\client\verbs\suicide.dm"
#include "code\modules\client\verbs\typing.dm"
#include "code\modules\client\verbs\who.dm"
#include "code\modules\clothing\chameleon.dm"
#include "code\modules\clothing\clothing.dm"
@@ -4432,6 +4439,9 @@
#include "code\modules\tgui_input\list.dm"
#include "code\modules\tgui_input\number.dm"
#include "code\modules\tgui_input\text.dm"
#include "code\modules\tgui_input\say_modal\modal.dm"
#include "code\modules\tgui_input\say_modal\speech.dm"
#include "code\modules\tgui_input\say_modal\typing.dm"
#include "code\modules\tgui_panel\audio.dm"
#include "code\modules\tgui_panel\external.dm"
#include "code\modules\tgui_panel\telemetry.dm"