* TGUI Say

* Add icon_ref_map.json to make tgui-dev-server stop screaming

* Update tgui.bundle.js

* bundle recompile

---------

Co-authored-by: Heroman3003 <31296024+Heroman3003@users.noreply.github.com>
Co-authored-by: Heroman <alesha3000@list.ru>
This commit is contained in:
ShadowLarkens
2024-08-12 05:48:06 +10:00
committed by GitHub
co-authored by Heroman3003 Heroman
parent bd1d79aebf
commit 7a7920f29e
53 changed files with 2502 additions and 339 deletions
+16
View File
@@ -46,6 +46,22 @@
],
"group": "build",
"label": "tgui: prettybuild"
},
{
"type": "shell",
"command": "bin/tgui --dev",
"windows": {
"command": ".\\bin\\tgui.bat --dev"
},
"problemMatcher": [
"$tsc",
"$eslint-stylish"
],
"options": {
"cwd": ".\\tgui\\",
},
"group": "build",
"label": "tgui: dev server"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
#undef ASSERT
/// Override BYOND's native ASSERT to optionally specify a message
#define ASSERT(condition, message...) \
if (!(condition)) { \
CRASH(assertion_message(__FILE__, __LINE__, #condition, ##message)) \
}
/proc/assertion_message(file, line, condition, message)
if (!isnull(message))
message = " - [message]"
return "[file]:[line]:Assertion failed: [condition][message]"
+9
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"
+122
View File
@@ -0,0 +1,122 @@
#define SIGNAL_ADDTRAIT(trait_ref) "addtrait [trait_ref]"
#define SIGNAL_REMOVETRAIT(trait_ref) "removetrait [trait_ref]"
// trait accessor defines
#define ADD_TRAIT(target, trait, source) \
do { \
var/list/_L; \
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; \
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/_S; \
if (sources && !islist(sources)) { \
_S = list(sources); \
} else { \
_S = sources\
}; \
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; \
SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(trait), trait); \
}; \
if (!length(_L)) { \
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/_S = sources; \
if (_L) { \
for (var/_T in _L) { \
_L[_T] &= _S;\
if (!length(_L[_T])) { \
_L -= _T; \
SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T), _T); \
}; \
};\
if (!length(_L)) { \
target._status_traits = null\
};\
}\
} while (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))
+11
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"
+7
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"
/// This trait comes from when a mob is currently typing.
#define CURRENTLY_TYPING_TRAIT "currently_typing"
+12
View File
@@ -0,0 +1,12 @@
// 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.
GLOBAL_LIST_INIT(traits_by_type, list(
/mob = list(
"TRAIT_THINKING_IN_CHARACTER" = TRAIT_THINKING_IN_CHARACTER,
)
))
+45
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)
+2
View File
@@ -23,6 +23,8 @@
/// Active timers with this datum as the target
var/list/active_timers
/// 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
@@ -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
+2 -2
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"),
+6
View File
@@ -127,6 +127,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)
@@ -179,6 +182,8 @@
GLOB.directory[ckey] = src
// Instantiate tgui panel
tgui_say = new(src, "tgui_say")
initialize_commandbar_spy()
tgui_panel = new(src, "browseroutput")
GLOB.ahelp_tickets.ClientLogin(src)
@@ -211,6 +216,7 @@
prefs.selecting_slots = FALSE
// Initialize tgui panel
tgui_say.initialize()
tgui_panel.initialize()
connection_time = world.time
@@ -226,7 +226,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"
@@ -401,6 +407,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 *
+77
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
@@ -51,33 +51,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()
-4
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
+6 -2
View File
@@ -4,6 +4,7 @@
/mob/verb/whisper(message as text)
set name = "Whisper"
set category = "IC"
set hidden = 1
//VOREStation Addition Start
if(forced_psay)
psay(message)
@@ -15,18 +16,21 @@
/mob/verb/say_verb(message as text)
set name = "Say"
set category = "IC"
set hidden = 1
//VOREStation Addition Start
if(forced_psay)
psay(message)
return
//VOREStation Addition End
set_typing_indicator(FALSE)
client?.stop_thinking()
usr.say(message)
/mob/verb/me_verb(message as message)
set name = "Me"
set category = "IC"
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."))
@@ -45,7 +49,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
+3 -2
View File
@@ -6,6 +6,7 @@
set name = "Subtle"
set category = "IC"
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,7 +19,7 @@
if(!message)
return
set_typing_indicator(FALSE)
client?.stop_thinking()
if(use_me)
usr.emote_vr("me",4,message)
else
@@ -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
+39 -47
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,77 +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"
/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"
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"
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"
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)
+2
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)
+5 -2
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
+129
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
+106
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
@@ -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)
+46
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

+46 -18
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"
@@ -1562,3 +1571,22 @@ window "text_editor"
border = line
saved-params = ""
multi-line = true
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 = ""
@@ -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');
});
});
+89
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;
}
}
@@ -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');
});
});
+59
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;
}
}
+389
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/;
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.Up | KEY.Down) {
const currentValue = this.innerRef.current?.value;
if (direction === KEY.Up) {
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.Up:
case KEY.Down:
// 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}
/>
);
};
+32
View File
@@ -0,0 +1,32 @@
/** 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 = {
':b ': '010',
':c ': 'Cmd',
':e ': 'Eng',
':h ': 'Dept',
':m ': 'Med',
':n ': 'Sci',
':p ': 'AI',
':s ': 'Sec',
':t ': 'Merc',
':u ': 'Sup',
':v ': 'Srv',
':y ': 'ITV',
} as const;
Binary file not shown.
+46
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}`,
});
};
+18
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 />);
};
+14
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:*"
}
}
+29
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;
}
}
+44
View File
@@ -0,0 +1,44 @@
@use 'sass:map';
$background: #131313;
$button: #1f1f1f;
$lightMode: #ffffff;
$lightBorder: #bbbbbb;
$lightHover: #eaeaea;
$_channel_map: (
// Radio
'010': #1e90ff,
'Cmd': #57b8f0,
'Eng': #fcdf03,
'Dept': #1ecc43,
'Med': #57f09e,
'Sci': #c68cfa,
'AI': #d65d95,
'Sec': #dd3535,
'Merc': #8f4a4b,
'Sup': #b88646,
'Srv': #6ca729,
'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),
)
);
}
@@ -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;
}
@@ -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;
}
+70
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;
}
}
}
@@ -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;
}
+29
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;
}
+23
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;
@@ -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) => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
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
+33
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:."
+12
View File
@@ -36,6 +36,7 @@
#include "code\__defines\ammunition.dm"
#include "code\__defines\appearance.dm"
#include "code\__defines\assemblies.dm"
#include "code\__defines\assert.dm"
#include "code\__defines\atmos.dm"
#include "code\__defines\atoms_movable.dm"
#include "code\__defines\belly_modes_vr.dm"
@@ -122,6 +123,7 @@
#include "code\__defines\span_vr.dm"
#include "code\__defines\species_languages.dm"
#include "code\__defines\species_languages_vr.dm"
#include "code\__defines\speech_channels.dm"
#include "code\__defines\spells.dm"
#include "code\__defines\sprite_sheets.dm"
#include "code\__defines\sqlite_defines.dm"
@@ -150,6 +152,9 @@
#include "code\__defines\dcs\flags.dm"
#include "code\__defines\dcs\helpers.dm"
#include "code\__defines\dcs\signals.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\misc.dm"
@@ -160,6 +165,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"
@@ -185,6 +191,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\unsorted.dm"
@@ -1887,6 +1894,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"
@@ -2034,6 +2042,7 @@
#include "code\modules\client\verbs\ooc.dm"
#include "code\modules\client\verbs\ping.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"
@@ -4115,6 +4124,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"