diff --git a/code/datums/records/record.dm b/code/datums/records/record.dm index 5441c8a7df0..07482a21911 100644 --- a/code/datums/records/record.dm +++ b/code/datums/records/record.dm @@ -177,6 +177,7 @@ species_type = locked_dna.species.type GLOB.manifest.locked += src + GLOB.name_to_appearance[name] = character_appearance // NOVA EDIT ADDITION - Cache these for Character Directory /datum/record/locked/Destroy() GLOB.manifest.locked -= src diff --git a/code/modules/mob/living/carbon/human/examine.dm b/code/modules/mob/living/carbon/human/examine.dm index ea468aa473f..37f152573aa 100644 --- a/code/modules/mob/living/carbon/human/examine.dm +++ b/code/modules/mob/living/carbon/human/examine.dm @@ -499,6 +499,8 @@ flavor_text_link = span_notice("\[Examine closely...\]") if (flavor_text_link) . += flavor_text_link + if (!face_obscured && !HAS_TRAIT(src, TRAIT_UNKNOWN) && client?.prefs.read_preference(/datum/preference/text/character_ad)) + . += span_notice("[t_He] [t_has] an ad in the character directory... \[Open directory?\]") //Temporary flavor text addition: if(temporary_flavor_text) diff --git a/modular_nova/master_files/code/modules/client/preferences/headshot.dm b/modular_nova/master_files/code/modules/client/preferences/headshot.dm index 877fa3de4cd..7861b3906cb 100644 --- a/modular_nova/master_files/code/modules/client/preferences/headshot.dm +++ b/modular_nova/master_files/code/modules/client/preferences/headshot.dm @@ -36,7 +36,7 @@ find_index = findtext(value, link_regex) if(find_index != 9) - to_chat(usr, span_warning("The image must be hosted on one of the following sites: 'Gyazo, Byond, Imgbox'")) + to_chat(usr, span_warning("The image must be hosted on one of the following sites: 'Gyazo (i.gyazo.com), Byond (files.byondhome.com), Imgbox (images2.imgbox.com)'")) return apply_headshot(value) diff --git a/modular_nova/master_files/code/modules/mob/dead/new_player/new_player.dm b/modular_nova/master_files/code/modules/mob/dead/new_player/new_player.dm new file mode 100644 index 00000000000..12ce2d72d55 --- /dev/null +++ b/modular_nova/master_files/code/modules/mob/dead/new_player/new_player.dm @@ -0,0 +1,5 @@ +/mob/dead/new_player/transfer_character() + if(iscyborg(new_character)) + var/mutable_appearance/character_appearance = new(new_character.appearance) + GLOB.name_to_appearance[new_character.real_name] = character_appearance // Cache this for Character Directory + return ..() diff --git a/modular_nova/master_files/code/modules/mob/living/sillicon/robot.dm b/modular_nova/master_files/code/modules/mob/living/sillicon/robot.dm index 147b7f38285..e892749ca5e 100644 --- a/modular_nova/master_files/code/modules/mob/living/sillicon/robot.dm +++ b/modular_nova/master_files/code/modules/mob/living/sillicon/robot.dm @@ -93,3 +93,10 @@ color = "#ffffffc2" pixel_y = -8 layer = ABOVE_MOB_LAYER + +// Update the borg's model appearance when they change models +/obj/item/robot_model/do_transform_animation() + . = ..() + var/mob/living/silicon/robot/cyborg = loc + var/mutable_appearance/character_appearance = new(cyborg.appearance) + GLOB.name_to_appearance[cyborg.real_name] = character_appearance // Cache this for Character Directory diff --git a/modular_nova/master_files/code/modules/mob_spawn/mob_spawn.dm b/modular_nova/master_files/code/modules/mob_spawn/mob_spawn.dm index 14350ff99ad..57b19421eef 100644 --- a/modular_nova/master_files/code/modules/mob_spawn/mob_spawn.dm +++ b/modular_nova/master_files/code/modules/mob_spawn/mob_spawn.dm @@ -41,6 +41,9 @@ else if (!isnull(spawned_human)) equip(spawned_human) + var/mutable_appearance/character_appearance = new(spawned_human.appearance) + GLOB.name_to_appearance[spawned_human.real_name] = character_appearance // Cache this for Character Directory + return spawned_mob /// This edit would cause somewhat ugly diffs, so I'm just replacing it. diff --git a/modular_nova/modules/character_directory/code/character_directory.dm b/modular_nova/modules/character_directory/code/character_directory.dm new file mode 100644 index 00000000000..c02c7dfc7f2 --- /dev/null +++ b/modular_nova/modules/character_directory/code/character_directory.dm @@ -0,0 +1,295 @@ +GLOBAL_DATUM(character_directory, /datum/character_directory) +GLOBAL_LIST_EMPTY(name_to_appearance) +#define READ_PREFS(target, pref) (target.client?.prefs?.read_preference(/datum/preference/pref)) + +// We want players to be able to decide whether they show up in the directory or not +/datum/preference/toggle/show_in_directory + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + default_value = TRUE + savefile_key = "show_in_directory" + savefile_identifier = PREFERENCE_PLAYER + +// The advertisement that you show to people looking through the directory +/datum/preference/text/character_ad + savefile_key = "character_ad" + category = PREFERENCE_CATEGORY_NON_CONTEXTUAL + savefile_identifier = PREFERENCE_CHARACTER + maximum_value_length = MAX_FLAVOR_LEN + +// TGUI gets angry if you don't define a default on text preferences +/datum/preference/text/character_ad/create_default_value() + return "" + +// Any text preference needs this for some reason +/datum/preference/text/character_ad/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences) + return FALSE + +/datum/preference/choiced/attraction + savefile_key = "attraction" + category = PREFERENCE_CATEGORY_NON_CONTEXTUAL + savefile_identifier = PREFERENCE_CHARACTER + +/datum/preference/choiced/attraction/init_possible_values() + return list("Gay", "Lesbian", "Straight", "Skolio", "Bi", "Pan", "Poly", "Omni", "Ace", "Aro", "Aro/Ace", "Unset", "Check OOC") + +/datum/preference/choiced/attraction/create_default_value() + return "Unset" + +/datum/preference/choiced/attraction/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences) + return FALSE + +/datum/preference/choiced/display_gender + savefile_key = "display_gender" + category = PREFERENCE_CATEGORY_NON_CONTEXTUAL + savefile_identifier = PREFERENCE_CHARACTER + +/datum/preference/choiced/display_gender/init_possible_values() + return list("Male", "Female", "Null", "Plural", "Nonbinary", "Omni", "Trans", "Andro", "Gyno", "Fluid", "Unset", "Check OOC") + +/datum/preference/choiced/display_gender/create_default_value() + return "Unset" + +/datum/preference/choiced/display_gender/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences) + return FALSE + +// Add a cooldown for the character directory to the client, primarily to stop server lag from refresh spam +/client + COOLDOWN_DECLARE(char_directory_cooldown) + +/// Opens character directory UI for a specific user +/client/verb/show_character_directory(specific_ad as text|null) + set name = "Character Directory" + set category = "OOC" + set desc = "Shows a listing of all active characters, along with their associated OOC notes, flavor text, and more." + + if(is_character_directory_on_cooldown()) + return + + // Check if there's not already a character directory open; open a new one if one is not present + if(!GLOB.character_directory) + GLOB.character_directory = new + + // So we start opening their page right away. There really isn't any other good way to pass this to tgui unfortunately... + if(specific_ad) + var/sanitized_name = trim(specific_ad, MAX_NAME_LEN) + GLOB.character_directory.start_viewing_ad[ckey] = sanitized_name + + GLOB.character_directory.ui_interact(mob) + +/// Returns TRUE if it's on cooldown, FALSE otherwise. This is primarily to stop malicious users from trying to lag the server by spamming this verb +/client/proc/is_character_directory_on_cooldown() + // This is primarily to stop malicious users from trying to lag the server by spamming this verb + if(!COOLDOWN_FINISHED(src, char_directory_cooldown)) + to_chat(src, span_alert("Hold your horses! It's still refreshing!")) + return TRUE + COOLDOWN_START(src, char_directory_cooldown, 10) + return FALSE + +// This is a global singleton. Keep in mind that all operations should occur on user, not src. +/datum/character_directory + /// The character preview views for the UI. + var/list/atom/movable/screen/map_view/char_preview/character_preview_views = list() + /// For when a character starts off viewing a specific character's ad + var/list/start_viewing_ad = list() + +/datum/character_directory/Destroy(force) + for(var/ckey in character_preview_views) + var/atom/movable/screen/map_view/char_preview/preview = character_preview_views[ckey] + var/mob/user = get_mob_by_ckey(ckey) + if(user) + user.client?.screen_maps -= preview + qdel(preview) + return ..() + +/// Makes a managed character preview view for a specific user +/datum/character_directory/proc/create_character_preview_view(mob/user) + var/assigned_view = "preview_[user.ckey]_[REF(src)]_directory" + + // sometimes--e.g. if you have a ui open and you observe--you can end up with a stuck map_view, which leads to subsequent previews not rendering. + // let's clear those out, we always want a new one when calling this proc anyway. + var/old_view = user.client?.screen_maps[assigned_view] + if(old_view) + character_preview_views -= old_view + user.client.screen_maps -= old_view + qdel(old_view) + + var/atom/movable/screen/map_view/char_preview/new_view = new(null) + new_view.generate_view(assigned_view) + new_view.display_to(user) + return new_view + +/// Takes a record and updates the character preview view to match it. +/datum/character_directory/proc/update_preview(mob/user, assigned_view, mutable_appearance/appearance) + var/mutable_appearance/preview = new(appearance) + preview.transform = matrix() // This is so scaled mobs aren't just getting cut off for being too big + + var/atom/movable/screen/map_view/char_preview/old_view = user.client?.screen_maps[assigned_view]?[1] + if(!old_view) + return + + old_view.appearance = preview.appearance + +/datum/character_directory/ui_state(mob/user) + return GLOB.always_state + +/datum/character_directory/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + character_preview_views[user.ckey] = create_character_preview_view(user) + ui = new(user, src, "NovaCharacterDirectory", "Character Directory") + ui.set_autoupdate(FALSE) + ui.open() + +/datum/character_directory/ui_close(mob/user) + var/atom/movable/screen/map_view/char_preview/old_preview = character_preview_views[user.ckey] + user.client?.screen_maps -= old_preview + character_preview_views -= user.ckey + qdel(old_preview) + +// We want this information to update any time the player updates their preferences, not just when the panel is refreshed +/datum/character_directory/ui_data(mob/user) + . = ..() + var/list/data = . + + // Collect the user's own preferences for the top of the UI + if (user?.client?.prefs) + data["personalVisibility"] = READ_PREFS(user, toggle/show_in_directory) + data["personalAttraction"] = READ_PREFS(user, choiced/attraction) + data["personalGender"] = READ_PREFS(user, choiced/display_gender) + data["personalErpTag"] = READ_PREFS(user, choiced/erp_status) + data["personalVoreTag"] = READ_PREFS(user, choiced/erp_status_v) + data["personalNonconTag"] = READ_PREFS(user, choiced/erp_status_nc) + data["personalHypnoTag"] = READ_PREFS(user, choiced/erp_status_hypno) + data["prefsOnly"] = TRUE + + data["assignedView"] = "preview_[user.ckey]_[REF(src)]_directory" + data["canOrbit"] = isobserver(user) + // for when we want to start off with a search term filled in automatically + var/autofill_search_term = start_viewing_ad[user.ckey] + if(autofill_search_term) + data["startViewing"] = autofill_search_term + start_viewing_ad -= user.ckey + + return data + +/datum/character_directory/ui_static_data(mob/user) + . = ..() + var/list/data = . + + // These are the variables we're trying to display in the directory + var/list/directory_mobs = list() + var/name + var/species + var/ooc_notes + var/flavor_text + var/attraction + var/gender + var/erp + var/vore + var/noncon + var/hypno + var/character_ad + var/headshot + var/ref + + // We want the directory to display only alive players, not observers or people in the lobby + for(var/mob/mob in GLOB.alive_player_list) + // Skip people who are opted out + if(!READ_PREFS(mob, toggle/show_in_directory)) + continue + // Just in case ? + if(QDELETED(mob)) + continue + + ref = REF(mob) + + // Different approach for humans and silicons + if(ishuman(mob)) + var/mob/living/carbon/human/human = mob + //If someone is obscured without flavor text visible, we don't want them on the Directory. + if((human.wear_mask && (human.wear_mask.flags_inv & HIDEFACE)) || (human.head && (human.head.flags_inv & HIDEFACE)) || (HAS_TRAIT(human, TRAIT_UNKNOWN))) + continue + //Display custom species, otherwise show base species instead + species = (READ_PREFS(human, text/custom_species)) || "Unset" + if(species == "Unset") + species = "[human.dna.species.name]" + //Load standard flavor text preference + flavor_text = READ_PREFS(human, text/flavor_text) || "" + headshot = human.dna.features["headshot"] || "" + else if(issilicon(mob)) + var/mob/living/silicon/silicon = mob + //If the target is a silicon, we want it to show its brain as its species + species = READ_PREFS(silicon, choiced/brain_type) + //Load silicon flavor text in place of normal flavor text + flavor_text = READ_PREFS(silicon, text/silicon_flavor_text) || "" + headshot = READ_PREFS(silicon, text/headshot) || "" + // Don't show if they are not a human or a silicon + else + continue + + // List of all the shown ERP preferences in the Directory. If there is none, return "Unset" + attraction = READ_PREFS(mob, choiced/attraction) || "Unspecified" + gender = READ_PREFS(mob, choiced/display_gender) || "Unset" + if(gender == "Unset") + gender = capitalize(mob.gender) + erp = READ_PREFS(mob, choiced/erp_status) || "Ask" + vore = READ_PREFS(mob, choiced/erp_status_v) || "Ask" + noncon = READ_PREFS(mob, choiced/erp_status_nc) || "Ask" + hypno = READ_PREFS(mob, choiced/erp_status_hypno) || "Ask" + character_ad = READ_PREFS(mob, text/character_ad) || "" + ooc_notes = READ_PREFS(mob, text/ooc_notes) || "" + // And finally, we want to get the mob's name, taking into account disguised names. + name = mob.real_name ? mob.name : mob.real_name + + directory_mobs.Add(list(list( + "name" = name, + "appearance_name" = mob.real_name, + "species" = species, + "ooc_notes" = ooc_notes, + "attraction" = attraction, + "gender" = gender, + "erp" = erp, + "vore" = vore, + "noncon" = noncon, + "hypno" = hypno, + "character_ad" = character_ad, + "flavor_text" = flavor_text, + "headshot" = headshot, + "ref" = ref + ))) + + data["directory"] = directory_mobs + + return data + +/datum/character_directory/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + + if(.) + return + + var/mob/user = usr + if(!user) + return + + switch(action) + if("refresh") + // This is primarily to stop malicious users from trying to lag the server by spamming this verb + if(!COOLDOWN_FINISHED(user.client, char_directory_cooldown)) + to_chat(user, "Please wait before refreshing the directory again.") + return + COOLDOWN_START(user.client, char_directory_cooldown, 10) + update_static_data(user, ui) + return TRUE + if("orbit") + var/ref = params["ref"] + var/mob/dead/observer/ghost = user + var/atom/movable/poi = (locate(ref) in GLOB.mob_list) + if (poi == null) + return TRUE + ghost.ManualFollow(poi) + ghost.reset_perspective(null) + return TRUE + if("view_character") + update_preview(usr, params["assigned_view"], GLOB.name_to_appearance[params["name"]]) + return TRUE diff --git a/modular_nova/modules/customization/modules/mob/living/carbon/human/human.dm b/modular_nova/modules/customization/modules/mob/living/carbon/human/human.dm index 44ca0cd3e60..ca3c208e12b 100644 --- a/modular_nova/modules/customization/modules/mob/living/carbon/human/human.dm +++ b/modular_nova/modules/customization/modules/mob/living/carbon/human/human.dm @@ -24,6 +24,8 @@ to_chat(usr, span_notice("[jointext(line, "\n")]")) if("open_examine_panel") mob_examine_panel.ui_interact(usr) //datum has a examine_panel component, here we open the window + if("open_character_ad") + usr.client?.show_character_directory(specific_ad = real_name) /mob/living/carbon/human/species/vox race = /datum/species/vox diff --git a/modular_nova/modules/customization/modules/mob/living/silicon/examine.dm b/modular_nova/modules/customization/modules/mob/living/silicon/examine.dm index f83c5843116..e6e317ec1ad 100644 --- a/modular_nova/modules/customization/modules/mob/living/silicon/examine.dm +++ b/modular_nova/modules/customization/modules/mob/living/silicon/examine.dm @@ -13,6 +13,9 @@ if (flavor_text_link) . += flavor_text_link + if (client?.prefs.read_preference(/datum/preference/text/character_ad)) + . += span_notice("They have an ad in the character directory... \[Open directory?\]") + if(client) var/erp_status_pref = client.prefs.read_preference(/datum/preference/choiced/erp_status) if(erp_status_pref && !CONFIG_GET(flag/disable_erp_preferences)) diff --git a/modular_nova/modules/customization/modules/mob/living/silicon/topic.dm b/modular_nova/modules/customization/modules/mob/living/silicon/topic.dm index d20650f478d..2acec6401f1 100644 --- a/modular_nova/modules/customization/modules/mob/living/silicon/topic.dm +++ b/modular_nova/modules/customization/modules/mob/living/silicon/topic.dm @@ -4,3 +4,5 @@ mob_examine_panel.ui_interact(usr) //datum has a examine_panel datum, here we open the window if(href_list["temporary_flavor"]) // we need this here because tg code doesnt call parent in /mob/living/silicon/Topic() show_temp_ftext(usr) + if(href_list["lookup_info"] == "open_character_ad") + usr.client?.show_character_directory(specific_ad = name) diff --git a/modular_nova/modules/title_screen/code/new_player.dm b/modular_nova/modules/title_screen/code/new_player.dm index ef571bc88fd..679eb527739 100644 --- a/modular_nova/modules/title_screen/code/new_player.dm +++ b/modular_nova/modules/title_screen/code/new_player.dm @@ -27,6 +27,11 @@ ViewManifest() return + if(href_list["view_directory"]) + play_lobby_button_sound() + client?.show_character_directory() + return + if(href_list["toggle_antag"]) play_lobby_button_sound() var/datum/preferences/preferences = client.prefs diff --git a/modular_nova/modules/title_screen/code/title_screen_html.dm b/modular_nova/modules/title_screen/code/title_screen_html.dm index 30f3f2949e2..a6171c6a549 100644 --- a/modular_nova/modules/title_screen/code/title_screen_html.dm +++ b/modular_nova/modules/title_screen/code/title_screen_html.dm @@ -105,6 +105,7 @@ GLOBAL_LIST_EMPTY(startup_messages) dat += {" JOIN GAME CREW MANIFEST + CHARACTER DIRECTORY "} dat += {"OBSERVE"} diff --git a/tgstation.dme b/tgstation.dme index 547059e5b16..81c18557eba 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6669,6 +6669,7 @@ #include "modular_nova\master_files\code\modules\mining\equipment\explorer_gear.dm" #include "modular_nova\master_files\code\modules\mob\login.dm" #include "modular_nova\master_files\code\modules\mob\dead\new_player\latejoin_menu.dm" +#include "modular_nova\master_files\code\modules\mob\dead\new_player\new_player.dm" #include "modular_nova\master_files\code\modules\mob\dead\new_player\preferences_setup.dm" #include "modular_nova\master_files\code\modules\mob\living\blood.dm" #include "modular_nova\master_files\code\modules\mob\living\emote_popup.dm" @@ -7010,6 +7011,7 @@ #include "modular_nova\modules\central_command_module\code\obj\wall.dm" #include "modular_nova\modules\chadian\code\chadian.dm" #include "modular_nova\modules\chaplain\code\mortis.dm" +#include "modular_nova\modules\character_directory\code\character_directory.dm" #include "modular_nova\modules\chat_colors\code\chat_color.dm" #include "modular_nova\modules\clock_cult\code\antagonist.dm" #include "modular_nova\modules\clock_cult\code\area.dm" diff --git a/tgui/packages/tgui/interfaces/NovaCharacterDirectory.jsx b/tgui/packages/tgui/interfaces/NovaCharacterDirectory.jsx new file mode 100644 index 00000000000..c35f04c11e7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NovaCharacterDirectory.jsx @@ -0,0 +1,511 @@ +// THIS IS A NOVA SECTOR UI FILE +import { useState } from 'react'; + +import { resolveAsset } from '../assets'; +import { useBackend } from '../backend'; +import { + Button, + Divider, + Icon, + Input, + LabeledList, + NoticeBox, + Section, + Stack, + Table, + Tooltip, +} from '../components'; +import { Window } from '../layouts'; +import { CharacterPreview } from './common/CharacterPreview'; + +const formatURLs = (text) => { + if (!text) return; + const parts = []; + let regex = /https?:\/\/[^\s/$.?#].[^\s]*/gi; + let lastIndex = 0; + + text.replace(regex, (url, index) => { + parts.push(text.substring(lastIndex, index)); + parts.push( + + {url} + , + ); + lastIndex = index + url.length; + return url; + }); + + parts.push(text.substring(lastIndex)); + + return