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 ad2c50287b6..6b2fc395cba 100644 Binary files a/icons/mob/talk.dmi and b/icons/mob/talk.dmi differ 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 && ( + + )} +