From acfa5e4fdd0e2d490db1282450eb7d1ad09517da Mon Sep 17 00:00:00 2001 From: Jeremiah <42397676+jlsnow301@users.noreply.github.com> Date: Thu, 16 Jun 2022 17:21:21 -0700 Subject: [PATCH] TGUI Say: Upgrades chat input with modern features (#67116) Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com> Co-authored-by: AnturK Co-authored-by: iamgoofball Co-authored-by: Aleksej Komarov Co-authored-by: KubeRoot <6917698+KubeRoot@users.noreply.github.com> Co-authored-by: Kapu1178 <75460809+Kapu1178@users.noreply.github.com> Co-authored-by: Iamgoofball <4081722+Iamgoofball@users.noreply.github.com> Co-authored-by: DomitiusKnack <56321744+DomitiusKnack@users.noreply.github.com> Co-authored-by: LemonInTheDark <58055496+LemonInTheDark@users.noreply.github.com> Co-authored-by: Wallem <66052067+Wallemations@users.noreply.github.com> --- code/__DEFINES/keybinding.dm | 4 +- code/__DEFINES/layers.dm | 2 + code/__DEFINES/logging.dm | 5 +- code/__DEFINES/speech_channels.dm | 5 + code/__HELPERS/logging/_logging.dm | 2 + code/__HELPERS/logging/talk.dm | 5 + code/_globalvars/logging.dm | 2 + .../configuration/entries/general.dm | 3 + code/datums/keybinding/communication.dm | 12 +- code/game/world.dm | 1 + code/modules/admin/verbs/commandreport.dm | 5 +- code/modules/client/client_defines.dm | 3 + code/modules/client/client_procs.dm | 22 ++- code/modules/client/preferences/tgui.dm | 10 + .../mob/living/carbon/human/human_say.dm | 2 +- .../mob/living/carbon/human/species.dm | 179 +++++++++-------- code/modules/mob/mob_defines.dm | 7 + code/modules/tgui/tgui_alert.dm | 183 ------------------ code/modules/tgui_input/alert.dm | 133 +++++++++++++ .../tgui_input_list.dm => tgui_input/list.dm} | 83 ++------ .../number.dm} | 88 ++------- code/modules/tgui_input/say_modal/modal.dm | 133 +++++++++++++ code/modules/tgui_input/say_modal/speech.dm | 92 +++++++++ code/modules/tgui_input/say_modal/typing.dm | 111 +++++++++++ .../tgui_input_text.dm => tgui_input/text.dm} | 98 ++-------- config/config.txt | 3 + icons/mob/talk.dmi | Bin 12767 -> 12967 bytes interface/skin.dmf | 19 ++ tgstation.dme | 12 +- tgui/packages/common/timer.js | 26 ++- .../packages/tgui-say/components/dragzone.tsx | 20 ++ tgui/packages/tgui-say/constants/index.tsx | 73 +++++++ tgui/packages/tgui-say/handlers/arrowKeys.tsx | 15 ++ .../tgui-say/handlers/backspaceDelete.tsx | 22 +++ tgui/packages/tgui-say/handlers/click.tsx | 9 + .../tgui-say/handlers/componentMount.tsx | 23 +++ .../tgui-say/handlers/componentUpdate.tsx | 6 + tgui/packages/tgui-say/handlers/enter.tsx | 23 +++ tgui/packages/tgui-say/handlers/escape.tsx | 8 + tgui/packages/tgui-say/handlers/force.tsx | 19 ++ .../tgui-say/handlers/incrementChannel.tsx | 32 +++ tgui/packages/tgui-say/handlers/index.tsx | 41 ++++ tgui/packages/tgui-say/handlers/input.tsx | 11 ++ tgui/packages/tgui-say/handlers/keyDown.tsx | 35 ++++ .../tgui-say/handlers/radioPrefix.tsx | 34 ++++ tgui/packages/tgui-say/handlers/reset.tsx | 21 ++ tgui/packages/tgui-say/handlers/setSize.tsx | 22 +++ .../tgui-say/handlers/viewHistory.tsx | 24 +++ tgui/packages/tgui-say/helpers/index.tsx | 157 +++++++++++++++ tgui/packages/tgui-say/index.tsx | 19 ++ tgui/packages/tgui-say/interfaces/TguiSay.tsx | 75 +++++++ tgui/packages/tgui-say/package.json | 13 ++ tgui/packages/tgui-say/styles/button.scss | 47 +++++ tgui/packages/tgui-say/styles/colors.scss | 60 ++++++ tgui/packages/tgui-say/styles/dragzone.scss | 73 +++++++ tgui/packages/tgui-say/styles/main.scss | 13 ++ tgui/packages/tgui-say/styles/modal.scss | 70 +++++++ tgui/packages/tgui-say/styles/textarea.scss | 24 +++ tgui/packages/tgui-say/types/index.tsx | 57 ++++++ tgui/packages/tgui/components/TextArea.js | 49 +++-- .../packages/tgui/interfaces/CommandReport.js | 90 --------- .../tgui/interfaces/CommandReport.tsx | 130 +++++++++++++ .../features/game_preferences/tgui.tsx | 7 + .../game_preferences/typing_indicator.tsx | 8 + .../tgui/interfaces/TextInputModal.tsx | 13 +- tgui/webpack.config.js | 4 + tgui/yarn.lock | 13 ++ tools/build/build.js | 2 + 68 files changed, 1990 insertions(+), 622 deletions(-) create mode 100644 code/__DEFINES/speech_channels.dm delete mode 100644 code/modules/tgui/tgui_alert.dm create mode 100644 code/modules/tgui_input/alert.dm rename code/modules/{tgui/tgui_input_list.dm => tgui_input/list.dm} (61%) rename code/modules/{tgui/tgui_input_number.dm => tgui_input/number.dm} (61%) create mode 100644 code/modules/tgui_input/say_modal/modal.dm create mode 100644 code/modules/tgui_input/say_modal/speech.dm create mode 100644 code/modules/tgui_input/say_modal/typing.dm rename code/modules/{tgui/tgui_input_text.dm => tgui_input/text.dm} (59%) create mode 100644 tgui/packages/tgui-say/components/dragzone.tsx create mode 100644 tgui/packages/tgui-say/constants/index.tsx create mode 100644 tgui/packages/tgui-say/handlers/arrowKeys.tsx create mode 100644 tgui/packages/tgui-say/handlers/backspaceDelete.tsx create mode 100644 tgui/packages/tgui-say/handlers/click.tsx create mode 100644 tgui/packages/tgui-say/handlers/componentMount.tsx create mode 100644 tgui/packages/tgui-say/handlers/componentUpdate.tsx create mode 100644 tgui/packages/tgui-say/handlers/enter.tsx create mode 100644 tgui/packages/tgui-say/handlers/escape.tsx create mode 100644 tgui/packages/tgui-say/handlers/force.tsx create mode 100644 tgui/packages/tgui-say/handlers/incrementChannel.tsx create mode 100644 tgui/packages/tgui-say/handlers/index.tsx create mode 100644 tgui/packages/tgui-say/handlers/input.tsx create mode 100644 tgui/packages/tgui-say/handlers/keyDown.tsx create mode 100644 tgui/packages/tgui-say/handlers/radioPrefix.tsx create mode 100644 tgui/packages/tgui-say/handlers/reset.tsx create mode 100644 tgui/packages/tgui-say/handlers/setSize.tsx create mode 100644 tgui/packages/tgui-say/handlers/viewHistory.tsx create mode 100644 tgui/packages/tgui-say/helpers/index.tsx create mode 100644 tgui/packages/tgui-say/index.tsx create mode 100644 tgui/packages/tgui-say/interfaces/TguiSay.tsx create mode 100644 tgui/packages/tgui-say/package.json create mode 100644 tgui/packages/tgui-say/styles/button.scss create mode 100644 tgui/packages/tgui-say/styles/colors.scss create mode 100644 tgui/packages/tgui-say/styles/dragzone.scss create mode 100644 tgui/packages/tgui-say/styles/main.scss create mode 100644 tgui/packages/tgui-say/styles/modal.scss create mode 100644 tgui/packages/tgui-say/styles/textarea.scss create mode 100644 tgui/packages/tgui-say/types/index.tsx delete mode 100644 tgui/packages/tgui/interfaces/CommandReport.js create mode 100644 tgui/packages/tgui/interfaces/CommandReport.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/typing_indicator.tsx diff --git a/code/__DEFINES/keybinding.dm b/code/__DEFINES/keybinding.dm index 2fc4f935809..dbc8ef85b17 100644 --- a/code/__DEFINES/keybinding.dm +++ b/code/__DEFINES/keybinding.dm @@ -29,9 +29,11 @@ #define COMSIG_KB_CLIENT_MINIMALHUD_DOWN "keybinding_client_minimalhud_down" //Communication -#define COMSIG_KB_CLIENT_OOC_DOWN "keybinding_client_ooc_down" + #define COMSIG_KB_CLIENT_SAY_DOWN "keybinding_client_say_down" +#define COMSIG_KB_CLIENT_RADIO_DOWN "keybinding_client_radio_down" #define COMSIG_KB_CLIENT_ME_DOWN "keybinding_client_me_down" +#define COMSIG_KB_CLIENT_OOC_DOWN "keybinding_client_ooc_down" //Human #define COMSIG_KB_HUMAN_QUICKEQUIP_DOWN "keybinding_human_quickequip_down" diff --git a/code/__DEFINES/layers.dm b/code/__DEFINES/layers.dm index cdc23da292a..634315447f1 100644 --- a/code/__DEFINES/layers.dm +++ b/code/__DEFINES/layers.dm @@ -186,6 +186,8 @@ #define RUNECHAT_PLANE 501 /// Plane for balloon text (text that fades up) #define BALLOON_CHAT_PLANE 502 +/// Bubble for typing indicators +#define TYPING_LAYER 500 //-------------------- HUD --------------------- //HUD layer defines diff --git a/code/__DEFINES/logging.dm b/code/__DEFINES/logging.dm index 9dae5569556..bfe6a591c72 100644 --- a/code/__DEFINES/logging.dm +++ b/code/__DEFINES/logging.dm @@ -38,15 +38,16 @@ #define LOG_ECON (1 << 18) #define LOG_VICTIM (1 << 19) #define LOG_RADIO_EMOTE (1 << 20) +#define LOG_SPEECH_INDICATORS (1 << 21) //Individual logging panel pages #define INDIVIDUAL_ATTACK_LOG (LOG_ATTACK | LOG_VICTIM) -#define INDIVIDUAL_SAY_LOG (LOG_SAY | LOG_WHISPER | LOG_DSAY) +#define INDIVIDUAL_SAY_LOG (LOG_SAY | LOG_WHISPER | LOG_DSAY | LOG_SPEECH_INDICATORS) #define INDIVIDUAL_EMOTE_LOG (LOG_EMOTE | LOG_RADIO_EMOTE) #define INDIVIDUAL_COMMS_LOG (LOG_PDA | LOG_CHAT | LOG_COMMENT | LOG_TELECOMMS) #define INDIVIDUAL_OOC_LOG (LOG_OOC | LOG_ADMIN) #define INDIVIDUAL_OWNERSHIP_LOG (LOG_OWNERSHIP) -#define INDIVIDUAL_SHOW_ALL_LOG (LOG_ATTACK | LOG_SAY | LOG_WHISPER | LOG_EMOTE | LOG_RADIO_EMOTE | LOG_DSAY | LOG_PDA | LOG_CHAT | LOG_COMMENT | LOG_TELECOMMS | LOG_OOC | LOG_ADMIN | LOG_OWNERSHIP | LOG_GAME | LOG_ADMIN_PRIVATE | LOG_ASAY | LOG_MECHA | LOG_VIRUS | LOG_SHUTTLE | LOG_ECON | LOG_VICTIM) +#define INDIVIDUAL_SHOW_ALL_LOG (LOG_ATTACK | LOG_SAY | LOG_WHISPER | LOG_EMOTE | LOG_RADIO_EMOTE | LOG_DSAY | LOG_PDA | LOG_CHAT | LOG_COMMENT | LOG_TELECOMMS | LOG_OOC | LOG_ADMIN | LOG_OWNERSHIP | LOG_GAME | LOG_ADMIN_PRIVATE | LOG_ASAY | LOG_MECHA | LOG_VIRUS | LOG_SHUTTLE | LOG_ECON | LOG_VICTIM | LOG_SPEECH_INDICATORS) #define LOGSRC_CKEY "Ckey" #define LOGSRC_MOB "Mob" diff --git a/code/__DEFINES/speech_channels.dm b/code/__DEFINES/speech_channels.dm new file mode 100644 index 00000000000..1be25c62ac8 --- /dev/null +++ b/code/__DEFINES/speech_channels.dm @@ -0,0 +1,5 @@ +// Used to direct channels to speak into. +#define SAY_CHANNEL "Say" +#define RADIO_CHANNEL "Radio" +#define ME_CHANNEL "Me" +#define OOC_CHANNEL "OOC" diff --git a/code/__HELPERS/logging/_logging.dm b/code/__HELPERS/logging/_logging.dm index 30f34a4f7b6..ac0181d5282 100644 --- a/code/__HELPERS/logging/_logging.dm +++ b/code/__HELPERS/logging/_logging.dm @@ -114,6 +114,8 @@ GLOBAL_LIST_INIT(testing_global_profiler, list("_PROFILE_NAME" = "Global")) log_mecha(log_text) if(LOG_SHUTTLE) log_shuttle(log_text) + if(LOG_SPEECH_INDICATORS) + log_speech_indicators(log_text) else stack_trace("Invalid individual logging type: [message_type]. Defaulting to [LOG_GAME] (LOG_GAME).") log_game(log_text) diff --git a/code/__HELPERS/logging/talk.dm b/code/__HELPERS/logging/talk.dm index 86382f6a339..bed13b6c350 100644 --- a/code/__HELPERS/logging/talk.dm +++ b/code/__HELPERS/logging/talk.dm @@ -40,3 +40,8 @@ /proc/log_telecomms(text) if (CONFIG_GET(flag/log_telecomms)) WRITE_LOG(GLOB.world_telecomms_log, "TCOMMS: [text]") + +/// Logging for speech indicators. +/proc/log_speech_indicators(text) + if (CONFIG_GET(flag/log_speech_indicators)) + WRITE_LOG(GLOB.world_speech_indicators_log, "SPEECH INDICATOR: [text]") diff --git a/code/_globalvars/logging.dm b/code/_globalvars/logging.dm index fc7368b6cda..31447e7cc50 100644 --- a/code/_globalvars/logging.dm +++ b/code/_globalvars/logging.dm @@ -33,6 +33,8 @@ GLOBAL_VAR(world_uplink_log) GLOBAL_PROTECT(world_uplink_log) GLOBAL_VAR(world_telecomms_log) GLOBAL_PROTECT(world_telecomms_log) +GLOBAL_VAR(world_speech_indicators_log) +GLOBAL_PROTECT(world_speech_indicators_log) GLOBAL_VAR(world_manifest_log) GLOBAL_PROTECT(world_manifest_log) GLOBAL_VAR(query_debug_log) diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index ec482d3ad65..65fce5d270f 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -131,6 +131,9 @@ /// log telecomms messages /datum/config_entry/flag/log_telecomms +/// log speech indicators(started/stopped speaking) +/datum/config_entry/flag/log_speech_indicators + /// log certain expliotable parrots and other such fun things in a JSON file of twitter valid phrases. /datum/config_entry/flag/log_twitter diff --git a/code/datums/keybinding/communication.dm b/code/datums/keybinding/communication.dm index 36301da7ab6..0c96919651c 100644 --- a/code/datums/keybinding/communication.dm +++ b/code/datums/keybinding/communication.dm @@ -3,18 +3,24 @@ /datum/keybinding/client/communication/say hotkey_keys = list("T") - name = "Say" + name = SAY_CHANNEL full_name = "IC Say" keybind_signal = COMSIG_KB_CLIENT_SAY_DOWN +/datum/keybinding/client/communication/radio + hotkey_keys = list("Y") + name = RADIO_CHANNEL + full_name = "IC Radio (;)" + keybind_signal = COMSIG_KB_CLIENT_RADIO_DOWN + /datum/keybinding/client/communication/ooc hotkey_keys = list("O") - name = "OOC" + name = OOC_CHANNEL full_name = "Out Of Character Say (OOC)" keybind_signal = COMSIG_KB_CLIENT_OOC_DOWN /datum/keybinding/client/communication/me hotkey_keys = list("M") - name = "Me" + name = ME_CHANNEL full_name = "Custom Emote (/Me)" keybind_signal = COMSIG_KB_CLIENT_ME_DOWN diff --git a/code/game/world.dm b/code/game/world.dm index 5f041fcbd03..a87994f54ac 100644 --- a/code/game/world.dm +++ b/code/game/world.dm @@ -142,6 +142,7 @@ GLOBAL_VAR(restart_counter) GLOB.world_pda_log = "[GLOB.log_directory]/pda.log" GLOB.world_uplink_log = "[GLOB.log_directory]/uplink.log" GLOB.world_telecomms_log = "[GLOB.log_directory]/telecomms.log" + GLOB.world_speech_indicators_log = "[GLOB.log_directory]/speech_indicators.log" GLOB.world_manifest_log = "[GLOB.log_directory]/manifest.log" GLOB.world_href_log = "[GLOB.log_directory]/hrefs.log" GLOB.world_mob_tag_log = "[GLOB.log_directory]/mob_tags.log" diff --git a/code/modules/admin/verbs/commandreport.dm b/code/modules/admin/verbs/commandreport.dm index c80c1900d4e..c6fa3e55408 100644 --- a/code/modules/admin/verbs/commandreport.dm +++ b/code/modules/admin/verbs/commandreport.dm @@ -99,8 +99,6 @@ custom_name = FALSE command_name = params["updated_name"] - if("update_report_contents") - command_report_content = params["updated_contents"] if("set_report_sound") played_sound = params["picked_sound"] if("toggle_announce") @@ -109,9 +107,10 @@ if(!command_name) to_chat(ui_user, span_danger("You can't send a report with no command name.")) return - if(!command_report_content) + if(!params["report"]) to_chat(ui_user, span_danger("You can't send a report with no contents.")) return + command_report_content = params["report"] send_announcement() return TRUE diff --git a/code/modules/client/client_defines.dm b/code/modules/client/client_defines.dm index cf3cd8ee37b..1edac7e630c 100644 --- a/code/modules/client/client_defines.dm +++ b/code/modules/client/client_defines.dm @@ -251,3 +251,6 @@ /// If this client has been fully initialized or not var/fully_created = FALSE + + /// Does this client have typing indicators enabled? + var/typing_indicators = FALSE diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index 73520d79de7..a725fc52360 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -219,6 +219,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( // Instantiate tgui panel tgui_panel = new(src, "browseroutput") + tgui_say = new(src, "tgui_say") + set_right_click_menu_mode(TRUE) GLOB.ahelp_tickets.ClientLogin(src) @@ -353,6 +355,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( // Initialize tgui panel tgui_panel.initialize() + tgui_say.initialize() + if(alert_mob_dupe_login && !holder) var/dupe_login_message = "Your ComputerID has already logged in with another key this round, please log out of this one NOW or risk being banned!" if (alert_admin_multikey) @@ -1031,12 +1035,18 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( movement_keys[key] = WEST if("South") movement_keys[key] = SOUTH - if("Say") - winset(src, "default-[REF(key)]", "parent=default;name=[key];command=say") - if("OOC") - winset(src, "default-[REF(key)]", "parent=default;name=[key];command=ooc") - if("Me") - winset(src, "default-[REF(key)]", "parent=default;name=[key];command=me") + if(SAY_CHANNEL) + var/say = tgui_say_create_open_command(SAY_CHANNEL) + winset(src, "default-[REF(key)]", "parent=default;name=[key];command=[say]") + if(RADIO_CHANNEL) + var/radio = tgui_say_create_open_command(RADIO_CHANNEL) + winset(src, "default-[REF(key)]", "parent=default;name=[key];command=[radio]") + if(ME_CHANNEL) + var/me = tgui_say_create_open_command(ME_CHANNEL) + winset(src, "default-[REF(key)]", "parent=default;name=[key];command=[me]") + if(OOC_CHANNEL) + var/ooc = tgui_say_create_open_command(OOC_CHANNEL) + winset(src, "default-[REF(key)]", "parent=default;name=[key];command=[ooc]") /client/proc/change_view(new_size) if (isnull(new_size)) diff --git a/code/modules/client/preferences/tgui.dm b/code/modules/client/preferences/tgui.dm index e04746e6ab0..9413a129e4e 100644 --- a/code/modules/client/preferences/tgui.dm +++ b/code/modules/client/preferences/tgui.dm @@ -47,3 +47,13 @@ for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis) // Force it to reload either way tgui.update_static_data(client.mob) + +/// Light mode for tgui say +/datum/preference/toggle/tgui_say_light_mode + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tgui_say_light_mode" + savefile_identifier = PREFERENCE_PLAYER + default_value = FALSE + +/datum/preference/toggle/tgui_say_light_mode/apply_to_client(client/client) + client.tgui_say?.load() diff --git a/code/modules/mob/living/carbon/human/human_say.dm b/code/modules/mob/living/carbon/human/human_say.dm index 5d27c1bcfa0..f128edc322d 100644 --- a/code/modules/mob/living/carbon/human/human_say.dm +++ b/code/modules/mob/living/carbon/human/human_say.dm @@ -84,4 +84,4 @@ /mob/living/carbon/human/get_alt_name() if(name != GetVoice()) - return " (as [get_id_name("Unknown")])"\ + return " (as [get_id_name("Unknown")])" diff --git a/code/modules/mob/living/carbon/human/species.dm b/code/modules/mob/living/carbon/human/species.dm index 367276b21de..f952fb621e9 100644 --- a/code/modules/mob/living/carbon/human/species.dm +++ b/code/modules/mob/living/carbon/human/species.dm @@ -1120,10 +1120,14 @@ GLOBAL_LIST_EMPTY(features_by_species) var/attack_direction = get_dir(user, target) if(atk_effect == ATTACK_EFFECT_KICK)//kicks deal 1.5x raw damage target.apply_damage(damage*1.5, user.dna.species.attack_type, affecting, armor_block, attack_direction = attack_direction) + if((damage * 1.5) >= 9) + target.force_say() log_combat(user, target, "kicked") else//other attacks deal full raw damage + 1.5x in stamina damage target.apply_damage(damage, user.dna.species.attack_type, affecting, armor_block, attack_direction = attack_direction) target.apply_damage(damage*1.5, STAMINA, affecting, armor_block) + if(damage >= 9) + target.force_say() log_combat(user, target, "punched") if((target.stat != DEAD) && damage >= user.dna.species.punchstunthreshold) @@ -1157,126 +1161,133 @@ GLOBAL_LIST_EMPTY(features_by_species) /datum/species/proc/spec_hitby(atom/movable/AM, mob/living/carbon/human/H) return -/datum/species/proc/spec_attack_hand(mob/living/carbon/human/M, mob/living/carbon/human/H, datum/martial_art/attacker_style, modifiers) - if(!istype(M)) +/datum/species/proc/spec_attack_hand(mob/living/carbon/human/owner, mob/living/carbon/human/target, datum/martial_art/attacker_style, modifiers) + if(!istype(owner)) return - CHECK_DNA_AND_SPECIES(M) - CHECK_DNA_AND_SPECIES(H) + CHECK_DNA_AND_SPECIES(owner) + CHECK_DNA_AND_SPECIES(target) - if(!istype(M)) //sanity check for drones. + if(!istype(owner)) //sanity check for drones. return - if(M.mind) - attacker_style = M.mind.martial_art - if((M != H) && M.combat_mode && H.check_shields(M, 0, M.name, attack_type = UNARMED_ATTACK)) - log_combat(M, H, "attempted to touch") - H.visible_message(span_warning("[M] attempts to touch [H]!"), \ - span_danger("[M] attempts to touch you!"), span_hear("You hear a swoosh!"), COMBAT_MESSAGE_RANGE, M) - to_chat(M, span_warning("You attempt to touch [H]!")) + if(owner.mind) + attacker_style = owner.mind.martial_art + if((owner != target) && owner.combat_mode && target.check_shields(owner, 0, owner.name, attack_type = UNARMED_ATTACK)) + log_combat(owner, target, "attempted to touch") + target.visible_message(span_warning("[owner] attempts to touch [target]!"), \ + span_danger("[owner] attempts to touch you!"), span_hear("You hear a swoosh!"), COMBAT_MESSAGE_RANGE, owner) + to_chat(owner, span_warning("You attempt to touch [target]!")) return - SEND_SIGNAL(M, COMSIG_MOB_ATTACK_HAND, M, H, attacker_style) + SEND_SIGNAL(owner, COMSIG_MOB_ATTACK_HAND, owner, target, attacker_style) if(LAZYACCESS(modifiers, RIGHT_CLICK)) - disarm(M, H, attacker_style) + disarm(owner, target, attacker_style) return // dont attack after - if(M.combat_mode) - harm(M, H, attacker_style) + if(owner.combat_mode) + harm(owner, target, attacker_style) else - help(M, H, attacker_style) + help(owner, target, attacker_style) -/datum/species/proc/spec_attacked_by(obj/item/I, mob/living/user, obj/item/bodypart/affecting, mob/living/carbon/human/H) +/datum/species/proc/spec_attacked_by(obj/item/weapon, mob/living/user, obj/item/bodypart/affecting, mob/living/carbon/human/human) // Allows you to put in item-specific reactions based on species - if(user != H) - if(H.check_shields(I, I.force, "the [I.name]", MELEE_ATTACK, I.armour_penetration)) + if(user != human) + if(human.check_shields(weapon, weapon.force, "the [weapon.name]", MELEE_ATTACK, weapon.armour_penetration)) return FALSE - if(H.check_block()) - H.visible_message(span_warning("[H] blocks [I]!"), \ - span_userdanger("You block [I]!")) + if(human.check_block()) + human.visible_message(span_warning("[human] blocks [weapon]!"), \ + span_userdanger("You block [weapon]!")) return FALSE var/hit_area if(!affecting) //Something went wrong. Maybe the limb is missing? - affecting = H.bodyparts[1] + affecting = human.bodyparts[1] hit_area = affecting.plaintext_zone var/def_zone = affecting.body_zone - var/armor_block = H.run_armor_check(affecting, MELEE, span_notice("Your armor has protected your [hit_area]!"), span_warning("Your armor has softened a hit to your [hit_area]!"),I.armour_penetration, weak_against_armour = I.weak_against_armour) + var/armor_block = human.run_armor_check(affecting, MELEE, span_notice("Your armor has protected your [hit_area]!"), span_warning("Your armor has softened a hit to your [hit_area]!"),weapon.armour_penetration, weak_against_armour = weapon.weak_against_armour) armor_block = min(ARMOR_MAX_BLOCK, armor_block) //cap damage reduction at 90% - var/Iwound_bonus = I.wound_bonus + var/Iwound_bonus = weapon.wound_bonus // this way, you can't wound with a surgical tool on help intent if they have a surgery active and are lying down, so a misclick with a circular saw on the wrong limb doesn't bleed them dry (they still get hit tho) - if((I.item_flags & SURGICAL_TOOL) && !user.combat_mode && H.body_position == LYING_DOWN && (LAZYLEN(H.surgeries) > 0)) + if((weapon.item_flags & SURGICAL_TOOL) && !user.combat_mode && human.body_position == LYING_DOWN && (LAZYLEN(human.surgeries) > 0)) Iwound_bonus = CANT_WOUND - var/weakness = check_species_weakness(I, user) + var/weakness = check_species_weakness(weapon, user) - H.send_item_attack_message(I, user, hit_area, affecting) + human.send_item_attack_message(weapon, user, hit_area, affecting) - var/attack_direction = get_dir(user, H) - apply_damage(I.force * weakness, I.damtype, def_zone, armor_block, H, wound_bonus = Iwound_bonus, bare_wound_bonus = I.bare_wound_bonus, sharpness = I.get_sharpness(), attack_direction = attack_direction) + var/attack_direction = get_dir(user, human) + apply_damage(weapon.force * weakness, weapon.damtype, def_zone, armor_block, human, wound_bonus = Iwound_bonus, bare_wound_bonus = weapon.bare_wound_bonus, sharpness = weapon.get_sharpness(), attack_direction = attack_direction) - if(!I.force) + if(!weapon.force) return FALSE //item force is zero - var/bloody = FALSE - if(((I.damtype == BRUTE) && I.force && prob(25 + (I.force * 2)))) - if(IS_ORGANIC_LIMB(affecting)) - I.add_mob_blood(H) //Make the weapon bloody, not the person. - if(prob(I.force * 2)) //blood spatter! - bloody = TRUE - var/turf/location = H.loc - if(istype(location)) - H.add_splatter_floor(location) - if(get_dist(user, H) <= 1) //people with TK won't get smeared with blood - user.add_mob_blood(H) + if(weapon.damtype != BRUTE) + return TRUE + if(!(prob(25 + (weapon.force * 2)))) + return TRUE - switch(hit_area) - if(BODY_ZONE_HEAD) - if(!I.get_sharpness() && armor_block < 50) - if(prob(I.force)) - H.adjustOrganLoss(ORGAN_SLOT_BRAIN, 20) - if(H.stat == CONSCIOUS) - H.visible_message(span_danger("[H] is knocked senseless!"), \ - span_userdanger("You're knocked senseless!")) - H.set_timed_status_effect(20 SECONDS, /datum/status_effect/confusion, only_if_higher = TRUE) - H.adjust_blurriness(10) - if(prob(10)) - H.gain_trauma(/datum/brain_trauma/mild/concussion) - else - H.adjustOrganLoss(ORGAN_SLOT_BRAIN, I.force * 0.2) + if(IS_ORGANIC_LIMB(affecting)) + weapon.add_mob_blood(human) //Make the weapon bloody, not the person. + if(prob(weapon.force * 2)) //blood spatter! + bloody = TRUE + var/turf/location = human.loc + if(istype(location)) + human.add_splatter_floor(location) + if(get_dist(user, human) <= 1) //people with TK won't get smeared with blood + user.add_mob_blood(human) - if(H.mind && H.stat == CONSCIOUS && H != user && prob(I.force + ((100 - H.health) * 0.5))) // rev deconversion through blunt trauma. - var/datum/antagonist/rev/rev = H.mind.has_antag_datum(/datum/antagonist/rev) - if(rev) - rev.remove_revolutionary(FALSE, user) + switch(hit_area) + if(BODY_ZONE_HEAD) + if(!weapon.get_sharpness() && armor_block < 50) + if(prob(weapon.force)) + human.adjustOrganLoss(ORGAN_SLOT_BRAIN, 20) + if(human.stat == CONSCIOUS) + human.visible_message(span_danger("[human] is knocked senseless!"), \ + span_userdanger("You're knocked senseless!")) + human.set_timed_status_effect(20 SECONDS, /datum/status_effect/confusion, only_if_higher = TRUE) + human.adjust_blurriness(10) + if(prob(10)) + human.gain_trauma(/datum/brain_trauma/mild/concussion) + else + human.adjustOrganLoss(ORGAN_SLOT_BRAIN, weapon.force * 0.2) - if(bloody) //Apply blood - if(H.wear_mask) - H.wear_mask.add_mob_blood(H) - H.update_inv_wear_mask() - if(H.head) - H.head.add_mob_blood(H) - H.update_inv_head() - if(H.glasses && prob(33)) - H.glasses.add_mob_blood(H) - H.update_inv_glasses() + if(human.mind && human.stat == CONSCIOUS && human != user && prob(weapon.force + ((100 - human.health) * 0.5))) // rev deconversion through blunt trauma. + var/datum/antagonist/rev/rev = human.mind.has_antag_datum(/datum/antagonist/rev) + if(rev) + rev.remove_revolutionary(FALSE, user) - if(BODY_ZONE_CHEST) - if(H.stat == CONSCIOUS && !I.get_sharpness() && armor_block < 50) - if(prob(I.force)) - H.visible_message(span_danger("[H] is knocked down!"), \ - span_userdanger("You're knocked down!")) - H.apply_effect(60, EFFECT_KNOCKDOWN, armor_block) + if(bloody) //Apply blood + if(human.wear_mask) + human.wear_mask.add_mob_blood(human) + human.update_inv_wear_mask() + if(human.head) + human.head.add_mob_blood(human) + human.update_inv_head() + if(human.glasses && prob(33)) + human.glasses.add_mob_blood(human) + human.update_inv_glasses() - if(bloody) - if(H.wear_suit) - H.wear_suit.add_mob_blood(H) - H.update_inv_wear_suit() - if(H.w_uniform) - H.w_uniform.add_mob_blood(H) - H.update_inv_w_uniform() + if(BODY_ZONE_CHEST) + if(human.stat == CONSCIOUS && !weapon.get_sharpness() && armor_block < 50) + if(prob(weapon.force)) + human.visible_message(span_danger("[human] is knocked down!"), \ + span_userdanger("You're knocked down!")) + human.apply_effect(60, EFFECT_KNOCKDOWN, armor_block) + + if(bloody) + if(human.wear_suit) + human.wear_suit.add_mob_blood(human) + human.update_inv_wear_suit() + if(human.w_uniform) + human.w_uniform.add_mob_blood(human) + human.update_inv_w_uniform() + + /// Triggers force say events + if(weapon.force > 10 || weapon.force >= 5 && prob(33)) + human.force_say(user) return TRUE diff --git a/code/modules/mob/mob_defines.dm b/code/modules/mob/mob_defines.dm index 1672ff86d61..0560fa3d77d 100644 --- a/code/modules/mob/mob_defines.dm +++ b/code/modules/mob/mob_defines.dm @@ -234,3 +234,10 @@ var/datum/client_interface/mock_client var/interaction_range = 0 //how far a mob has to be to interact with something without caring about obsctruction, defaulted to 0 tiles + + /// Typing indicator - mob is typing into a input + var/typing_indicator = FALSE + /// Thinking indicator - mob has input window open + var/thinking_indicator = FALSE + /// User is thinking in character. Used to revert to thinking state after stop_typing + var/thinking_IC = FALSE diff --git a/code/modules/tgui/tgui_alert.dm b/code/modules/tgui/tgui_alert.dm deleted file mode 100644 index 71a59550092..00000000000 --- a/code/modules/tgui/tgui_alert.dm +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Creates a TGUI alert window and returns the user's response. - * - * This proc should be used to create alerts that the caller will wait for a response from. - * Arguments: - * * user - The user to show the alert to. - * * message - The content of the alert, shown in the body of the TGUI window. - * * title - The of the alert modal, shown on the top of the TGUI window. - * * buttons - The options that can be chosen by the user, each string is assigned a button on the UI. - * * timeout - The timeout of the alert, after which the modal will close and qdel itself. Set to zero for no timeout. - * * autofocus - The bool that controls if this alert should grab window focus. - */ -/proc/tgui_alert(mob/user, message = "", title, list/buttons = list("Ok"), timeout = 0, autofocus = TRUE) - if (!user) - user = usr - if (!istype(user)) - if (istype(user, /client)) - var/client/client = user - user = client.mob - else - return - // Client does NOT have tgui_input on: Returns regular input - if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) - if(length(buttons) == 2) - return alert(user, message, title, buttons[1], buttons[2]) - if(length(buttons) == 3) - return alert(user, message, title, buttons[1], buttons[2], buttons[3]) - var/datum/tgui_modal/alert = new(user, message, title, buttons, timeout, autofocus) - alert.ui_interact(user) - alert.wait() - if (alert) - . = alert.choice - qdel(alert) - -/** - * Creates an asynchronous TGUI alert window with an associated callback. - * - * This proc should be used to create alerts that invoke a callback with the user's chosen option. - * Arguments: - * * user - The user to show the alert to. - * * message - The content of the alert, shown in the body of the TGUI window. - * * title - The of the alert modal, shown on the top of the TGUI window. - * * buttons - The options that can be chosen by the user, each string is assigned a button on the UI. - * * callback - The callback to be invoked when a choice is made. - * * timeout - The timeout of the alert, after which the modal will close and qdel itself. Disabled by default, can be set to seconds otherwise. - * * autofocus - The bool that controls if this alert should grab window focus. - */ -/proc/tgui_alert_async(mob/user, message = "", title, list/buttons = list("Ok"), datum/callback/callback, timeout = 0, autofocus = TRUE) - if (!user) - user = usr - if (!istype(user)) - if (istype(user, /client)) - var/client/client = user - user = client.mob - else - return - // Client does NOT have tgui_input on: Returns regular input - if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) - if(length(buttons) == 2) - return alert(user, message, title, buttons[1], buttons[2]) - if(length(buttons) == 3) - return alert(user, message, title, buttons[1], buttons[2], buttons[3]) - var/datum/tgui_modal/async/alert = new(user, message, title, buttons, callback, timeout, autofocus) - alert.ui_interact(user) - -/** - * # tgui_modal - * - * Datum used for instantiating and using a TGUI-controlled modal that prompts the user with - * a message and has buttons for responses. - */ -/datum/tgui_modal - /// The title of the TGUI window - var/title - /// The textual body of the TGUI window - var/message - /// The list of buttons (responses) provided on the TGUI window - var/list/buttons - /// The button that the user has pressed, null if no selection has been made - var/choice - /// The time at which the tgui_modal was created, for displaying timeout progress. - var/start_time - /// The lifespan of the tgui_modal, after which the window will close and delete itself. - var/timeout - /// The bool that controls if this modal should grab window focus - var/autofocus - /// Boolean field describing if the tgui_modal was closed by the user. - var/closed - -/datum/tgui_modal/New(mob/user, message, title, list/buttons, timeout, autofocus) - src.autofocus = autofocus - src.buttons = buttons.Copy() - src.message = message - src.title = title - if (timeout) - src.timeout = timeout - start_time = world.time - QDEL_IN(src, timeout) - -/datum/tgui_modal/Destroy(force, ...) - SStgui.close_uis(src) - QDEL_NULL(buttons) - . = ..() - -/** - * Waits for a user's response to the tgui_modal's prompt before returning. Returns early if - * the window was closed by the user. - */ -/datum/tgui_modal/proc/wait() - while (!choice && !closed && !QDELETED(src)) - stoplag(1) - -/datum/tgui_modal/ui_interact(mob/user, datum/tgui/ui) - ui = SStgui.try_update_ui(user, src, ui) - if(!ui) - ui = new(user, src, "AlertModal") - ui.open() - -/datum/tgui_modal/ui_close(mob/user) - . = ..() - closed = TRUE - -/datum/tgui_modal/ui_state(mob/user) - return GLOB.always_state - -/datum/tgui_modal/ui_static_data(mob/user) - . = list() - .["autofocus"] = autofocus - .["buttons"] = buttons - .["message"] = message - .["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) - .["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) - .["title"] = title - -/datum/tgui_modal/ui_data(mob/user) - . = list() - if(timeout) - .["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) - -/datum/tgui_modal/ui_act(action, list/params) - . = ..() - if (.) - return - switch(action) - if("choose") - if (!(params["choice"] in buttons)) - CRASH("[usr] entered a non-existent button choice: [params["choice"]]") - set_choice(params["choice"]) - closed = TRUE - SStgui.close_uis(src) - return TRUE - if("cancel") - closed = TRUE - SStgui.close_uis(src) - return TRUE - -/datum/tgui_modal/proc/set_choice(choice) - src.choice = choice - -/** - * # async tgui_modal - * - * An asynchronous version of tgui_modal to be used with callbacks instead of waiting on user responses. - */ -/datum/tgui_modal/async - /// The callback to be invoked by the tgui_modal upon having a choice made. - var/datum/callback/callback - -/datum/tgui_modal/async/New(mob/user, message, title, list/buttons, callback, timeout, autofocus) - ..(user, message, title, buttons, timeout, autofocus) - src.callback = callback - -/datum/tgui_modal/async/Destroy(force, ...) - QDEL_NULL(callback) - . = ..() - -/datum/tgui_modal/async/set_choice(choice) - . = ..() - if(!isnull(src.choice)) - callback?.InvokeAsync(src.choice) - -/datum/tgui_modal/async/wait() - return diff --git a/code/modules/tgui_input/alert.dm b/code/modules/tgui_input/alert.dm new file mode 100644 index 00000000000..a9d8955c0a3 --- /dev/null +++ b/code/modules/tgui_input/alert.dm @@ -0,0 +1,133 @@ +/** + * Creates a TGUI alert window and returns the user's response. + * + * This proc should be used to create alerts that the caller will wait for a response from. + * Arguments: + * * user - The user to show the alert to. + * * message - The content of the alert, shown in the body of the TGUI window. + * * title - The of the alert modal, shown on the top of the TGUI window. + * * buttons - The options that can be chosen by the user, each string is assigned a button on the UI. + * * timeout - The timeout of the alert, after which the modal will close and qdel itself. Set to zero for no timeout. + * * autofocus - The bool that controls if this alert should grab window focus. + */ +/proc/tgui_alert(mob/user, message = "", title, list/buttons = list("Ok"), timeout = 0, autofocus = TRUE) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return + // A gentle nudge - you should not be using TGUI alert for anything other than a simple message. + if(length(buttons) > 3) + log_tgui(user, "Error: TGUI Alert initiated with too many buttons. Use a list.", "TguiAlert") + return tgui_input_list(user, message, title, buttons, timeout, autofocus) + // Client does NOT have tgui_input on: Returns regular input + if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) + if(length(buttons) == 2) + return alert(user, message, title, buttons[1], buttons[2]) + if(length(buttons) == 3) + return alert(user, message, title, buttons[1], buttons[2], buttons[3]) + var/datum/tgui_alert/alert = new(user, message, title, buttons, timeout, autofocus) + alert.ui_interact(user) + alert.wait() + if (alert) + . = alert.choice + qdel(alert) + +/** + * # tgui_alert + * + * Datum used for instantiating and using a TGUI-controlled modal that prompts the user with + * a message and has buttons for responses. + */ +/datum/tgui_alert + /// The title of the TGUI window + var/title + /// The textual body of the TGUI window + var/message + /// The list of buttons (responses) provided on the TGUI window + var/list/buttons + /// The button that the user has pressed, null if no selection has been made + var/choice + /// The time at which the tgui_alert was created, for displaying timeout progress. + var/start_time + /// The lifespan of the tgui_alert, after which the window will close and delete itself. + var/timeout + /// The bool that controls if this modal should grab window focus + var/autofocus + /// Boolean field describing if the tgui_alert was closed by the user. + var/closed + +/datum/tgui_alert/New(mob/user, message, title, list/buttons, timeout, autofocus) + src.autofocus = autofocus + src.buttons = buttons.Copy() + src.message = message + src.title = title + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + +/datum/tgui_alert/Destroy(force, ...) + SStgui.close_uis(src) + QDEL_NULL(buttons) + return ..() + +/** + * Waits for a user's response to the tgui_alert's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_alert/proc/wait() + while (!choice && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_alert/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "AlertModal") + ui.open() + +/datum/tgui_alert/ui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_alert/ui_state(mob/user) + return GLOB.always_state + +/datum/tgui_alert/ui_static_data(mob/user) + var/list/data = list() + data["autofocus"] = autofocus + data["buttons"] = buttons + data["message"] = message + data["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + data["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + data["title"] = title + return data + +/datum/tgui_alert/ui_data(mob/user) + var/list/data = list() + if(timeout) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data + +/datum/tgui_alert/ui_act(action, list/params) + . = ..() + if (.) + return + switch(action) + if("choose") + if (!(params["choice"] in buttons)) + CRASH("[usr] entered a non-existent button choice: [params["choice"]]") + set_choice(params["choice"]) + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("cancel") + closed = TRUE + SStgui.close_uis(src) + return TRUE + +/datum/tgui_alert/proc/set_choice(choice) + src.choice = choice diff --git a/code/modules/tgui/tgui_input_list.dm b/code/modules/tgui_input/list.dm similarity index 61% rename from code/modules/tgui/tgui_input_list.dm rename to code/modules/tgui_input/list.dm index 8a38ec768b8..abac3ec4356 100644 --- a/code/modules/tgui/tgui_input_list.dm +++ b/code/modules/tgui_input/list.dm @@ -31,36 +31,6 @@ . = input.choice qdel(input) -/** - * Creates an asynchronous TGUI input list window with an associated callback. - * - * This proc should be used to create inputs that invoke a callback with the user's chosen option. - * Arguments: - * * user - The user to show the input box to. - * * message - The content of the input box, shown in the body of the TGUI window. - * * title - The title of the input box, shown on the top of the TGUI window. - * * items - The options that can be chosen by the user, each string is assigned a button on the UI. - * * default - If an option is already preselected on the UI. Current values, etc. - * * callback - The callback to be invoked when a choice is made. - * * timeout - The timeout of the input box, after which the menu will close and qdel itself. Set to zero for no timeout. - */ -/proc/tgui_input_list_async(mob/user, message, title = "Select", list/items, default, datum/callback/callback, timeout = 60 SECONDS) - if (!user) - user = usr - if(!length(items)) - return - if (!istype(user)) - if (istype(user, /client)) - var/client/client = user - user = client.mob - else - return - /// Client does NOT have tgui_input on: Returns regular input - if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) - return input(user, message, title) as null|anything in items - var/datum/tgui_list_input/async/input = new(user, message, title, items, default, callback, timeout) - input.ui_interact(user) - /** * # tgui_list_input * @@ -94,22 +64,16 @@ src.items_map = list() src.default = default var/list/repeat_items = list() - // Gets rid of illegal characters var/static/regex/whitelistedWords = regex(@{"([^\u0020-\u8000]+)"}) - for(var/i in items) if(!i) continue - var/string_key = whitelistedWords.Replace("[i]", "") - //avoids duplicated keys E.g: when areas have the same name string_key = avoid_assoc_duplicate_keys(string_key, repeat_items) - src.items += string_key src.items_map[string_key] = i - if (timeout) src.timeout = timeout start_time = world.time @@ -118,7 +82,7 @@ /datum/tgui_list_input/Destroy(force, ...) SStgui.close_uis(src) QDEL_NULL(items) - . = ..() + return ..() /** * Waits for a user's response to the tgui_list_input's prompt before returning. Returns early if @@ -142,18 +106,20 @@ return GLOB.always_state /datum/tgui_list_input/ui_static_data(mob/user) - . = list() - .["init_value"] = default || items[1] - .["items"] = items - .["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) - .["message"] = message - .["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) - .["title"] = title + var/list/data = list() + data["init_value"] = default || items[1] + data["items"] = items + data["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + data["message"] = message + data["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + data["title"] = title + return data /datum/tgui_list_input/ui_data(mob/user) - . = list() + var/list/data = list() if(timeout) - .["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1) + data["timeout"] = clamp((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS), 0, 1) + return data /datum/tgui_list_input/ui_act(action, list/params) . = ..() @@ -174,28 +140,3 @@ /datum/tgui_list_input/proc/set_choice(choice) src.choice = choice - -/** - * # async tgui_list_input - * - * An asynchronous version of tgui_list_input to be used with callbacks instead of waiting on user responses. - */ -/datum/tgui_list_input/async - /// The callback to be invoked by the tgui_list_input upon having a choice made. - var/datum/callback/callback - -/datum/tgui_list_input/async/New(mob/user, message, title, list/items, default, callback, timeout) - ..(user, message, title, items, default, timeout) - src.callback = callback - -/datum/tgui_list_input/async/Destroy(force, ...) - QDEL_NULL(callback) - . = ..() - -/datum/tgui_list_input/async/set_choice(choice) - . = ..() - if(!isnull(src.choice)) - callback?.InvokeAsync(src.choice) - -/datum/tgui_list_input/async/wait() - return diff --git a/code/modules/tgui/tgui_input_number.dm b/code/modules/tgui_input/number.dm similarity index 61% rename from code/modules/tgui/tgui_input_number.dm rename to code/modules/tgui_input/number.dm index 9bf1981d2fc..81964597df7 100644 --- a/code/modules/tgui/tgui_input_number.dm +++ b/code/modules/tgui_input/number.dm @@ -35,38 +35,6 @@ . = number_input.entry qdel(number_input) -/** - * Creates an asynchronous TGUI number input window with an associated callback. - * - * This proc should be used to create number inputs that invoke a callback with the user's entry. - * - * Arguments: - * * user - The user to show the number input to. - * * message - The content of the number input, shown in the body of the TGUI window. - * * title - The title of the number input modal, shown on the top of the TGUI window. - * * default - The default (or current) value, shown as a placeholder. Users can press refresh with this. - * * max_value - Specifies a maximum value. If none is set, any number can be entered. Pressing "max" defaults to 1000. - * * min_value - Specifies a minimum value. Often 0. - * * callback - The callback to be invoked when a choice is made. - * * timeout - The timeout of the number input, after which the modal will close and qdel itself. Set to zero for no timeout. - * * round_value - whether the inputted number is rounded down into an integer. - */ -/proc/tgui_input_number_async(mob/user, message, title = "Number Input", default = 0, max_value = 10000, min_value = 0, datum/callback/callback, timeout = 60 SECONDS, round_value = TRUE) - if (!user) - user = usr - if (!istype(user)) - if (istype(user, /client)) - var/client/client = user - user = client.mob - else - return - // Client does NOT have tgui_input on: Returns regular input - if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) - var/input_number = input(user, message, title, default) as null|num - return clamp(round_value ? round(input_number) : input_number, min_value, max_value) - var/datum/tgui_input_number/async/number_input = new(user, message, title, default, max_value, min_value, callback, timeout, round_value) - number_input.ui_interact(user) - /** * # tgui_input_number * @@ -86,15 +54,14 @@ var/message /// The minimum value that can be entered. var/min_value + /// Whether the submitted number is rounded down into an integer. + var/round_value /// The time at which the number input was created, for displaying timeout progress. var/start_time /// The lifespan of the number input, after which the window will close and delete itself. var/timeout /// The title of the TGUI window var/title - /// Whether the submitted number is rounded down into an integer. - var/round_value - /datum/tgui_input_number/New(mob/user, message, title, default, max_value, min_value, timeout, round_value) src.default = default @@ -120,7 +87,7 @@ /datum/tgui_input_number/Destroy(force, ...) SStgui.close_uis(src) - . = ..() + return ..() /** * Waits for a user's response to the tgui_input_number's prompt before returning. Returns early if @@ -144,19 +111,21 @@ return GLOB.always_state /datum/tgui_input_number/ui_static_data(mob/user) - . = list() - .["init_value"] = default // Default is a reserved keyword - .["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) - .["max_value"] = max_value - .["message"] = message - .["min_value"] = min_value - .["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) - .["title"] = title + var/list/data = list() + data["init_value"] = default // Default is a reserved keyword + data["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + data["max_value"] = max_value + data["message"] = message + data["min_value"] = min_value + data["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + data["title"] = title + return data /datum/tgui_input_number/ui_data(mob/user) - . = list() + var/list/data = list() if(timeout) - .["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data /datum/tgui_input_number/ui_act(action, list/params) . = ..() @@ -181,29 +150,4 @@ return TRUE /datum/tgui_input_number/proc/set_entry(entry) - src.entry = entry - -/** - * # async tgui_input_number - * - * An asynchronous version of tgui_input_number to be used with callbacks instead of waiting on user responses. - */ -/datum/tgui_input_number/async - /// The callback to be invoked by the tgui_input_number upon having a choice made. - var/datum/callback/callback - -/datum/tgui_input_number/async/New(mob/user, message, title, default, max_value, min_value, callback, timeout) - ..(user, message, title, default, max_value, min_value, timeout) - src.callback = callback - -/datum/tgui_input_number/async/Destroy(force, ...) - QDEL_NULL(callback) - . = ..() - -/datum/tgui_input_number/async/set_entry(entry) - . = ..() - if(!isnull(src.entry)) - callback?.InvokeAsync(src.entry) - -/datum/tgui_input_number/async/wait() - return + src.entry = entry diff --git a/code/modules/tgui_input/say_modal/modal.dm b/code/modules/tgui_input/say_modal/modal.dm new file mode 100644 index 00000000000..e1012238357 --- /dev/null +++ b/code/modules/tgui_input/say_modal/modal.dm @@ -0,0 +1,133 @@ +/** 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) + window.subscribe(src, .proc/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 + winshow(client, "tgui_say", FALSE) + window.send_message("props", list( + lightMode = client.prefs?.read_preference(/datum/preference/toggle/tgui_say_light_mode), + 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) + start_thinking() + if(client.typing_indicators) + log_speech_indicators("[key_name(client)] started typing at [loc_name(client.mob)], indicators enabled.") + else + log_speech_indicators("[key_name(client)] started typing at [loc_name(client.mob)], indicators DISABLED.") + 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() + if(client.typing_indicators) + log_speech_indicators("[key_name(client)] stopped typing at [loc_name(client.mob)], indicators enabled.") + else + log_speech_indicators("[key_name(client)] stopped typing at [loc_name(client.mob)], indicators DISABLED.") + +/** + * 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["mode"] == TRUE) + start_thinking() + return TRUE + if(payload["mode"] == FALSE) + stop_thinking() + return TRUE + return FALSE + if (type == "typing") + start_typing() + return TRUE + if (type == "entry" || type == "force") + handle_entry(type, payload) + return TRUE + return FALSE diff --git a/code/modules/tgui_input/say_modal/speech.dm b/code/modules/tgui_input/say_modal/speech.dm new file mode 100644 index 00000000000..f2ee51d381a --- /dev/null +++ b/code/modules/tgui_input/say_modal/speech.dm @@ -0,0 +1,92 @@ +/** + * 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) + 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) + if(channel == OOC_CHANNEL) + client.ooc(entry) + return TRUE + switch(channel) + if(RADIO_CHANNEL) + client.mob.say_verb(";" + entry) + return TRUE + if(ME_CHANNEL) + client.mob.me_verb(entry) + return TRUE + if(SAY_CHANNEL) + client.mob.say_verb(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.") + +/** + * 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 = SAY_CHANNEL // No ooc leaks + delegate_speech(alter_entry(payload), target_channel) + return TRUE + return FALSE diff --git a/code/modules/tgui_input/say_modal/typing.dm b/code/modules/tgui_input/say_modal/typing.dm new file mode 100644 index 00000000000..0e64c427d7b --- /dev/null +++ b/code/modules/tgui_input/say_modal/typing.dm @@ -0,0 +1,111 @@ +/// Thinking +GLOBAL_DATUM_INIT(thinking_indicator, /mutable_appearance, mutable_appearance('icons/mob/talk.dmi', "default3", TYPING_LAYER)) +/// Typing +GLOBAL_DATUM_INIT(typing_indicator, /mutable_appearance, mutable_appearance('icons/mob/talk.dmi', "default0", TYPING_LAYER)) + + +/** Creates a thinking indicator over the mob. */ +/mob/proc/create_thinking_indicator() + return + +/** Removes the thinking indicator over the mob. */ +/mob/proc/remove_thinking_indicator() + return + +/** Creates a typing indicator over the mob. */ +/mob/proc/create_typing_indicator() + return + +/** Removes the typing indicator over the mob. */ +/mob/proc/remove_typing_indicator() + return + +/** Removes any indicators and marks the mob as not speaking IC. */ +/mob/proc/remove_all_indicators() + return + +/mob/set_stat(new_stat) + . = ..() + if(.) + remove_all_indicators() + +/mob/Logout() + remove_all_indicators() + return ..() + +/// Whether or not to show a typing indicator when speaking. Defaults to on. +/datum/preference/toggle/typing_indicator + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "typingIndicator" + savefile_identifier = PREFERENCE_PLAYER + +/datum/preference/toggle/typing_indicator/apply_to_client(client/client, value) + client?.typing_indicators = value + +/** Sets the mob as "thinking" - with indicator and variable thinking_IC */ +/datum/tgui_say/proc/start_thinking() + if(!window_open || !client.typing_indicators) + return FALSE + /// Special exemptions + if(isabductor(client.mob)) + return FALSE + client.mob.thinking_IC = TRUE + client.mob.create_thinking_indicator() + +/** Removes typing/thinking indicators and flags the mob as not thinking */ +/datum/tgui_say/proc/stop_thinking() + client.mob.remove_all_indicators() + +/** + * 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() + client.mob.remove_thinking_indicator() + if(!window_open || !client.typing_indicators || !client.mob.thinking_IC) + return FALSE + client.mob.create_typing_indicator() + addtimer(CALLBACK(src, .proc/stop_typing), 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. + */ +/datum/tgui_say/proc/stop_typing() + if(!client?.mob) + return FALSE + client.mob.remove_typing_indicator() + if(!window_open || !client.typing_indicators || !client.mob.thinking_IC) + return FALSE + client.mob.create_thinking_indicator() + +/// Overrides for overlay creation +/mob/living/create_thinking_indicator() + if(thinking_indicator || typing_indicator || !thinking_IC || stat != CONSCIOUS ) + return FALSE + add_overlay(GLOB.thinking_indicator) + thinking_indicator = TRUE + +/mob/living/remove_thinking_indicator() + if(!thinking_indicator) + return FALSE + cut_overlay(GLOB.thinking_indicator) + thinking_indicator = FALSE + +/mob/living/create_typing_indicator() + if(typing_indicator || thinking_indicator || !thinking_IC || stat != CONSCIOUS) + return FALSE + add_overlay(GLOB.typing_indicator) + typing_indicator = TRUE + +/mob/living/remove_typing_indicator() + if(!typing_indicator) + return FALSE + cut_overlay(GLOB.typing_indicator) + typing_indicator = FALSE + +/mob/living/remove_all_indicators() + thinking_IC = FALSE + remove_thinking_indicator() + remove_typing_indicator() + diff --git a/code/modules/tgui/tgui_input_text.dm b/code/modules/tgui_input/text.dm similarity index 59% rename from code/modules/tgui/tgui_input_text.dm rename to code/modules/tgui_input/text.dm index 62a5efeff8c..1571ad6b552 100644 --- a/code/modules/tgui/tgui_input_text.dm +++ b/code/modules/tgui_input/text.dm @@ -44,45 +44,7 @@ qdel(text_input) /** - * Creates an asynchronous TGUI text input window with an associated callback. - * - * This proc should be used to create text inputs that invoke a callback with the user's entry. - * Arguments: - * * user - The user to show the text input to. - * * message - The content of the text input, shown in the body of the TGUI window. - * * title - The title of the text input modal, shown on the top of the TGUI window. - * * default - The default (or current) value, shown as a placeholder. - * * max_length - Specifies a max length for input. - * * multiline - Bool that determines if the input box is much larger. Good for large messages, laws, etc. - * * encode - If toggled, input is filtered via html_encode. Setting this to FALSE gives raw input. - * * callback - The callback to be invoked when a choice is made. - */ -/proc/tgui_input_text_async(mob/user, message = "", title = "Text Input", default, max_length = MAX_MESSAGE_LEN, multiline = FALSE, encode = TRUE, datum/callback/callback, timeout = 60 SECONDS) - if (!user) - user = usr - if (!istype(user)) - if (istype(user, /client)) - var/client/client = user - user = client.mob - else - return - // Client does NOT have tgui_input on: Returns regular input - if(!user.client.prefs.read_preference(/datum/preference/toggle/tgui_input)) - if(encode) - if(multiline) - return stripped_multiline_input(user, message, title, default, max_length) - else - return stripped_input(user, message, title, default, max_length) - else - if(multiline) - return input(user, message, title, default) as message|null - else - return input(user, message, title, default) as text|null - var/datum/tgui_input_text/async/text_input = new(user, message, title, default, max_length, multiline, encode, callback, timeout) - text_input.ui_interact(user) - -/** - * # tgui_input_text + * tgui_input_text * * Datum used for instantiating and using a TGUI-controlled text input that prompts the user with * a message and has an input for text entry. @@ -109,7 +71,6 @@ /// The title of the TGUI window var/title - /datum/tgui_input_text/New(mob/user, message, title, default, max_length, multiline, encode, timeout) src.default = default src.encode = encode @@ -124,7 +85,7 @@ /datum/tgui_input_text/Destroy(force, ...) SStgui.close_uis(src) - . = ..() + return ..() /** * Waits for a user's response to the tgui_input_text's prompt before returning. Returns early if @@ -148,19 +109,21 @@ return GLOB.always_state /datum/tgui_input_text/ui_static_data(mob/user) - . = list() - .["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) - .["max_length"] = max_length - .["message"] = message - .["multiline"] = multiline - .["placeholder"] = default // Default is a reserved keyword - .["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) - .["title"] = title + var/list/data = list() + data["large_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_large) + data["max_length"] = max_length + data["message"] = message + data["multiline"] = multiline + data["placeholder"] = default // Default is a reserved keyword + data["swapped_buttons"] = user.client.prefs.read_preference(/datum/preference/toggle/tgui_input_swapped) + data["title"] = title + return data /datum/tgui_input_text/ui_data(mob/user) - . = list() + var/list/data = list() if(timeout) - .["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data /datum/tgui_input_text/ui_act(action, list/params) . = ..() @@ -172,7 +135,7 @@ if(length(params["entry"]) > max_length) CRASH("[usr] typed a text string longer than the max length") if(encode && (length(html_encode(params["entry"])) > max_length)) - to_chat(usr, span_notice("Input uses special characters, thus reducing the maximum length.")) + to_chat(usr, span_notice("Your message was clipped due to special character usage.")) set_entry(params["entry"]) closed = TRUE SStgui.close_uis(src) @@ -182,32 +145,13 @@ SStgui.close_uis(src) return TRUE +/** + * Sets the return value for the tgui text proc. + * If html encoding is enabled, the text will be encoded. + * This can sometimes result in a string that is longer than the max length. + * If the string is longer than the max length, it will be clipped. + */ /datum/tgui_input_text/proc/set_entry(entry) if(!isnull(entry)) var/converted_entry = encode ? html_encode(entry) : entry src.entry = trim(converted_entry, max_length) - -/** - * # async tgui_input_text - * - * An asynchronous version of tgui_input_text to be used with callbacks instead of waiting on user responses. - */ -/datum/tgui_input_text/async - // The callback to be invoked by the tgui_input_text upon having a choice made. - var/datum/callback/callback - -/datum/tgui_input_text/async/New(mob/user, message, title, default, max_length, multiline, encode, callback, timeout) - ..(user, message, title, default, max_length, multiline, encode, timeout) - src.callback = callback - -/datum/tgui_input_text/async/Destroy(force, ...) - QDEL_NULL(callback) - . = ..() - -/datum/tgui_input_text/async/set_entry(entry) - . = ..() - if(!isnull(src.entry)) - callback?.InvokeAsync(src.entry) - -/datum/tgui_input_text/async/wait() - return diff --git a/config/config.txt b/config/config.txt index 1b87a1ea355..67b299b0863 100644 --- a/config/config.txt +++ b/config/config.txt @@ -166,6 +166,9 @@ LOG_TOOLS ## Log all timers on timer auto reset # LOG_TIMERS_ON_BUCKET_RESET +## log speech indicators +LOG_SPEECH_INDICATORS + ##Log camera pictures - Must have picture logging enabled PICTURE_LOGGING_CAMERA diff --git a/icons/mob/talk.dmi b/icons/mob/talk.dmi index ad2c50287b6816cc0b86b1bf316e342da2333da5..6b2fc395cba0d477e30c547399c7f9e2263e5cde 100644 GIT binary patch literal 12967 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 Tk~bd$KZm}z+W&a(sZ0M0Kvvq9 literal 12767 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 HbL0O2NJN>k diff --git a/interface/skin.dmf b/interface/skin.dmf index 19ba4cc3b2b..dd7d8a3e91a 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -339,3 +339,22 @@ window "statwindow" is-visible = false saved-params = "" +window "tgui_say" + elem "tgui_say" + type = MAIN + pos = 848,500 + size = 231x30 + anchor1 = 50,50 + anchor2 = 50,50 + is-visible = false + saved-params = "" + statusbar = false + can-minimize = false + elem "browser" + type = BROWSER + pos = 0,0 + size = 231x30 + anchor1 = 0,0 + anchor2 = 0,0 + saved-params = "" + diff --git a/tgstation.dme b/tgstation.dme index 980f149e3bd..7a66930706e 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -164,6 +164,7 @@ #include "code\__DEFINES\span.dm" #include "code\__DEFINES\spatial_gridmap.dm" #include "code\__DEFINES\species_clothing_paths.dm" +#include "code\__DEFINES\speech_channels.dm" #include "code\__DEFINES\speech_controller.dm" #include "code\__DEFINES\stat.dm" #include "code\__DEFINES\stat_tracking.dm" @@ -4280,10 +4281,6 @@ #include "code\modules\tgui\states.dm" #include "code\modules\tgui\status_composers.dm" #include "code\modules\tgui\tgui.dm" -#include "code\modules\tgui\tgui_alert.dm" -#include "code\modules\tgui\tgui_input_list.dm" -#include "code\modules\tgui\tgui_input_number.dm" -#include "code\modules\tgui\tgui_input_text.dm" #include "code\modules\tgui\tgui_window.dm" #include "code\modules\tgui\states\admin.dm" #include "code\modules\tgui\states\always.dm" @@ -4306,6 +4303,13 @@ #include "code\modules\tgui\states\reverse_contained.dm" #include "code\modules\tgui\states\self.dm" #include "code\modules\tgui\states\zlevel.dm" +#include "code\modules\tgui_input\alert.dm" +#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" diff --git a/tgui/packages/common/timer.js b/tgui/packages/common/timer.js index 1177071b9c6..7d89e935b9b 100644 --- a/tgui/packages/common/timer.js +++ b/tgui/packages/common/timer.js @@ -28,11 +28,31 @@ export const debounce = (fn, time, immediate = false) => { }; }; +/** + * Returns a function, that, when invoked, will only be triggered at most once + * during a given window of time. + */ +export const throttle = (fn, time) => { + let previouslyRun, queuedToRun; + return function invokeFn(...args) { + const now = Date.now(); + queuedToRun = clearTimeout(queuedToRun); + if (!previouslyRun || now - previouslyRun >= time) { + fn.apply(null, args); + previouslyRun = now; + } else { + queuedToRun = setTimeout( + invokeFn.bind(null, ...args), + time - (now - previouslyRun) + ); + } + }; +}; + /** * Suspends an asynchronous function for N milliseconds. * * @param {number} time */ -export const sleep = time => ( - new Promise(resolve => setTimeout(resolve, time)) -); +export const sleep = (time) => + new Promise((resolve) => setTimeout(resolve, time)); diff --git a/tgui/packages/tgui-say/components/dragzone.tsx b/tgui/packages/tgui-say/components/dragzone.tsx new file mode 100644 index 00000000000..95ebfa1a854 --- /dev/null +++ b/tgui/packages/tgui-say/components/dragzone.tsx @@ -0,0 +1,20 @@ +import { dragStartHandler } from 'tgui/drag'; +import { DragzoneProps } from '../types'; + +/** Creates a draggable edge. Props Req: Location */ +export const Dragzone = (props: Partial) => { + const { theme } = props; + if (!theme) return null; + const direction + = (props.top && 'top') + || (props.right && 'right') + || (props.bottom && 'bottom') + || (props.left && 'left'); + + return ( +
+ ); +}; diff --git a/tgui/packages/tgui-say/constants/index.tsx b/tgui/packages/tgui-say/constants/index.tsx new file mode 100644 index 00000000000..145d9985c36 --- /dev/null +++ b/tgui/packages/tgui-say/constants/index.tsx @@ -0,0 +1,73 @@ +/** Radio channels */ +export const CHANNELS = ['Say', 'Radio', 'Me', 'OOC'] as const; + +/** Window sizes in pixels */ +export const WINDOW_SIZES = { + small: 30, + medium: 50, + large: 70, + width: 231, +} as const; + +/** Line lengths for autoexpand */ +export const LINE_LENGTHS = { + small: 20, + medium: 35, +} as const; + +/** + * Radio prefixes. + * Contains the properties: + * id - string. css class identifier. + * label - string. button label. + */ +export const RADIO_PREFIXES = { + ':a ': { + id: 'hive', + label: 'Hive', + }, + ':b ': { + id: 'binary', + label: '0101', + }, + ':c ': { + id: 'command', + label: 'Cmd', + }, + ':e ': { + id: 'engi', + label: 'Engi', + }, + ':m ': { + id: 'medical', + label: 'Med', + }, + ':n ': { + id: 'science', + label: 'Sci', + }, + ':o ': { + id: 'ai', + label: 'AI', + }, + ':s ': { + id: 'security', + label: 'Sec', + }, + ':t ': { + id: 'syndicate', + label: 'Syndi', + }, + ':u ': { + id: 'supply', + label: 'Supp', + }, + ':v ': { + id: 'service', + label: 'Svc', + }, + ':y ': { + id: 'centcom', + label: 'CCom', + }, +} as const; diff --git a/tgui/packages/tgui-say/handlers/arrowKeys.tsx b/tgui/packages/tgui-say/handlers/arrowKeys.tsx new file mode 100644 index 00000000000..a736c9d860e --- /dev/null +++ b/tgui/packages/tgui-say/handlers/arrowKeys.tsx @@ -0,0 +1,15 @@ +import { KEY_DOWN, KEY_UP } from 'common/keycodes'; +import { getHistoryLength } from '../helpers'; +import { Modal } from '../types'; + +/** Increments the chat history counter, looping through entries */ +export const handleArrowKeys = function (this: Modal, direction: number) { + const { historyCounter } = this.fields; + if (direction === KEY_UP && historyCounter < getHistoryLength()) { + this.fields.historyCounter++; + this.events.onViewHistory(); + } else if (direction === KEY_DOWN && historyCounter > 0) { + this.fields.historyCounter--; + this.events.onViewHistory(); + } +}; diff --git a/tgui/packages/tgui-say/handlers/backspaceDelete.tsx b/tgui/packages/tgui-say/handlers/backspaceDelete.tsx new file mode 100644 index 00000000000..340b4132b09 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/backspaceDelete.tsx @@ -0,0 +1,22 @@ +import { CHANNELS } from '../constants'; +import { Modal } from '../types'; + +/** + * 1. Resets history if editing a message + * 2. Backspacing while empty resets any radio subchannels + * 3. Ensures backspace and delete calculate window size + */ +export const handleBackspaceDelete = function (this: Modal) { + const { buttonContent, channel } = this.state; + const { radioPrefix, value } = this.fields; + // User is on a chat history message + if (typeof buttonContent === 'number') { + this.fields.historyCounter = 0; + this.setState({ buttonContent: CHANNELS[channel] }); + } + if (!value.length && radioPrefix) { + this.fields.radioPrefix = ''; + this.setState({ buttonContent: CHANNELS[channel] }); + } + this.events.onSetSize(value.length); +}; diff --git a/tgui/packages/tgui-say/handlers/click.tsx b/tgui/packages/tgui-say/handlers/click.tsx new file mode 100644 index 00000000000..e25b3385e8d --- /dev/null +++ b/tgui/packages/tgui-say/handlers/click.tsx @@ -0,0 +1,9 @@ +import { Modal } from '../types'; + +/** + * User clicks the channel button. + * Simulates the tab key. + */ +export const handleClick = function (this: Modal) { + this.events.onIncrementChannel(); +}; diff --git a/tgui/packages/tgui-say/handlers/componentMount.tsx b/tgui/packages/tgui-say/handlers/componentMount.tsx new file mode 100644 index 00000000000..03f57120c45 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/componentMount.tsx @@ -0,0 +1,23 @@ +import { CHANNELS } from '../constants'; +import { windowLoad, windowOpen } from '../helpers'; +import { Modal } from '../types'; + +/** Attach listeners, sets window size just in case */ +export const handleComponentMount = function (this: Modal) { + Byond.subscribeTo('props', (data) => { + this.fields.maxLength = data.maxLength; + this.fields.lightMode = !!data.lightMode; + }); + Byond.subscribeTo('force', () => { + this.events.onForce(); + }); + Byond.subscribeTo('open', (data) => { + const channel = CHANNELS.indexOf(data.channel) || 0; + this.setState({ buttonContent: CHANNELS[channel], channel }); + setTimeout(() => { + this.fields.innerRef.current?.focus(); + }, 1); + windowOpen(CHANNELS[channel]); + }); + windowLoad(); +}; diff --git a/tgui/packages/tgui-say/handlers/componentUpdate.tsx b/tgui/packages/tgui-say/handlers/componentUpdate.tsx new file mode 100644 index 00000000000..cc57c53873d --- /dev/null +++ b/tgui/packages/tgui-say/handlers/componentUpdate.tsx @@ -0,0 +1,6 @@ +import { Modal } from '../types'; + +/** After updating the input value, sets back to false */ +export const handleComponentUpdate = function (this: Modal) { + this.setState({ edited: false }); +}; diff --git a/tgui/packages/tgui-say/handlers/enter.tsx b/tgui/packages/tgui-say/handlers/enter.tsx new file mode 100644 index 00000000000..d181a77fd1b --- /dev/null +++ b/tgui/packages/tgui-say/handlers/enter.tsx @@ -0,0 +1,23 @@ +import { CHANNELS } from '../constants'; +import { storeChat, windowClose } from '../helpers'; +import { Modal } from '../types'; + +/** User presses enter. Closes if no value. */ +export const handleEnter = function ( + this: Modal, + event: KeyboardEvent, + value: string +) { + const { channel } = this.state; + const { maxLength, radioPrefix } = this.fields; + event.preventDefault(); + if (value && value.length < maxLength) { + storeChat(value); + Byond.sendMessage('entry', { + channel: CHANNELS[channel], + entry: channel === 0 ? radioPrefix + value : value, + }); + } + this.events.onReset(); + windowClose(); +}; diff --git a/tgui/packages/tgui-say/handlers/escape.tsx b/tgui/packages/tgui-say/handlers/escape.tsx new file mode 100644 index 00000000000..7c2527fab3c --- /dev/null +++ b/tgui/packages/tgui-say/handlers/escape.tsx @@ -0,0 +1,8 @@ +import { windowClose } from '../helpers'; +import { Modal } from '../types'; + +/** User presses escape, closes the window */ +export const handleEscape = function (this: Modal) { + this.events.onReset(); + windowClose(); +}; diff --git a/tgui/packages/tgui-say/handlers/force.tsx b/tgui/packages/tgui-say/handlers/force.tsx new file mode 100644 index 00000000000..3bb11037c77 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/force.tsx @@ -0,0 +1,19 @@ +import { CHANNELS, WINDOW_SIZES } from '../constants'; +import { windowSet } from '../helpers'; +import { Modal } from '../types'; + +/** Sends the current input to byond and purges it */ +export const handleForce = function (this: Modal) { + const { channel, size } = this.state; + const { radioPrefix, value } = this.fields; + if (value && channel < 2) { + this.timers.forceDebounce({ + channel: CHANNELS[channel], + entry: channel === 0 ? radioPrefix + value : value, + }); + this.events.onReset(channel); + if (size !== WINDOW_SIZES.small) { + windowSet(); + } + } +}; diff --git a/tgui/packages/tgui-say/handlers/incrementChannel.tsx b/tgui/packages/tgui-say/handlers/incrementChannel.tsx new file mode 100644 index 00000000000..6c005d0c63c --- /dev/null +++ b/tgui/packages/tgui-say/handlers/incrementChannel.tsx @@ -0,0 +1,32 @@ +import { CHANNELS } from '../constants'; +import { Modal } from '../types'; + +/** + * Increments the channel or resets to the beginning of the list. + * If the user switches between IC/OOC, messages Byond to toggle thinking + * indicators. + */ +export const handleIncrementChannel = function (this: Modal) { + const { channel } = this.state; + const { radioPrefix } = this.fields; + if (radioPrefix === ':b ') { + this.timers.channelDebounce({ mode: true }); + } + this.fields.radioPrefix = ''; + if (channel === CHANNELS.length - 1) { + this.timers.channelDebounce({ mode: true }); + this.setState({ + buttonContent: CHANNELS[0], + channel: 0, + }); + } else { + if (channel === 2) { + // Disables thinking indicator for OOC channel + this.timers.channelDebounce({ mode: false }); + } + this.setState({ + buttonContent: CHANNELS[channel + 1], + channel: channel + 1, + }); + } +}; diff --git a/tgui/packages/tgui-say/handlers/index.tsx b/tgui/packages/tgui-say/handlers/index.tsx new file mode 100644 index 00000000000..b400c82aa72 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/index.tsx @@ -0,0 +1,41 @@ +import { handleArrowKeys } from './arrowKeys'; +import { handleBackspaceDelete } from './backspaceDelete'; +import { handleComponentMount } from './componentMount'; +import { handleComponentUpdate } from './componentUpdate'; +import { handleClick } from './click'; +import { handleEnter } from './enter'; +import { handleEscape } from './escape'; +import { handleForce } from './force'; +import { handleIncrementChannel } from './incrementChannel'; +import { handleInput } from './input'; +import { handleKeyDown } from './keyDown'; +import { handleRadioPrefix } from './radioPrefix'; +import { handleReset } from './reset'; +import { handleSetSize } from './setSize'; +import { handleViewHistory } from './viewHistory'; +import { Modal } from '../types'; + +/** + * Maps all TGUI say events with their associated handlers. + * + * return -- object: events + */ +export const eventHandlerMap = (parent: Modal): Modal["events"] => { + return { + onArrowKeys: handleArrowKeys.bind(parent), + onBackspaceDelete: handleBackspaceDelete.bind(parent), + onClick: handleClick.bind(parent), + onComponentMount: handleComponentMount.bind(parent), + onComponentUpdate: handleComponentUpdate.bind(parent), + onEnter: handleEnter.bind(parent), + onEscape: handleEscape.bind(parent), + onForce: handleForce.bind(parent), + onIncrementChannel: handleIncrementChannel.bind(parent), + onInput: handleInput.bind(parent), + onKeyDown: handleKeyDown.bind(parent), + onRadioPrefix: handleRadioPrefix.bind(parent), + onReset: handleReset.bind(parent), + onSetSize: handleSetSize.bind(parent), + onViewHistory: handleViewHistory.bind(parent), + }; +}; diff --git a/tgui/packages/tgui-say/handlers/input.tsx b/tgui/packages/tgui-say/handlers/input.tsx new file mode 100644 index 00000000000..553f1fa95ef --- /dev/null +++ b/tgui/packages/tgui-say/handlers/input.tsx @@ -0,0 +1,11 @@ +import { Modal } from '../types'; + +/** + * Grabs input and sets size, force values etc. + * Input value only triggers a rerender on setEdited. + */ +export const handleInput = function (this: Modal, _, value: string) { + this.fields.value = value; + this.events.onRadioPrefix(); + this.events.onSetSize(value.length); +}; diff --git a/tgui/packages/tgui-say/handlers/keyDown.tsx b/tgui/packages/tgui-say/handlers/keyDown.tsx new file mode 100644 index 00000000000..1e554f33917 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/keyDown.tsx @@ -0,0 +1,35 @@ +import { KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_TAB, KEY_UP } from 'common/keycodes'; +import { isAlphanumeric, getHistoryLength } from '../helpers'; +import { Modal } from '../types'; + +/** + * Handles other key events. + * TAB - Changes channels. + * UP/DOWN - Sets history counter and input value. + * BKSP/DEL - Resets history counter and checks window size. + * TYPING - When users key, it tells byond that it's typing. + */ +export const handleKeyDown = function (this: Modal, event: KeyboardEvent) { + const { channel } = this.state; + const { radioPrefix } = this.fields; + if (!event.keyCode) { + return; // Really doubt it, but... + } + if (isAlphanumeric(event.keyCode)) { + if (channel !== 3 && radioPrefix !== ':b ') { + this.timers.typingThrottle(); + } + } + if (event.keyCode === KEY_UP || event.keyCode === KEY_DOWN) { + if (getHistoryLength()) { + this.events.onArrowKeys(event.keyCode); + } + } + if (event.keyCode === KEY_DELETE || event.keyCode === KEY_BACKSPACE) { + this.events.onBackspaceDelete(); + } + if (event.keyCode === KEY_TAB) { + this.events.onIncrementChannel(); + event.preventDefault(); + } +}; diff --git a/tgui/packages/tgui-say/handlers/radioPrefix.tsx b/tgui/packages/tgui-say/handlers/radioPrefix.tsx new file mode 100644 index 00000000000..21feda26c93 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/radioPrefix.tsx @@ -0,0 +1,34 @@ +import { RADIO_PREFIXES } from '../constants'; +import { Modal } from '../types'; + +/** + * Gets any channel prefixes from the chat bar + * and changes to the corresponding radio subchannel. + * + * Exemptions: Channel is OOC, value is too short, + * Not a valid radio pref, or value is already the radio pref. + */ +export const handleRadioPrefix = function (this: Modal) { + const { channel } = this.state; + const { radioPrefix, value } = this.fields; + if (channel > 1 || value.length < 3) { + return; + } + const nextPrefix = value.slice(0, 3)?.toLowerCase(); + if (!RADIO_PREFIXES[nextPrefix] || radioPrefix === nextPrefix) { + return; + } + this.fields.value = value.slice(3); + // Binary is a "secret" channel + if (nextPrefix === ':b ') { + Byond.sendMessage('thinking', { mode: false }); + } else if (radioPrefix === ':b ' && nextPrefix !== ':b ') { + Byond.sendMessage('thinking', { mode: true }); + } + this.fields.radioPrefix = nextPrefix; + this.setState({ + buttonContent: RADIO_PREFIXES[nextPrefix]?.label, + channel: 0, + edited: true, + }); +}; diff --git a/tgui/packages/tgui-say/handlers/reset.tsx b/tgui/packages/tgui-say/handlers/reset.tsx new file mode 100644 index 00000000000..ffe5215eb43 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/reset.tsx @@ -0,0 +1,21 @@ +import { CHANNELS, WINDOW_SIZES } from '../constants'; +import { valueExists } from '../helpers'; +import { Modal } from '../types'; + +/** + * Resets window to default parameters. + * + * Parameters: + * channel - Optional. Sets the channel and thus the color scheme. + */ +export const handleReset = function (this: Modal, channel?: number) { + this.fields.historyCounter = 0; + this.fields.radioPrefix = ''; + this.fields.value = ''; + this.setState({ + buttonContent: valueExists(channel) ? CHANNELS[channel!] : '', + channel: valueExists(channel) ? channel! : -1, + edited: true, + size: WINDOW_SIZES.small, + }); +}; diff --git a/tgui/packages/tgui-say/handlers/setSize.tsx b/tgui/packages/tgui-say/handlers/setSize.tsx new file mode 100644 index 00000000000..ba8ccc56278 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/setSize.tsx @@ -0,0 +1,22 @@ +import { LINE_LENGTHS, WINDOW_SIZES } from '../constants'; +import { windowSet } from '../helpers'; +import { Modal } from '../types'; + +/** Adjusts window sized based on event.target.value */ +export const handleSetSize = function (this: Modal, value: number) { + const { size } = this.state; + if (value > LINE_LENGTHS.medium && size !== WINDOW_SIZES.large) { + this.setState({ size: WINDOW_SIZES.large }); + windowSet(WINDOW_SIZES.large); + } else if ( + value <= LINE_LENGTHS.medium + && value > LINE_LENGTHS.small + && size !== WINDOW_SIZES.medium + ) { + this.setState({ size: WINDOW_SIZES.medium }); + windowSet(WINDOW_SIZES.medium); + } else if (value <= LINE_LENGTHS.small && size !== WINDOW_SIZES.small) { + this.setState({ size: WINDOW_SIZES.small }); + windowSet(WINDOW_SIZES.small); + } +}; diff --git a/tgui/packages/tgui-say/handlers/viewHistory.tsx b/tgui/packages/tgui-say/handlers/viewHistory.tsx new file mode 100644 index 00000000000..bfbf5aa3122 --- /dev/null +++ b/tgui/packages/tgui-say/handlers/viewHistory.tsx @@ -0,0 +1,24 @@ +import { CHANNELS } from '../constants'; +import { getHistoryAt, getHistoryLength } from '../helpers'; +import { Modal } from '../types'; + +/** Sets the input value to chat history at index historyCounter. */ +export const handleViewHistory = function (this: Modal) { + const { channel } = this.state; + const { historyCounter } = this.fields; + if (historyCounter > 0 && getHistoryLength()) { + this.fields.value = getHistoryAt(historyCounter); + if (channel < 2) { + this.timers.typingThrottle(); + } + this.setState({ buttonContent: historyCounter, edited: true }); + this.events.onSetSize(0); + } else { + this.fields.value = ''; + this.setState({ + buttonContent: CHANNELS[channel], + edited: true, + }); + this.events.onSetSize(0); + } +}; diff --git a/tgui/packages/tgui-say/helpers/index.tsx b/tgui/packages/tgui-say/helpers/index.tsx new file mode 100644 index 00000000000..0a729846e89 --- /dev/null +++ b/tgui/packages/tgui-say/helpers/index.tsx @@ -0,0 +1,157 @@ +import { CHANNELS, RADIO_PREFIXES, WINDOW_SIZES } from '../constants'; +import { KEY_0, KEY_Z } from 'common/keycodes'; +import { classes } from 'common/react'; +import { debounce, throttle } from 'common/timer'; + +/** + * Window functions + */ +/** + * Once byond signals this via keystroke, it + * ensures window size, visibility, and focus. + */ +export const windowOpen = (channel: string) => { + setOpen(); + 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 = () => { + setClosed(); + Byond.sendMessage('close'); +}; + +/** Some QoL to hide the window on load. Doesn't log this event */ +export const windowLoad = () => { + Byond.winset('tgui_say', { + pos: '848,500', + }); + setClosed(); +}; + +/** + * Modifies the window size. + * + * Parameters: + * size - The size of the window in pixels. Optional. + */ +export const windowSet = (size: number = WINDOW_SIZES.small) => { + Byond.winset('tgui_say', { size: `${WINDOW_SIZES.width}x${size}` }); + Byond.winset('tgui_say.browser', { size: `${WINDOW_SIZES.width}x${size}` }); +}; + +/** Private functions */ +/** Sets the skin props as opened. Focus might be a placebo here. */ +const setOpen = () => { + Byond.winset('tgui_say', { + 'is-visible': true, + size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`, + }); + Byond.winset('tgui_say.browser', { + 'is-visible': true, + size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`, + }); +}; + +/** Sets the skin props as closed. */ +const setClosed = () => { + Byond.winset('tgui_say', { + 'is-visible': false, + size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`, + }); + Byond.winset('map', { + focus: true, + }); +}; + +/** + * Chat history functions + */ +/** Stores a list of chat messages entered as values */ +let savedMessages: string[] = []; + +/** Returns the chat history at specified index */ +export const getHistoryAt = (index: number): string => + savedMessages[savedMessages.length - index]; + +/** + * The length of chat history. + * I am absolutely being excessive, but whatever + */ +export const getHistoryLength = (): number => savedMessages.length; + +/** + * Stores entries in the chat history. + * Deletes old entries if the list is too long. + */ +export const storeChat = (message: string): void => { + if (savedMessages.length === 5) { + savedMessages.shift(); + } + savedMessages.push(message); +}; + +/** Miscellaneous */ + +/** + * Returns modular css classes. + * + * Parameters: + * element - required string. The element selector. + * theme - optional string. The theme to apply. + * options - optional string | number. Adds another css selector. + */ +export const getCss = ( + element: string, + theme?: string, + options?: string | number +): string => + classes([ + element, + valueExists(theme) && `${element}-${theme}`, + valueExists(options) && `${element}-${options}`, + ]); + +/** + * Returns a string that represents the css selector to use. + * Light mode takes precedence over radioPrefixes, + * radioPrefixes takes precedence over channel. + * + * Parameters: + * lightMode - boolean. If true, returns the light mode selector. + * radioPrefix - string. If not empty, returns the radio prefix selector. + * channel - number. The channel to use. + */ +export const getTheme = ( + lightMode: boolean, + radioPrefix: string, + channel: number +): string => { + return ( + (lightMode && 'lightMode') + || RADIO_PREFIXES[radioPrefix]?.id + || CHANNELS[channel]?.toLowerCase() + ); +}; + +/** Checks keycodes for alpha/numeric characters */ +export const isAlphanumeric = (keyCode: number): boolean => + keyCode >= KEY_0 && keyCode <= KEY_Z; + +/** Timers: Prevents overloading the server, throttles messages */ +export const timers = { + channelDebounce: debounce((mode) => Byond.sendMessage('thinking', mode), 400), + forceDebounce: debounce( + (entry) => Byond.sendMessage('force', entry), + 1000, + true + ), + typingThrottle: throttle(() => Byond.sendMessage('typing'), 4000), +}; + +/** Checks if a parameter is null or undefined. Returns bool */ +export const valueExists = (param: any): boolean => + param !== null && param !== undefined; diff --git a/tgui/packages/tgui-say/index.tsx b/tgui/packages/tgui-say/index.tsx new file mode 100644 index 00000000000..41956294f46 --- /dev/null +++ b/tgui/packages/tgui-say/index.tsx @@ -0,0 +1,19 @@ +import './styles/main.scss'; +import { createRenderer } from 'tgui/renderer'; +import { TguiSay } from './interfaces/TguiSay'; + +const renderApp = createRenderer(() => { + return ; +}); + +const setupApp = () => { + // Delay setup + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupApp); + return; + } + + renderApp(); +}; + +setupApp(); diff --git a/tgui/packages/tgui-say/interfaces/TguiSay.tsx b/tgui/packages/tgui-say/interfaces/TguiSay.tsx new file mode 100644 index 00000000000..413173b27c5 --- /dev/null +++ b/tgui/packages/tgui-say/interfaces/TguiSay.tsx @@ -0,0 +1,75 @@ +import { TextArea } from 'tgui/components'; +import { WINDOW_SIZES } from '../constants'; +import { Dragzone } from '../components/dragzone'; +import { eventHandlerMap } from '../handlers'; +import { getCss, getTheme, timers } from '../helpers'; +import { Component, createRef } from 'inferno'; +import { Modal, State } from '../types'; + +/** Primary class for the TGUI say modal. */ +export class TguiSay extends Component<{}, State> { + events: Modal['events'] = eventHandlerMap(this); + fields: Modal['fields'] = { + historyCounter: 0, + innerRef: createRef(), + lightMode: false, + maxLength: 1024, + radioPrefix: '', + value: '', + }; + state: Modal['state'] = { + buttonContent: '', + channel: -1, + edited: false, + size: WINDOW_SIZES.small, + }; + timers: Modal['timers'] = timers; + + componentDidMount() { + this.events.onComponentMount(); + } + + componentDidUpdate() { + if (this.state.edited) { + this.events.onComponentUpdate(); + } + } + + render() { + const { onClick, onEnter, onEscape, onKeyDown, onInput } = this.events; + const { innerRef, lightMode, maxLength, radioPrefix, value } = this.fields; + const { buttonContent, channel, edited, size } = this.state; + const theme = getTheme(lightMode, radioPrefix, channel); + + return ( +
+ +
+ + {!!theme && ( + + )} +