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}
+
+
+
+ act('applyoutfit', {
+ path: current_outfit,
+ })}>
+ Confirm
+
+
+
+ );
+};