diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm index 1b4f63ff305..cb3fd847bbe 100644 --- a/code/__HELPERS/icons.dm +++ b/code/__HELPERS/icons.dm @@ -1061,10 +1061,9 @@ GLOBAL_LIST_EMPTY(friendly_animal_types) var/icon/out_icon = icon('icons/effects/effects.dmi', "nothing") + COMPILE_OVERLAYS(body) for(var/D in showDirs) - body.setDir(D) - COMPILE_OVERLAYS(body) - var/icon/partial = getFlatIcon(body) + var/icon/partial = getFlatIcon(body, defdir=D) out_icon.Insert(partial,dir=D) humanoid_icon_cache[icon_id] = out_icon diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 932a8fa94f5..8b86167e3ad 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -84,7 +84,7 @@ GLOBAL_PROTECT(admin_verbs_ban) GLOBAL_LIST_INIT(admin_verbs_sounds, list(/client/proc/play_local_sound, /client/proc/play_direct_mob_sound, /client/proc/play_sound, /client/proc/set_round_end_sound)) GLOBAL_PROTECT(admin_verbs_sounds) GLOBAL_LIST_INIT(admin_verbs_fun, list( - /client/proc/cmd_admin_dress, + /client/proc/cmd_select_equipment, /client/proc/cmd_admin_gib_self, /client/proc/drop_bomb, /client/proc/set_dynex_scale, @@ -217,7 +217,7 @@ GLOBAL_LIST_INIT(admin_verbs_hideable, list( /client/proc/play_local_sound, /client/proc/play_sound, /client/proc/set_round_end_sound, - /client/proc/cmd_admin_dress, + /client/proc/cmd_select_equipment, /client/proc/cmd_admin_gib_self, /client/proc/drop_bomb, /client/proc/drop_dynex_bomb, diff --git a/code/modules/admin/verbs/debug.dm b/code/modules/admin/verbs/debug.dm index 0495449c409..f022a2dea21 100644 --- a/code/modules/admin/verbs/debug.dm +++ b/code/modules/admin/verbs/debug.dm @@ -495,39 +495,6 @@ But you can call procs that are of type /mob/living/carbon/human/proc/ for that set name = "Test Areas (ALL)" cmd_admin_areatest(FALSE) -/client/proc/cmd_admin_dress(mob/M in GLOB.mob_list) - set category = "Admin.Events" - set name = "Select equipment" - if(!(ishuman(M) || isobserver(M))) - alert("Invalid mob") - return - - var/dresscode = robust_dress_shop() - - if(!dresscode) - return - - var/delete_pocket - var/mob/living/carbon/human/H - if(isobserver(M)) - H = M.change_mob_type(/mob/living/carbon/human, null, null, TRUE) - else - H = M - if(H.l_store || H.r_store || H.s_store) //saves a lot of time for admins and coders alike - if(alert("Drop Items in Pockets? No will delete them.", "Robust quick dress shop", "Yes", "No") == "No") - delete_pocket = TRUE - - SSblackbox.record_feedback("tally", "admin_verb", 1, "Select Equipment") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! - for (var/obj/item/I in H.get_equipped_items(delete_pocket)) - qdel(I) - if(dresscode != "Naked") - H.equipOutfit(dresscode) - - H.regenerate_icons() - - log_admin("[key_name(usr)] changed the equipment of [key_name(H)] to [dresscode].") - message_admins("[key_name_admin(usr)] changed the equipment of [ADMIN_LOOKUPFLW(H)] to [dresscode].") - /client/proc/robust_dress_shop() var/list/baseoutfits = list("Naked","Custom","As Job...", "As Plasmaman...") diff --git a/code/modules/admin/verbs/selectequipment.dm b/code/modules/admin/verbs/selectequipment.dm new file mode 100644 index 00000000000..8e8c8b323db --- /dev/null +++ b/code/modules/admin/verbs/selectequipment.dm @@ -0,0 +1,230 @@ +/client/proc/cmd_select_equipment(mob/target in GLOB.mob_list) + set category = "Admin.Events" + set name = "Select equipment" + + + var/datum/select_equipment/ui = new(usr, target) + ui.ui_interact(usr) + +/* + * This is the datum housing the select equipment UI. + * + * You may notice some oddities about the way outfits are passed to the UI and vice versa here. + * That's because it handles both outfit typepaths (for normal outfits) *and* outfit objects (for custom outfits). + * + * Custom outfits need to be objects as they're created in runtime. + * "Then just handle the normal outfits as objects too and simplify the handling" - you may say. + * There are about 300 outfit types at the time of writing this. Initializing all of these to objects would be a huge waste. + * + */ + +/datum/select_equipment + var/client/user + var/mob/target_mob + + var/dummy_key + var/mob/living/carbon/human/dummy/dummy + + //static list to share all the outfit typepaths between all instances of this datum. + var/static/list/cached_outfits + + //a typepath if the selected outfit is a normal outfit; + //an object if the selected outfit is a custom outfit + var/datum/outfit/selected_outfit = /datum/outfit + //serializable string for the UI to keep track of which outfit is selected + var/selected_identifier = "/datum/outfit" + +/datum/select_equipment/New(_user, mob/target) + user = CLIENT_FROM_VAR(_user) + + if(!ishuman(target) && !isobserver(target)) + alert("Invalid mob") + return + target_mob = target + +/datum/select_equipment/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "SelectEquipment", "Select Equipment") + ui.open() + ui.set_autoupdate(FALSE) + +/datum/select_equipment/ui_state(mob/user) + return GLOB.admin_state + +/datum/select_equipment/ui_status(mob/user, datum/ui_state/state) + if(QDELETED(target_mob)) + return UI_CLOSE + return ..() + +/datum/select_equipment/ui_close(mob/user) + clear_human_dummy(dummy_key) + qdel(src) + +/datum/select_equipment/proc/init_dummy() + dummy_key = "selectequipmentUI_[target_mob]" + dummy = generate_or_wait_for_human_dummy(dummy_key) + var/mob/living/carbon/carbon_target = target_mob + if(istype(carbon_target)) + carbon_target.dna.transfer_identity(dummy) + dummy.updateappearance() + + unset_busy_human_dummy(dummy_key) + return + +/** + * Packs up data about an outfit as an assoc list to send to the UI as an outfit entry. + * + * Args: + * * category (string) - The tab it will be under + * + * * identifier (typepath or ref) - This will sent this back to ui_act to preview or spawn in an outfit. + * * Must be unique between all entries. + * + * * name (string) - Will be the text on the button + * + * * priority (bool)(optional) - If True, the UI will sort the entry to the top, right below favorites. + * + * * custom_entry (bool)(optional) - Send the identifier with a "ref" keyword instead of "path", + * * for the UI to tell apart custom outfits from normal ones. + * + * Returns (list) An outfit entry + */ + +/datum/select_equipment/proc/outfit_entry(category, identifier, name, priority=FALSE, custom_entry=FALSE) + if(custom_entry) + return list("category" = category, "ref" = identifier, "name" = name, "priority" = priority) + return list("category" = category, "path" = identifier, "name" = name, "priority" = priority) + +/datum/select_equipment/proc/make_outfit_entries(category="General", list/outfit_list) + var/list/entries = list() + for(var/path as anything in outfit_list) + var/datum/outfit/outfit = path + entries += list(outfit_entry(category, path, initial(outfit.name))) + return entries + +//GLOB.custom_outfits lists outfit *objects* so we'll need to do some custom handling for it +/datum/select_equipment/proc/make_custom_outfit_entries(list/outfit_list) + var/list/entries = list() + for(var/datum/outfit/outfit as anything in outfit_list) + entries += list(outfit_entry("Custom", REF(outfit), outfit.name, custom_entry=TRUE)) //it's either this or special handling on the UI side + return entries + +/datum/select_equipment/ui_data(mob/user) + var/list/data = list() + if(!dummy) + init_dummy() + + var/datum/preferences/prefs = target_mob?.client?.prefs + var/icon/dummysprite = get_flat_human_icon(null, prefs=prefs, dummy_key = dummy_key, outfit_override = selected_outfit) + data["icon64"] = icon2base64(dummysprite) + data["name"] = target_mob + + data["favorites"] = list() + if(prefs) + data["favorites"] = prefs.favorite_outfits + + var/list/custom + custom += make_custom_outfit_entries(GLOB.custom_outfits) + data["custom_outfits"] = custom + data["current_outfit"] = selected_identifier + return data + + +/datum/select_equipment/ui_static_data(mob/user) + var/list/data = list() + if(!cached_outfits) + cached_outfits = list() + cached_outfits += list(outfit_entry("General", /datum/outfit, "Naked", priority=TRUE)) + cached_outfits += make_outfit_entries("General", subtypesof(/datum/outfit) - typesof(/datum/outfit/job) - typesof(/datum/outfit/plasmaman)) + cached_outfits += make_outfit_entries("Jobs", typesof(/datum/outfit/job)) + cached_outfits += make_outfit_entries("Plasmamen Outfits", typesof(/datum/outfit/plasmaman)) + + data["outfits"] = cached_outfits + return data + + +/datum/select_equipment/proc/resolve_outfit(text) + + var/path = text2path(text) + if(ispath(path, /datum/outfit)) + return path + + else //don't bail yet - could be a custom outfit + var/datum/outfit/custom_outfit = locate(text) + if(istype(custom_outfit)) + return custom_outfit + + +/datum/select_equipment/ui_act(action, params) + if(..()) + return + . = TRUE + switch(action) + if("preview") + var/datum/outfit/new_outfit = resolve_outfit(params["path"]) + + if(ispath(new_outfit)) //got a typepath - that means we're dealing with a normal outfit + selected_identifier = new_outfit //these are keyed by type + //by the way, no, they can't be keyed by name because many of them have duplicate names + + else if(istype(new_outfit)) //got an initialized object - means it's a custom outfit + selected_identifier = REF(new_outfit) //and the outfit will be keyed by its ref (cause its type will always be /datum/outfit) + + else //we got nothing and should bail + return + + selected_outfit = new_outfit + + if("applyoutfit") + var/datum/outfit/new_outfit = resolve_outfit(params["path"]) + if(new_outfit && ispath(new_outfit)) //initialize it + new_outfit = new new_outfit + if(!istype(new_outfit)) + return + user.admin_apply_outfit(target_mob, new_outfit) + + if("customoutfit") + user.outfit_manager() + + if("togglefavorite") + var/datum/outfit/outfit_path = resolve_outfit(params["path"]) + if(!ispath(outfit_path)) //we do *not* want custom outfits (i.e objects) here, they're not even persistent + return + + if(user.prefs.favorite_outfits.Find(outfit_path)) //already there, remove it + user.prefs.favorite_outfits -= outfit_path + else //not there, add it + user.prefs.favorite_outfits += outfit_path + user.prefs.save_preferences() + +/client/proc/admin_apply_outfit(mob/target, dresscode) + if(!ishuman(target) && !isobserver(target)) + alert("Invalid mob") + return + + if(!dresscode) + return + + var/delete_pocket + var/mob/living/carbon/human/human_target + if(isobserver(target)) + human_target = target.change_mob_type(/mob/living/carbon/human, delete_old_mob = TRUE) + else + human_target = target + if(human_target.l_store || human_target.r_store || human_target.s_store) //saves a lot of time for admins and coders alike + if(alert("Drop Items in Pockets? No will delete them.", "Robust quick dress shop", "Yes", "No") == "No") + delete_pocket = TRUE + + SSblackbox.record_feedback("tally", "admin_verb", 1, "Select Equipment") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + for(var/obj/item/item in human_target.get_equipped_items(delete_pocket)) + qdel(item) + if(dresscode != "Naked") + human_target.equipOutfit(dresscode) + + human_target.regenerate_icons() + + log_admin("[key_name(usr)] changed the equipment of [key_name(human_target)] to [dresscode].") + message_admins("[key_name_admin(usr)] changed the equipment of [ADMIN_LOOKUPFLW(human_target)] to [dresscode].") + + return dresscode diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index e0e738c558b..405e3bb0cff 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -146,6 +146,8 @@ GLOBAL_LIST_EMPTY(preferences_datums) var/persistent_scars = TRUE ///If we want to broadcast deadchat connect/disconnect messages var/broadcast_login_logout = TRUE + ///What outfit typepaths we've favorited in the SelectEquipment menu + var/list/favorite_outfits = list() /datum/preferences/New(client/C) parent = C @@ -645,7 +647,7 @@ GLOBAL_LIST_EMPTY(preferences_datums) dat += "Set screentip mode: [screentip_pref ? "Enabled" : "Disabled"]
" dat += "Screentip color:    Change
" dat += "Item Hover Outlines: [itemoutline_pref ? "Enabled" : "Disabled"]
" - + dat += "Ambient Occlusion: [ambientocclusion ? "Enabled" : "Disabled"]
" dat += "Fit Viewport: [auto_fit_viewport ? "Auto" : "Manual"]
" diff --git a/code/modules/client/preferences_savefile.dm b/code/modules/client/preferences_savefile.dm index c049e44af2a..3734b3b7586 100644 --- a/code/modules/client/preferences_savefile.dm +++ b/code/modules/client/preferences_savefile.dm @@ -214,6 +214,15 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car READ_FILE(S["hearted_until"], hearted_until) if(hearted_until > world.realtime) hearted = TRUE + //favorite outfits + READ_FILE(S["favorite_outfits"], favorite_outfits) + + var/list/parsed_favs = list() + for(var/typetext in favorite_outfits) + var/datum/outfit/path = text2path(typetext) + if(ispath(path)) //whatever typepath fails this check probably doesn't exist anymore + parsed_favs += path + favorite_outfits = uniqueList(parsed_favs) //try to fix any outdated data if necessary if(needs_update >= 0) @@ -262,6 +271,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car pda_style = sanitize_inlist(pda_style, GLOB.pda_styles, initial(pda_style)) pda_color = sanitize_hexcolor(pda_color, 6, 1, initial(pda_color)) key_bindings = sanitize_keybindings(key_bindings) + favorite_outfits = SANITIZE_LIST(favorite_outfits) if(needs_update >= 0) //save the updated version var/old_default_slot = default_slot @@ -339,6 +349,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car WRITE_FILE(S["pda_color"], pda_color) WRITE_FILE(S["key_bindings"], key_bindings) WRITE_FILE(S["hearted_until"], (hearted_until > world.realtime ? hearted_until : null)) + WRITE_FILE(S["favorite_outfits"], favorite_outfits) return TRUE /datum/preferences/proc/load_character(slot) diff --git a/code/modules/mob/living/carbon/human/dummy.dm b/code/modules/mob/living/carbon/human/dummy.dm index 0d8af0ddc1d..360efeb0d7c 100644 --- a/code/modules/mob/living/carbon/human/dummy.dm +++ b/code/modules/mob/living/carbon/human/dummy.dm @@ -40,13 +40,26 @@ GLOBAL_LIST_EMPTY(dummy_mob_list) D = new GLOB.human_dummy_list[slotkey] = D GLOB.dummy_mob_list += D + else + D.regenerate_icons() //they were cut in wipe_state() D.in_use = TRUE return D -/proc/unset_busy_human_dummy(slotnumber) - if(!slotnumber) +/proc/unset_busy_human_dummy(slotkey) + if(!slotkey) return - var/mob/living/carbon/human/dummy/D = GLOB.human_dummy_list[slotnumber] + var/mob/living/carbon/human/dummy/D = GLOB.human_dummy_list[slotkey] if(istype(D)) D.wipe_state() D.in_use = FALSE + +/proc/clear_human_dummy(slotkey) + if(!slotkey) + return + + var/mob/living/carbon/human/dummy/dummy = GLOB.human_dummy_list[slotkey] + + GLOB.human_dummy_list -= slotkey + if(istype(dummy)) + GLOB.dummy_mob_list -= dummy + qdel(dummy) diff --git a/tgstation.dme b/tgstation.dme index df3d9e561b0..7ed54542887 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -1541,6 +1541,7 @@ #include "code\modules\admin\verbs\randomverbs.dm" #include "code\modules\admin\verbs\reestablish_db_connection.dm" #include "code\modules\admin\verbs\secrets.dm" +#include "code\modules\admin\verbs\selectequipment.dm" #include "code\modules\admin\verbs\shuttlepanel.dm" #include "code\modules\admin\verbs\spawnobjasmob.dm" #include "code\modules\admin\verbs\tripAI.dm" diff --git a/tgui/packages/common/collections.js b/tgui/packages/common/collections.js index 7b4540e730a..5c0b3d08938 100644 --- a/tgui/packages/common/collections.js +++ b/tgui/packages/common/collections.js @@ -171,6 +171,8 @@ export const sortBy = (...iterateeFns) => array => { return mappedArray; }; +export const sort = sortBy(); + /** * A fast implementation of reduce. */ @@ -235,6 +237,8 @@ export const uniqBy = iterateeFn => array => { return result; }; +export const uniq = uniqBy(); + /** * Creates an array of grouped elements, the first of which contains * the first elements of the given arrays, the second of which contains diff --git a/tgui/packages/tgui/interfaces/SelectEquipment.js b/tgui/packages/tgui/interfaces/SelectEquipment.js new file mode 100644 index 00000000000..e7abf709bc9 --- /dev/null +++ b/tgui/packages/tgui/interfaces/SelectEquipment.js @@ -0,0 +1,214 @@ +import { filter, map, sortBy, uniq } from 'common/collections'; +import { flow } from 'common/fp'; +import { createSearch } from 'common/string'; +import { useBackend, useLocalState } from '../backend'; +import { Box, Button, Icon, Input, Section, Stack, Tabs } from '../components'; +import { Window } from '../layouts'; + +// here's an important mental define: +// custom outfits give a ref keyword instead of path +const getOutfitKey = outfit => outfit.path || outfit.ref; + +const useOutfitTabs = (context, categories) => { + return useLocalState(context, 'selected-tab', categories[0]); +}; + +export const SelectEquipment = (props, context) => { + const { act, data } = useBackend(context); + const { + name, + icon64, + current_outfit, + favorites, + } = data; + + const isFavorited = entry => favorites?.includes(entry.path); + + const outfits = map(entry => ({ + ...entry, + favorite: isFavorited(entry), + }))([ + ...data.outfits, + ...data.custom_outfits, + ]); + + // even if no custom outfits were sent, we still want to make sure there's + // at least a 'Custom' tab so the button to create a new one pops up + const categories = uniq([ + ...outfits.map(entry => entry.category), + 'Custom', + ]); + const [tab] = useOutfitTabs(context, categories); + + const [searchText, setSearchText] = useLocalState( + context, 'searchText', ''); + const searchFilter = createSearch(searchText, entry => ( + entry.name + entry.path + )); + + const visibleOutfits = flow([ + filter(entry => entry.category === tab), + filter(searchFilter), + sortBy( + entry => !entry.favorite, + entry => !entry.priority, + entry => entry.name + ), + ])(outfits); + + const getOutfitEntry = current_outfit => outfits.find(outfit => ( + getOutfitKey(outfit) === current_outfit + )); + + const currentOutfitEntry = getOutfitEntry(current_outfit); + + return ( + + + + + + + setSearchText(value)} /> + + + + + + + + + + + + +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ ); +}; + +const DisplayTabs = (props, context) => { + const { categories } = props; + const [tab, setTab] = useOutfitTabs(context, categories); + return ( + + {categories.map(category => ( + setTab(category)}> + {category} + + ))} + + ); +}; + +const OutfitDisplay = (props, context) => { + const { act, data } = useBackend(context); + const { current_outfit } = data; + const { entries, currentTab } = props; + return ( +
+ {entries.map(entry => ( + + )} +
+ ); +}; + +const CurrentlySelectedDisplay = (props, context) => { + const { act, data } = useBackend(context); + const { current_outfit } = data; + const { entry } = props; + return ( + + {entry?.path && ( + + act('togglefavorite', { + path: entry.path, + })} /> + + )} + + + Currently selected: + + + {entry?.name} + + + + + + + ); +};