Current Quirks: [all_quirks.len ? all_quirks.Join(", ") : "None"]
"
- dat += "
Identity
"
- dat += "
"
- if(is_banned_from(user.ckey, "Appearance"))
- dat += "You are banned from using custom names and appearances. You can continue to adjust your characters, but you will be randomised once you join the game. "
- dat += "Random Name "
- dat += "Always Random Name: [be_random_name ? "Yes" : "No"] "
-
- dat += "Name: "
- dat += "[real_name] "
-
- if(FGENDER in pref_species.species_traits) //check for forced genders first like a smart person
- gender = FEMALE
- else if(AGENDER in pref_species.species_traits)
- gender = PLURAL
- else if(MGENDER in pref_species.species_traits)
- gender = MALE
- else
- var/dispGender
- if(gender == MALE)
- dispGender = "Male"
- else if(gender == FEMALE)
- dispGender = "Female"
- else
- dispGender = "Other"
- dat += "Gender:[dispGender]"
- dat += "[random_locks["gender"] ? "Unlock" : "Lock"] "
-
- dat += "Age:[age] "
-
- dat += "Special Names: "
- var/old_group
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/namedata = GLOB.preferences_custom_names[custom_name_id]
- if(!old_group)
- old_group = namedata["group"]
- else if(old_group != namedata["group"])
- old_group = namedata["group"]
- dat += " "
- dat += "[namedata["pref_name"]]: [custom_names[custom_name_id]] "
- dat += "
"
-
- var/use_skintones = pref_species.use_skintones
- if(use_skintones)
-
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
Skin Tone
"
-
- dat += "[skin_tone]"
- dat += "[random_locks["underwear"] ? "Unlock" : "Lock"] "
-
- var/mutant_colors
- if((((MUTCOLORS in pref_species.species_traits) && !(NOCOLORCHANGE in pref_species.species_traits))) || (MUTCOLORS_PARTSONLY in pref_species.species_traits))
-
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
Mutant Color
"
-
- dat += ""
- dat += "Change[random_locks["mcolor"] ? "Unlock" : "Lock"] "
-
- mutant_colors = TRUE
-
- if(istype(pref_species, /datum/species/ethereal)) //not the best thing to do tbf but I dont know whats better.
-
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
Ethereal Color
"
-
- dat += ""
- dat += "Change[random_locks["ethcolor"] ? "Unlock" : "Lock"] "
-
-
- if(istype(pref_species, /datum/species/preternis)) //fuck, i know even less than you, i've just been copy pasting thus far.
-
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
Preternis Color
"
-
- dat += ""
- dat += "Change[random_locks["pretcolor"] ? "Unlock" : "Lock"] "
-
- if((EYECOLOR in pref_species.species_traits) || !(NOEYESPRITES in pref_species.species_traits))
-
- if(!use_skintones && !mutant_colors)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
Eye Color
"
-
- dat += ""
- dat += "Change[random_locks["eyes"] ? "Unlock" : "Lock"] "
-
- dat += ""
- else if(use_skintones || mutant_colors)
- dat += ""
-
- if(HAIR in pref_species.species_traits)
-
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
"
- dat += "[facial_hair_style]"
- dat += "[random_locks["facial_hair_style"] ? "Unlock" : "Lock"] "
+/datum/preferences/ui_interact(mob/user, datum/tgui/ui)
+ // If you leave and come back, re-register the character preview
+ if (!isnull(character_preview_view) && !(character_preview_view in user.client?.screen))
+ user.client?.register_map_obj(character_preview_view)
- dat += "<> "
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "PreferencesMenu")
+ ui.set_autoupdate(FALSE)
+ ui.open()
- dat += "Change"
- dat += "[random_locks["facial"] ? "Unlock" : "Lock"] "
+ // HACK: Without this the character starts out really tiny because of some BYOND bug.
+ // You can fix it by changing a preference, so let's just forcably update the body to emulate this.
+ addtimer(CALLBACK(character_preview_view, /atom/movable/screen/character_preview_view/proc/update_body), 1 SECONDS)
- dat += "
Hair Gradient
"
+/datum/preferences/ui_state(mob/user)
+ return GLOB.always_state
- dat += "[features["gradientstyle"]]"
- dat += "[random_locks["gradientstyle"] ? "Unlock" : "Lock"] "
+// Without this, a hacker would be able to edit other people's preferences if
+// they had the ref to Topic to.
+/datum/preferences/ui_status(mob/user, datum/ui_state/state)
+ return user.client == parent ? UI_INTERACTIVE : UI_CLOSE
- dat += "<> "
+/datum/preferences/ui_data(mob/user)
+ var/list/data = list()
- dat += "Change"
- dat += "[random_locks["gradientcolor"] ? "Unlock" : "Lock"] "
+ if (isnull(character_preview_view))
+ character_preview_view = create_character_preview_view(user)
+ else if (character_preview_view.client != parent)
+ // The client re-logged, and doing this when they log back in doesn't seem to properly
+ // carry emissives.
+ character_preview_view.register_to_client(parent)
- dat += ""
+ if (tainted_character_profiles)
+ data["character_profiles"] = create_character_profiles()
+ tainted_character_profiles = FALSE
- //Mutant stuff
- var/mutant_category = 0
+ data["character_preferences"] = compile_character_preferences(user)
- if("tail_lizard" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ data["active_slot"] = default_slot
- dat += "
Tail
"
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ data += preference_middleware.get_ui_data(user)
- dat += "[features["tail_lizard"]]"
- dat += "[random_locks["tail_lizard"] ? "Unlock" : "Lock"] "
+ return data
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+/datum/preferences/ui_static_data(mob/user)
+ var/list/data = list()
- if("tail_polysmorph" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ data["character_profiles"] = create_character_profiles()
- dat += "
Tail
"
+ data["character_preview_view"] = character_preview_view.assigned_map
+ data["overflow_role"] = SSjob.GetJob(SSjob.overflow_role).title
+ data["window"] = current_window
- dat += "[features["tail_polysmorph"]]"
- dat += "[random_locks["tail_polysmorph"] ? "Unlock" : "Lock"] "
+ data["content_unlocked"] = unlock_content
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ data += preference_middleware.get_ui_static_data(user)
- if("snout" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ return data
- dat += "
Snout
"
+/datum/preferences/ui_assets(mob/user)
+ var/list/assets = list(
+ get_asset_datum(/datum/asset/spritesheet/preferences),
+ get_asset_datum(/datum/asset/json/preferences),
+ )
- dat += "[features["snout"]]"
- dat += "[random_locks["snout"] ? "Unlock" : "Lock"] "
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ assets += preference_middleware.get_ui_assets()
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ return assets
- if("horns" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/datum/preferences/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if (.)
+ return
- dat += "
Horns
"
+ switch (action)
+ if ("change_slot")
+ // Save existing character
+ save_character()
- dat += "[features["horns"]]"
- dat += "[random_locks["horns"] ? "Unlock" : "Lock"] "
+ // SAFETY: `load_character` performs sanitization the slot number
+ if (!load_character(params["slot"]))
+ tainted_character_profiles = TRUE
+ randomise_appearance_prefs()
+ save_character()
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ preference_middleware.on_new_character(usr)
- if("frills" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ character_preview_view.update_body()
- dat += "
Frills
"
+ return TRUE
+ if ("rotate")
+ character_preview_view.dir = turn(character_preview_view.dir, -90)
- dat += "[features["frills"]]"
- dat += "[random_locks["frills"] ? "Unlock" : "Lock"] "
+ return TRUE
+ if ("set_preference")
+ var/requested_preference_key = params["preference"]
+ var/value = params["value"]
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ if (preference_middleware.pre_set_preference(usr, requested_preference_key, value))
+ return TRUE
- if("spines" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
- dat += "
Spines
"
+ // SAFETY: `update_preference` performs validation checks
+ if (!update_preference(requested_preference, value))
+ return FALSE
- dat += "[features["spines"]]"
- dat += "[random_locks["spines"] ? "Unlock" : "Lock"] "
+ if (istype(requested_preference, /datum/preference/name))
+ tainted_character_profiles = TRUE
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ return TRUE
+ if ("set_color_preference")
+ var/requested_preference_key = params["preference"]
- if("body_markings" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
- dat += "
Body Markings
"
+ if (!istype(requested_preference, /datum/preference/color) \
+ && !istype(requested_preference, /datum/preference/color_legacy) \
+ )
+ return FALSE
- dat += "[features["body_markings"]]"
- dat += "[random_locks["body_markings"] ? "Unlock" : "Lock"] "
+ var/default_value = read_preference(requested_preference.type)
+ if (istype(requested_preference, /datum/preference/color_legacy))
+ default_value = expand_three_digit_color(default_value)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ // Yielding
+ var/new_color = input(
+ usr,
+ "Select new color",
+ null,
+ default_value || COLOR_WHITE,
+ ) as color | null
- if(("legs" in pref_species.default_features) && !(DIGITIGRADE in pref_species.species_traits))
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ if (!new_color)
+ return FALSE
- dat += "
Legs
"
+ if (!update_preference(requested_preference, new_color))
+ return FALSE
- dat += "[features["legs"]]"
- dat += "[random_locks["legs"] ? "Unlock" : "Lock"] "
+ return TRUE
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ var/delegation = preference_middleware.action_delegations[action]
+ if (!isnull(delegation))
+ return call(preference_middleware, delegation)(params, usr)
- if("moth_wings" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ return FALSE
- dat += "
Moth wings
"
+/datum/preferences/ui_close(mob/user)
+ save_character()
+ save_preferences()
+ QDEL_NULL(character_preview_view)
- dat += "[features["moth_wings"]]"
- dat += "[random_locks["moth_wings"] ? "Unlock" : "Lock"] "
+/datum/preferences/Topic(href, list/href_list)
+ . = ..()
+ if (.)
+ return
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ if (href_list["open_keybindings"])
+ current_window = PREFERENCE_TAB_KEYBINDINGS
+ update_static_data(usr)
+ ui_interact(usr)
+ return TRUE
- if("teeth" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/datum/preferences/Topic(href, list/href_list)
+ . = ..()
+ if (.)
+ return
- dat += "
"
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (!preference.is_accessible(src))
+ continue
- dat += "[features["dome"]]"
- dat += "[random_locks["dome"] ? "Unlock" : "Lock"] "
+ LAZYINITLIST(preferences[preference.category])
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ var/value = read_preference(preference.type)
+ var/data = preference.compile_ui_data(user, value)
- if("dorsal_tubes" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ preferences[preference.category][preference.savefile_key] = data
- dat += "
Dorsal Tubes
"
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ var/list/append_character_preferences = preference_middleware.get_character_preferences(user)
+ if (isnull(append_character_preferences))
+ continue
- dat += "[features["dorsal_tubes"]]"
- dat += "[random_locks["dorsal_tubes"] ? "Unlock" : "Lock"] "
+ for (var/category in append_character_preferences)
+ if (category in preferences)
+ preferences[category] += append_character_preferences[category]
+ else
+ preferences[category] = append_character_preferences[category]
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ return preferences
- if("ethereal_mark" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/// Applies all PREFERENCE_PLAYER preferences
+/datum/preferences/proc/apply_all_client_preferences()
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preference.savefile_identifier != PREFERENCE_PLAYER)
+ continue
- dat += "
Ethereal Mark
"
+ value_cache -= preference.type
+ preference.apply_to_client(parent, read_preference(preference.type))
- dat += "[features["ethereal_mark"]]"
- dat += "[random_locks["ethereal_mark"] ? "Unlock" : "Lock"] "
+// This is necessary because you can open the set preferences menu before
+// the atoms SS is done loading.
+INITIALIZE_IMMEDIATE(/atom/movable/screen/character_preview_view)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+/// A preview of a character for use in the preferences menu
+/atom/movable/screen/character_preview_view
+ name = "character_preview"
+ del_on_map_removal = FALSE
+ layer = GAME_PLANE
+ plane = GAME_PLANE
- if("pod_hair" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ /// The body that is displayed
+ var/mob/living/carbon/human/dummy/body
- dat += "
Head Vegitation Style
"
- dat += "[features["pod_hair"]]"
- dat += "[random_locks["pod_hair"] ? "Unlock" : "Lock"] "
- dat += "Change"
- dat += "[random_locks["hair"] ? "Unlock" : "Lock"] "
+ /// The preferences this refers to
+ var/datum/preferences/preferences
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ var/list/plane_masters = list()
- if("pod_flower" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
- dat += "
Head Flowers Color
"
- dat += "Change"
- dat += "[random_locks["facial"] ? "Unlock" : "Lock"] "
+ /// The client that is watching this view
+ var/client/client
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+/atom/movable/screen/character_preview_view/Initialize(mapload, datum/preferences/preferences, client/client)
+ . = ..()
- if("tail_human" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ assigned_map = "character_preview_[REF(src)]"
+ set_position(1, 1)
- dat += "
Tail
"
+ src.preferences = preferences
- dat += "[features["tail_human"]]"
- dat += "[random_locks["tail_human"] ? "Unlock" : "Lock"] "
+/atom/movable/screen/character_preview_view/Destroy()
+ QDEL_NULL(body)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/plane_master in plane_masters)
+ client?.screen -= plane_master
+ qdel(plane_master)
- if("ipc_screen" in pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ client?.clear_map(assigned_map)
+ client?.screen -= src
- dat += "
Screen Style
"
+ preferences?.character_preview_view = null
- dat += "[features["ipc_screen"]] "
+ client = null
+ plane_masters = null
+ preferences = null
- dat += "Change "
+ return ..()
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+/// Updates the currently displayed body
+/atom/movable/screen/character_preview_view/proc/update_body()
+ if (isnull(body))
+ create_body()
+ else
+ body.wipe_state()
+ appearance = preferences.render_new_preview_appearance(body)
- if("ipc_antenna" in pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/atom/movable/screen/character_preview_view/proc/create_body()
+ QDEL_NULL(body)
- dat += "
Antenna Style
"
+ body = new
- dat += "[features["ipc_antenna"]] "
+ // Without this, it doesn't show up in the menu
+ body.appearance_flags &= ~KEEP_TOGETHER
- dat += "Change "
+/// Registers the relevant map objects to a client
+/atom/movable/screen/character_preview_view/proc/register_to_client(client/client)
+ QDEL_LIST(plane_masters)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ src.client = client
- if("ipc_chassis" in pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ if (!client)
+ return
- dat += "
Chassis Style
"
-
- dat += "[features["ipc_chassis"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ears" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
Ears
"
+ for (var/plane_master_type in subtypesof(/atom/movable/screen/plane_master))
+ var/atom/movable/screen/plane_master/plane_master = new plane_master_type
+ plane_master.screen_loc = "[assigned_map]:CENTER"
+ client?.screen |= plane_master
- dat += "[features["ears"]]"
- dat += "[random_locks["ears"] ? "Unlock" : "Lock"] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("plasmaman_helmet" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "
"
+ var/savefile/savefile = new(path)
+ for (var/index in 1 to max_save_slots)
+ // It won't be updated in the savefile yet, so just read the name directly
+ if (index == default_slot)
+ profiles += read_preference(/datum/preference/name/real_name)
+ continue
+ savefile.cd = "/character[index]"
- if (1) // Game Preferences
- dat += "
"
- // yogs start - Donor features
- if (3) //Donator preferences
- dat += "
"
- dat += "
Donator Preferences
"
- if(is_donator(user.client))
- dat += "Quiet round:[(src.yogtoggles & QUIET_ROUND) ? "Yes" : "No"] "
- dat += "Wear fancy hat as borg: "
- dat += "[borg_hat ? "Yes" : "No"] "
- dat += "Fancy Hat: "
- ///This is the typepath of the donor's hat that they may choose to spawn with.
- var/typehat = donor_hat
- var/temp_hat = donor_hat ? (new typehat()) : "None selected"
- dat += "Pick [temp_hat] "
- if(donor_hat)
- qdel(temp_hat)
- dat += "Fancy Item: "
- ///Whatever item the donator has chosen to apply.
- var/typeitem = donor_item
- var/temp_item = donor_item ? (new typeitem()) : "None selected"
- dat += "Pick [temp_item] "
- if(donor_item)
- qdel(temp_item)
- dat += "Fancy PDA: "
- dat += "[GLOB.donor_pdas[donor_pda]] "
- dat += "Purrbation (Humans only) "
- dat += "[purrbation ? "Yes" : "No"] "
- else
- dat += "Donate here"
- dat += "
"
- // yogs end
- if (4) // Keybindings
- // Create an inverted list of keybindings -> key
- var/list/user_binds = list()
- for (var/key in key_bindings)
- for(var/kb_name in key_bindings[key])
- user_binds[kb_name] += list(key)
-
- var/list/kb_categories = list()
- // Group keybinds by category
- for (var/name in GLOB.keybindings_by_name)
- var/datum/keybinding/kb = GLOB.keybindings_by_name[name]
- kb_categories[kb.category] += list(kb)
-
- dat += ""
-
- for (var/category in kb_categories)
- dat += "
[category]
"
- for (var/i in kb_categories[category])
- var/datum/keybinding/kb = i
- if(!length(user_binds[kb.name]) || user_binds[kb.name][1] == "Unbound")
- dat += " Unbound"
- var/list/default_keys = hotkeys ? kb.hotkey_keys : kb.classic_keys
- if(LAZYLEN(default_keys))
- dat += "| Default: [default_keys.Join(", ")]"
- dat += " "
- else
- var/bound_key = user_binds[kb.name][1]
- dat += " [bound_key]"
- for(var/bound_key_index in 2 to length(user_binds[kb.name]))
- bound_key = user_binds[kb.name][bound_key_index]
- dat += " | [bound_key]"
- if(length(user_binds[kb.name]) < MAX_KEYS_PER_KEYBIND)
- dat += "| Add Secondary"
- var/list/default_keys = hotkeys ? kb.classic_keys : kb.hotkey_keys
- if(LAZYLEN(default_keys))
- dat += "| Default: [default_keys.Join(", ")]"
- dat += " "
-
- dat += "
", 350, 300)
- popup.set_content(HTML)
- popup.open(FALSE)
- onclose(user, "capturekeypress", src)
-
-/datum/preferences/proc/SetChoices(mob/user, limit = 17, list/splitJobs = list("Research Director", "Head of Personnel"), widthPerColumn = 295, height = 620)
- if(!SSjob)
- return
-
- //limit - The amount of jobs allowed per column. Defaults to 17 to make it look nice.
- //splitJobs - Allows you split the table by job. You can make different tables for each department by including their heads. Defaults to CE to make it look nice.
- //widthPerColumn - Screen's width for every column.
- //height - Screen's height.
+ profiles += name
- var/width = widthPerColumn
+ return profiles
- var/HTML = "
"
- if(SSjob.occupations.len <= 0)
- HTML += "The job SSticker is not yet finished creating jobs, please try again later"
- HTML += "
" // Easier to press up here.
- HTML += ""
- HTML += "
" // Table within a table for alignment, also allows you to easily add more colomns.
- HTML += "
"
- var/index = -1
-
- //The job before the current job. I only use this to get the previous jobs color when I'm filling in blank rows.
- var/datum/job/lastJob
-
- var/datum/job/overflow = SSjob.GetJob(SSjob.overflow_role)
-
- for(var/datum/job/job in sortList(SSjob.occupations, /proc/cmp_job_display_asc))
-
- index += 1
- if((index >= limit) || (job.title in splitJobs))
- width += widthPerColumn
- if((index < limit) && (lastJob != null))
- //If the cells were broken up by a job in the splitJob list then it will fill in the rest of the cells with
- //the last job's selection color. Creating a rather nice effect.
- for(var/i = 0, i < (limit - index), i += 1)
- HTML += "
"
- continue
- // yogs start - Donor features, quiet round
- if(((rank in GLOB.command_positions) || (rank in GLOB.nonhuman_positions)) && (src.yogtoggles & QUIET_ROUND))
- HTML += "[rank]
\[QUIET\]
"
- continue
- // yogs end
+/// Inverts the key_bindings list such that it can be used for key_bindings_by_key
+/datum/preferences/proc/get_key_bindings_by_key(list/key_bindings)
+ var/list/output = list()
- var/rank_display
- if(job.alt_titles)
- rank_display = "[GetPlayerAltTitle(job)]"
- else
- rank_display = span_dark("[rank]")
+ for (var/action in key_bindings)
+ for (var/key in key_bindings[action])
+ LAZYADD(output[key], action)
- if((rank in GLOB.command_positions) || (rank == "AI"))//Bold head jobs
- HTML += "[rank_display]"
- else
- HTML += rank_display
-
- HTML += "
"
- continue
+ for (var/preference_key in GLOB.preference_entries_by_key)
+ var/datum/preference/preference = GLOB.preference_entries_by_key[preference_key]
+ if (preference.is_randomizable() && preference.randomize_by_default)
+ default_randomization[preference_key] = RANDOM_ENABLED
- HTML += "[prefLevelLabel]"
- HTML += ""
+ return default_randomization
- for(var/i = 1, i < (limit - index), i += 1) // Finish the column so it is even
- HTML += "
 
 
"
- HTML += "
"
- HTML += "
"
- var/message = "Be an [SSjob.overflow_role] if preferences unavailable"
- if(joblessrole == BERANDOMJOB)
- message = "Get random job if preferences unavailable"
- else if(joblessrole == RETURNTOLOBBY)
- message = "Return to lobby if preferences unavailable"
- HTML += "
Left-click to add or remove quirks. You need negative quirks to have positive ones. \
- Quirks are applied at roundstart and cannot normally be removed.
", 900, 600) //no reason not to reuse the occupation window, as it's cleaner that way
- popup.set_window_options("can_close=0")
- popup.set_content(dat.Join())
- popup.open(FALSE)
+ return TRUE
/datum/preferences/proc/GetQuirkBalance()
var/bal = 0
@@ -1349,959 +641,6 @@ GLOBAL_LIST_EMPTY(preferences_datums)
sum++
return sum
-/datum/preferences/Topic(href, href_list, hsrc) //yeah, gotta do this I guess..
- . = ..()
- if(href_list["close"])
- var/client/C = usr.client
- if(C)
- C.clear_character_previews()
-
-/datum/preferences/proc/process_link(mob/user, list/href_list)
- // yogs start - Donor features
- if(href_list["preference"] == "donor")
- if(is_donator(user))
- var/client/C = (istype(user, /client)) ? user : user.client
- switch(href_list["task"])
- if("borghat")
- borg_hat = !borg_hat
- if("hat")
- C.custom_donator_item()
- if("item")
- C.custom_donator_item()
- if("quiet_round")
- yogtoggles ^= QUIET_ROUND
- if("pda")
- donor_pda = (donor_pda % GLOB.donor_pdas.len) + 1
- if("purrbation")
- purrbation = !purrbation
- else
- message_admins("EXPLOIT \[donor\]: [user] tried to access donor only functions (as a non-donor). Attempt made on \"[href_list["preference"]]\" -> \"[href_list["task"]]\".")
- // yogs end
- if(href_list["bancheck"])
- var/list/ban_details = is_banned_from_with_details(user.ckey, user.client.address, user.client.computer_id, href_list["bancheck"])
- var/admin = FALSE
- if(GLOB.permissions.admin_datums[user.ckey] || GLOB.permissions.deadmins[user.ckey])
- admin = TRUE
- for(var/i in ban_details)
- if(admin && !text2num(i["applies_to_admins"]))
- continue
- ban_details = i
- break //we only want to get the most recent ban's details
- if(ban_details && ban_details.len)
- var/expires = "This is a permanent ban."
- if(ban_details["expiration_time"])
- expires = " The ban is for [DisplayTimeText(text2num(ban_details["duration"]) MINUTES)] and expires on [ban_details["expiration_time"]] (server time)."
- to_chat(user, span_danger("You, or another user of this computer or connection ([ban_details["key"]]) is banned from playing [href_list["bancheck"]]. The ban reason is: [ban_details["reason"]] This ban (BanID #[ban_details["id"]]) was applied by [ban_details["admin_key"]] on [ban_details["bantime"]] during round ID [ban_details["round_id"]]. [expires]"))
- return
- if(href_list["preference"] == "job")
- switch(href_list["task"])
- if("close")
- user << browse(null, "window=mob_occupation")
- ShowChoices(user)
- if("reset")
- ResetJobs()
- SetChoices(user)
- if("random")
- switch(joblessrole)
- if(RETURNTOLOBBY)
- if(is_banned_from(user.ckey, SSjob.overflow_role))
- joblessrole = BERANDOMJOB
- else
- joblessrole = BEOVERFLOW
- if(BEOVERFLOW)
- joblessrole = BERANDOMJOB
- if(BERANDOMJOB)
- joblessrole = RETURNTOLOBBY
- SetChoices(user)
- if ("alt_title")
- var/datum/job/job = SSjob.GetJob(href_list["job"])
- if (job)
- var/choices = list(job.title) + job.alt_titles
- var/choice = input("Pick a title for [job.title].", "Character Generation", GetPlayerAltTitle(job)) as anything in choices | null
- if(choice)
- SetPlayerAltTitle(job, choice)
- SetChoices(user)
- if("setJobLevel")
- UpdateJobPreference(user, href_list["text"], text2num(href_list["level"]))
- else
- SetChoices(user)
- return 1
-
- else if(href_list["preference"] == "trait")
- switch(href_list["task"])
- if("close")
- user << browse(null, "window=mob_occupation")
- ShowChoices(user)
- if("update")
- var/quirk = href_list["trait"]
- if(!SSquirks.quirks[quirk])
- return
- for(var/V in SSquirks.quirk_blacklist) //V is a list
- var/list/L = V
- for(var/Q in all_quirks)
- if((quirk in L) && (Q in L) && !(Q == quirk)) //two quirks have lined up in the list of the list of quirks that conflict with each other, so return (see quirks.dm for more details)
- to_chat(user, span_danger("[quirk] is incompatible with [Q]."))
- return
- var/value = SSquirks.quirk_points[quirk] // The value of the chosen quirk.
- var/balance = GetQuirkBalance()
- if(quirk in all_quirks)
- if(balance + value < 0)
- to_chat(user, span_warning("Refunding this would cause you to go below your balance!"))
- return
- all_quirks -= quirk
- else
- var/positive_count = GetPositiveQuirkCount() // Yogs -- fixes weird behaviour when at max positive quirks
- if(positive_count > MAX_QUIRKS || (positive_count == MAX_QUIRKS && value > 0)) // Yogs
- to_chat(user, span_warning("You can't have more than [MAX_QUIRKS] positive quirks!"))
- return
- if(balance - value < 0)
- to_chat(user, span_warning("You don't have enough balance to gain this quirk!"))
- return
- all_quirks += quirk
- SetQuirks(user)
- if("reset")
- all_quirks = list()
- SetQuirks(user)
- else
- SetQuirks(user)
- return TRUE
-
- switch(href_list["task"])
- if("random")
- switch(href_list["preference"])
- if("name")
- real_name = pref_species.random_name(gender,1)
- if("age")
- age = rand(AGE_MIN, AGE_MAX)
- if("hair")
- hair_color = random_short_color()
- if("hair_style")
- hair_style = random_hair_style(gender)
- if("facial")
- facial_hair_color = random_short_color()
- if("facial_hair_style")
- facial_hair_style = random_facial_hair_style(gender)
- if("underwear")
- underwear = random_underwear(gender)
- if("undershirt")
- undershirt = random_undershirt(gender)
- if("socks")
- socks = random_socks()
- if(BODY_ZONE_PRECISE_EYES)
- eye_color = random_eye_color()
- if("s_tone")
- skin_tone = random_skin_tone()
- if("bag")
- backbag = pick(GLOB.backbaglist)
- if("all")
- random_character(gender)
- if("lock")
- switch(href_list["preference"])
- if("u_all")
- for(var/i in random_locks)
- random_locks[i] = 0;
- if("l_all")
- random_locks = list(
- "gender" = gender,
- "mcolor" = 1,
- "ethcolor" = 1,
- "pretcolor" = 1,
- "tail_lizard" = 1,
- "tail_human" = 1,
- "wings" = 1,
- "snout" = 1,
- "horns" = 1,
- "ears" = 1,
- "frills" = 1,
- "spines" = 1,
- "body_markings" = 1,
- "legs" = 1,
- "caps" = 1,
- "moth_wings" = 1,
- "tail_polysmorph" = 1,
- "teeth" = 1,
- "dome" = 1,
- "dorsal_tubes" = 1,
- "ethereal_mark" = 1,
- )
- if("gender")
- random_locks["random_locks"] = gender
- else
- random_locks[href_list["preference"]] = !random_locks[href_list["preference"]]
-
- if("input")
-
- if(href_list["preference"] in GLOB.preferences_custom_names)
- ask_for_custom_name(user,href_list["preference"])
-
-
- switch(href_list["preference"])
- if("ghostform")
- if(unlock_content)
- var/new_form = input(user, "Thanks for supporting BYOND - Choose your ghostly form:","Thanks for supporting BYOND",null) as null|anything in GLOB.ghost_forms
- if(new_form)
- ghost_form = new_form
- if("ghostorbit")
- if(unlock_content)
- var/new_orbit = input(user, "Thanks for supporting BYOND - Choose your ghostly orbit:","Thanks for supporting BYOND", null) as null|anything in GLOB.ghost_orbits
- if(new_orbit)
- ghost_orbit = new_orbit
-
- if("ghostaccs")
- var/new_ghost_accs = tgui_alert(usr,"Do you want your ghost to show full accessories where possible, hide accessories but still use the directional sprites where possible, or also ignore the directions and stick to the default sprites?",,list(GHOST_ACCS_FULL_NAME, GHOST_ACCS_DIR_NAME, GHOST_ACCS_NONE_NAME))
- switch(new_ghost_accs)
- if(GHOST_ACCS_FULL_NAME)
- ghost_accs = GHOST_ACCS_FULL
- if(GHOST_ACCS_DIR_NAME)
- ghost_accs = GHOST_ACCS_DIR
- if(GHOST_ACCS_NONE_NAME)
- ghost_accs = GHOST_ACCS_NONE
-
- if("ghostothers")
- var/new_ghost_others = tgui_alert(usr,"Do you want the ghosts of others to show up as their own setting, as their default sprites or always as the default white ghost?",,list(GHOST_OTHERS_THEIR_SETTING_NAME, GHOST_OTHERS_DEFAULT_SPRITE_NAME, GHOST_OTHERS_SIMPLE_NAME))
- switch(new_ghost_others)
- if(GHOST_OTHERS_THEIR_SETTING_NAME)
- ghost_others = GHOST_OTHERS_THEIR_SETTING
- if(GHOST_OTHERS_DEFAULT_SPRITE_NAME)
- ghost_others = GHOST_OTHERS_DEFAULT_SPRITE
- if(GHOST_OTHERS_SIMPLE_NAME)
- ghost_others = GHOST_OTHERS_SIMPLE
-
- if("name")
- var/new_name = input(user, "Choose your character's name:", "Character Preference") as text|null
- if(new_name)
- new_name = reject_bad_name(new_name, pref_species.allow_numbers_in_name)
- if(new_name)
- real_name = new_name
- else
- to_chat(user, "Invalid name. Your name should be at least 2 and at most [MAX_NAME_LEN] characters long. It may only contain the characters A-Z, a-z, -, ' and .")
-
- if("age")
- var/new_age = input(user, "Choose your character's age:\n([AGE_MIN]-[AGE_MAX])", "Character Preference") as num|null
- if(new_age)
- age = max(min( round(text2num(new_age)), AGE_MAX),AGE_MIN)
-
- if("cycle_background")
- background = next_list_item(background, background_options)
-
- if("hair")
- var/new_hair = input(user, "Choose your character's hair colour:", "Character Preference","#"+hair_color) as color|null
- if(new_hair)
- hair_color = sanitize_hexcolor(new_hair)
-
- if("hair_style")
- var/new_hair_style
- if(gender == MALE)
- new_hair_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in GLOB.hair_styles_male_list
- else if(gender == FEMALE)
- new_hair_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in GLOB.hair_styles_female_list
- else
- new_hair_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in GLOB.hair_styles_list
- if(new_hair_style)
- hair_style = new_hair_style
-
- if("next_hair_style")
- if (gender == MALE)
- hair_style = next_list_item(hair_style, GLOB.hair_styles_male_list)
- else if(gender == FEMALE)
- hair_style = next_list_item(hair_style, GLOB.hair_styles_female_list)
- else
- hair_style = next_list_item(hair_style, GLOB.hair_styles_list)
-
- if("previous_hair_style")
- if (gender == MALE)
- hair_style = previous_list_item(hair_style, GLOB.hair_styles_male_list)
- else if(gender == FEMALE)
- hair_style = previous_list_item(hair_style, GLOB.hair_styles_female_list)
- else
- hair_style = previous_list_item(hair_style, GLOB.hair_styles_list)
-
- if("facial")
- var/new_facial = input(user, "Choose your character's facial-hair colour:", "Character Preference","#"+facial_hair_color) as color|null
- if(new_facial)
- facial_hair_color = sanitize_hexcolor(new_facial)
-
- if("facial_hair_style")
- var/new_facial_hair_style
- if(gender == MALE)
- new_facial_hair_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in GLOB.facial_hair_styles_male_list
- else if(gender == FEMALE)
- new_facial_hair_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in GLOB.facial_hair_styles_female_list
- else
- new_facial_hair_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in GLOB.facial_hair_styles_list
- if(new_facial_hair_style)
- facial_hair_style = new_facial_hair_style
-
- if("next_facehair_style")
- if (gender == MALE)
- facial_hair_style = next_list_item(facial_hair_style, GLOB.facial_hair_styles_male_list)
- else if(gender == FEMALE)
- facial_hair_style = next_list_item(facial_hair_style, GLOB.facial_hair_styles_female_list)
- else
- facial_hair_style = next_list_item(facial_hair_style, GLOB.facial_hair_styles_list)
-
- if("previous_facehair_style")
- if (gender == MALE)
- facial_hair_style = previous_list_item(facial_hair_style, GLOB.facial_hair_styles_male_list)
- else if (gender == FEMALE)
- facial_hair_style = previous_list_item(facial_hair_style, GLOB.facial_hair_styles_female_list)
- else
- facial_hair_style = previous_list_item(facial_hair_style, GLOB.facial_hair_styles_list)
-
- if("hair_gradient")
- var/new_hair_gradient_color = input(user, "Choose your character's hair gradient colour:", "Character Preference","#"+features["gradientcolor"]) as color|null
- if(new_hair_gradient_color)
- features["gradientcolor"] = sanitize_hexcolor(new_hair_gradient_color)
-
- if("hair_gradient_style")
- var/new_gradient_style
- new_gradient_style = input(user, "Choose your character's hair gradient style:", "Character Preference") as null|anything in GLOB.hair_gradients_list
- if(new_gradient_style)
- features["gradientstyle"] = new_gradient_style
-
- if("next_hair_gradient_style")
- features["gradientstyle"] = next_list_item(features["gradientstyle"], GLOB.hair_gradients_list)
-
- if("previous_hair_gradient_style")
- features["gradientstyle"] = previous_list_item(features["gradientstyle"], GLOB.hair_gradients_list)
-
- if("underwear")
- var/new_underwear
- if(gender == MALE)
- new_underwear = input(user, "Choose your character's underwear:", "Character Preference") as null|anything in GLOB.underwear_m
- else if(gender == FEMALE)
- new_underwear = input(user, "Choose your character's underwear:", "Character Preference") as null|anything in GLOB.underwear_f
- else
- new_underwear = input(user, "Choose your character's underwear:", "Character Preference") as null|anything in GLOB.underwear_list
- if(new_underwear)
- underwear = new_underwear
-
- if("undershirt")
- var/new_undershirt
- if(gender == MALE)
- new_undershirt = input(user, "Choose your character's undershirt:", "Character Preference") as null|anything in GLOB.undershirt_m
- else if(gender == FEMALE)
- new_undershirt = input(user, "Choose your character's undershirt:", "Character Preference") as null|anything in GLOB.undershirt_f
- else
- new_undershirt = input(user, "Choose your character's undershirt:", "Character Preference") as null|anything in GLOB.undershirt_list
- if(new_undershirt)
- undershirt = new_undershirt
-
- if("socks")
- var/new_socks
- new_socks = input(user, "Choose your character's socks:", "Character Preference") as null|anything in GLOB.socks_list
- if(new_socks)
- socks = new_socks
-
- if(BODY_ZONE_PRECISE_EYES)
- var/new_eyes = input(user, "Choose your character's eye colour:", "Character Preference","#"+eye_color) as color|null
- if(new_eyes)
- eye_color = sanitize_hexcolor(new_eyes)
-
- if("species")
-
- var/result = input(user, "Select a species", "Species Selection") as null|anything in (is_mentor(user) ? (GLOB.roundstart_races + GLOB.mentor_races) : GLOB.roundstart_races)
-
- if(result)
- var/newtype = GLOB.species_list[result]
- pref_species = new newtype()
- //Now that we changed our species, we must verify that the mutant colour is still allowed.
- var/temp_hsv = RGBtoHSV(features["mcolor"])
- if(features["mcolor"] == "#000" || (!(MUTCOLORS_PARTSONLY in pref_species.species_traits) && ReadHSV(temp_hsv)[3] < ReadHSV("#3a3a3a")[3]))
- features["mcolor"] = pref_species.default_color
- var/CQ
- for(var/Q in all_quirks)
- var/quirk_type = SSquirks.quirks[Q]
- var/datum/quirk/quirk = new quirk_type(no_init = TRUE)
- CQ = quirk.check_quirk(src)
- if(CQ)
- all_quirks -= Q
- to_chat(user, span_danger(CQ))
- if(GetQuirkBalance() < 0)
- to_chat(user, span_danger("Your quirk balance is now negative, and you will need to re-balance it or all quirks will be disabled."))
-
- if("mcolor")
- var/new_mutantcolor = input(user, "Choose your character's alien/mutant color:", "Character Preference","#"+features["mcolor"]) as color|null
- if(new_mutantcolor)
- var/temp_hsv = RGBtoHSV(new_mutantcolor)
- if(new_mutantcolor == "#000000")
- features["mcolor"] = pref_species.default_color
- else if((MUTCOLORS_PARTSONLY in pref_species.species_traits) || ReadHSV(temp_hsv)[3] >= ReadHSV("#3a3a3a")[3]) // mutantcolors must be bright, but only if they affect the skin
- features["mcolor"] = sanitize_hexcolor(new_mutantcolor)
- else
- to_chat(user, span_danger("Invalid color. Your color is not bright enough."))
-
- if("ethcolor")
- var/new_etherealcolor = input(user, "Choose your ethereal color", "Character Preference") as null|anything in GLOB.color_list_ethereal
- if(new_etherealcolor)
- features["ethcolor"] = GLOB.color_list_ethereal[new_etherealcolor]
-
- if("pretcolor")
- var/new_preterniscolor = input(user, "Choose your preternis color", "Character Preference") as null|anything in GLOB.color_list_preternis
- if(new_preterniscolor)
- features["pretcolor"] = GLOB.color_list_preternis[new_preterniscolor]
-
- if("tail_lizard")
- var/new_tail
- new_tail = input(user, "Choose your character's tail:", "Character Preference") as null|anything in GLOB.tails_list_lizard
- if(new_tail)
- features["tail_lizard"] = new_tail
-
- if("tail_polysmorph")
- var/new_tail
- new_tail = input(user, "Choose your character's tail:", "Character Preference") as null|anything in GLOB.tails_list_polysmorph
- if(new_tail)
- features["tail_polysmorph"] = new_tail
-
- if("tail_human")
- var/new_tail
- new_tail = input(user, "Choose your character's tail:", "Character Preference") as null|anything in GLOB.tails_list_human
- if(new_tail)
- features["tail_human"] = new_tail
-
- if("snout")
- var/new_snout
- new_snout = input(user, "Choose your character's snout:", "Character Preference") as null|anything in GLOB.snouts_list
- if(new_snout)
- features["snout"] = new_snout
-
- if("horns")
- var/new_horns
- new_horns = input(user, "Choose your character's horns:", "Character Preference") as null|anything in GLOB.horns_list
- if(new_horns)
- features["horns"] = new_horns
-
- if("ears")
- var/new_ears
- new_ears = input(user, "Choose your character's ears:", "Character Preference") as null|anything in GLOB.ears_list
- if(new_ears)
- features["ears"] = new_ears
-
- if("wings")
- var/new_wings
- new_wings = input(user, "Choose your character's wings:", "Character Preference") as null|anything in GLOB.r_wings_list
- if(new_wings)
- features["wings"] = new_wings
-
- if("frills")
- var/new_frills
- new_frills = input(user, "Choose your character's frills:", "Character Preference") as null|anything in GLOB.frills_list
- if(new_frills)
- features["frills"] = new_frills
-
- if("spines")
- var/new_spines
- new_spines = input(user, "Choose your character's spines:", "Character Preference") as null|anything in GLOB.spines_list
- if(new_spines)
- features["spines"] = new_spines
-
- if("body_markings")
- var/new_body_markings
- new_body_markings = input(user, "Choose your character's body markings:", "Character Preference") as null|anything in GLOB.body_markings_list
- if(new_body_markings)
- features["body_markings"] = new_body_markings
-
- if("legs")
- var/new_legs
- new_legs = input(user, "Choose your character's legs:", "Character Preference") as null|anything in GLOB.legs_list
- if(new_legs)
- features["legs"] = new_legs
-
- if("moth_wings")
- var/new_moth_wings
- new_moth_wings = input(user, "Choose your character's wings:", "Character Preference") as null|anything in GLOB.moth_wings_list
- if(new_moth_wings)
- features["moth_wings"] = new_moth_wings
-
- if("teeth")
- var/new_teeth
- new_teeth = input(user, "Choose your character's teeth:", "Character Preference") as null|anything in GLOB.teeth_list
- if(new_teeth)
- features["teeth"] = new_teeth
-
- if("dome")
- var/new_dome
- new_dome = input(user, "Choose your character's dome:", "Character Preference") as null|anything in GLOB.dome_list
- if(new_dome)
- features["dome"] = new_dome
-
- if("dorsal_tubes")
- var/new_dorsal_tubes
- new_dorsal_tubes = input(user, "Choose if your character has dorsal tubes:", "Character Preference") as null|anything in GLOB.dorsal_tubes_list
- if(new_dorsal_tubes)
- features["dorsal_tubes"] = new_dorsal_tubes
-
- if("ethereal_mark")
- var/new_ethereal_mark
- new_ethereal_mark = input(user, "Choose if your character has a facial mark", "Character Preference") as null|anything in GLOB.ethereal_mark_list
- if(new_ethereal_mark)
- features["ethereal_mark"] = new_ethereal_mark
-
- if("pod_hair")
- var/new_pod_hair
- new_pod_hair = input(user, "Choose the style of your head vegitation", "Character Preference") as null|anything in GLOB.pod_hair_list
- if(new_pod_hair)
- features["pod_hair"] = new_pod_hair
- features["pod_flower"] = new_pod_hair
- if("pod_hair_color")
- var/new_hair = input(user, "Choose your character's \"hair\" colour:", "Character Preference","#"+hair_color) as color|null
- if(new_hair)
- var/temp_hsv = RGBtoHSV(new_hair)
- if(new_hair == "#000000")
- hair_color = pref_species.default_color
- to_chat(user, span_danger("Invalid \"hair\" color. Your color is not bright enough."))
- else if(ReadHSV(temp_hsv)[3] >= ReadHSV("#3a3a3a")[3]) // mutantcolors must be bright, but only if they affect the skin
- hair_color = sanitize_hexcolor(new_hair)
- else
- to_chat(user, span_danger("Invalid \"hair\" color. Your color is not bright enough."))
- if("pod_flower_color")
- var/new_facial = input(user, "Choose your character's head flower colour:", "Character Preference","#"+facial_hair_color) as color|null
- if(new_facial)
- var/temp_hsv = RGBtoHSV(new_facial)
- if(new_facial == "#000000")
- facial_hair_color = pref_species.default_color
- to_chat(user, span_danger("Invalid \"hair\" color. Your color is not bright enough."))
- else if(ReadHSV(temp_hsv)[3] >= ReadHSV("#3a3a3a")[3]) // mutantcolors must be bright, but only if they affect the skin
- facial_hair_color = sanitize_hexcolor(new_facial)
- else
- to_chat(user, span_danger("Invalid head flower color. Your color is not bright enough."))
- if("ipc_screen")
- var/new_ipc_screen
-
- new_ipc_screen = input(user, "Choose your character's screen:", "Character Preference") as null|anything in GLOB.ipc_screens_list
-
- if(new_ipc_screen)
- features["ipc_screen"] = new_ipc_screen
-
- if("ipc_antenna")
- var/new_ipc_antenna
-
- new_ipc_antenna = input(user, "Choose your character's antenna:", "Character Preference") as null|anything in GLOB.ipc_antennas_list
-
- if(new_ipc_antenna)
- features["ipc_antenna"] = new_ipc_antenna
-
- if("ipc_chassis")
- var/new_ipc_chassis
-
- new_ipc_chassis = input(user, "Choose your character's chassis:", "Character Preference") as null|anything in GLOB.ipc_chassis_list
-
- if(new_ipc_chassis)
- features["ipc_chassis"] = new_ipc_chassis
-
- if("plasmaman_helmet")
- var/new_plasmaman_helmet
-
- new_plasmaman_helmet = input(user, "Choose your character's plasmaman helmet style:", "Character Preference") as null|anything in GLOB.plasmaman_helmet_list
- if(new_plasmaman_helmet)
- features["plasmaman_helmet"] = new_plasmaman_helmet
-
- if("s_tone")
- var/new_s_tone = input(user, "Choose your character's skin-tone:", "Character Preference") as null|anything in GLOB.skin_tones
- if(new_s_tone)
- skin_tone = new_s_tone
-
- if("ooccolor")
- var/new_ooccolor = input(user, "Choose your OOC colour:", "Game Preference",ooccolor) as color|null
- if(new_ooccolor)
- ooccolor = new_ooccolor
-
- if("asaycolor")
- var/new_asaycolor = input(user, "Choose your ASAY color:", "Game Preference",asaycolor) as color|null
- if(new_asaycolor)
- asaycolor = new_asaycolor
-
- if("bag")
- var/new_backbag = input(user, "Choose your character's style of bag:", "Character Preference") as null|anything in GLOB.backbaglist
- if(new_backbag)
- backbag = new_backbag
-
- if("suit")
- jumpsuit_style = jumpsuit_style == PREF_SUIT ? PREF_SKIRT : PREF_SUIT
-
- if("uplink_loc")
- var/new_loc = input(user, "Choose your character's traitor uplink spawn location:", "Character Preference") as null|anything in GLOB.uplink_spawn_loc_list
- if(new_loc)
- uplink_spawn_loc = new_loc
-
- if("ai_core_icon")
- var/ai_core_icon = input(user, "Choose your preferred AI core display screen:", "AI Core Display Screen Selection") as null|anything in GLOB.ai_core_display_screens - "Portrait"
- if(ai_core_icon)
- preferred_ai_core_display = ai_core_icon
-
- if("sec_dept")
- var/department = input(user, "Choose your preferred security department:", "Security Departments") as null|anything in GLOB.security_depts_prefs
- if(department)
- prefered_security_department = department
-
- if("eng_dept")
- var/department = input(user, "Choose your preferred engineering department:", "Engineering Departments") as null|anything in GLOB.engineering_depts_prefs
- if(department)
- prefered_engineering_department = department
-
- if("accent")
- var/aksent = input(user,"Choose your accent:","Available Accents") as null|anything in (assoc_list_strip_value(strings("accents.json", "accent_file_names", directory = "strings/accents")) + "None")
- if(aksent)
- if(aksent == "None")
- accent = initial(accent)
- else
- accent = aksent
- if ("preferred_map")
- var/maplist = list()
- var/default = "Default"
- if (config.defaultmap)
- default += " ([config.defaultmap.map_name])"
- for (var/M in config.maplist)
- var/datum/map_config/VM = config.maplist[M]
- if(!VM.votable)
- continue
- var/friendlyname = "[VM.map_name] "
- if (VM.voteweight <= 0)
- friendlyname += " (disabled)"
- maplist[friendlyname] = VM.map_name
- maplist[default] = null
- var/pickedmap = input(user, "Choose your preferred map. This will be used to help weight random map selection.", "Character Preference") as null|anything in maplist
- if (pickedmap)
- preferred_map = maplist[pickedmap]
-
- if ("clientfps")
- var/desiredfps = input(user, "Choose your desired fps. (0 = synced with server tick rate (currently:[world.fps]))", "Character Preference", clientfps) as null|num
- if (!isnull(desiredfps))
- clientfps = desiredfps
- parent.fps = desiredfps
- if("ui")
- var/pickedui = input(user, "Choose your UI style.", "Character Preference", UI_style) as null|anything in GLOB.available_ui_styles
- if(pickedui)
- UI_style = pickedui
- if (parent && parent.mob && parent.mob.hud_used)
- parent.mob.hud_used.update_ui_style(ui_style2icon(UI_style))
- if("pda_style")
- var/pickedPDAStyle = input(user, "Choose your PDA style.", "Character Preference", pda_style) as null|anything in GLOB.pda_styles
- if(pickedPDAStyle)
- pda_style = pickedPDAStyle
- if("pda_color")
- var/pickedPDAColor = input(user, "Choose your PDA Interface color.", "Character Preference",pda_color) as color|null
- if(pickedPDAColor)
- pda_color = pickedPDAColor
- if("pda_theme")
- var/pickedPDATheme = input(user, "Choose your PDA Interface theme.", "Character Preference", pda_theme) as null|anything in GLOB.pda_themes
- if(pickedPDATheme)
- pda_theme = pickedPDATheme
- if("id_in_pda")
- id_in_pda = !id_in_pda
- if("skillcape")
- var/list/selectablecapes = list()
- var/max_eligable = TRUE
- for(var/id in GLOB.skillcapes)
- var/datum/skillcape/A = GLOB.skillcapes[id]
- if(!A.job)
- continue
- if(user.client.prefs.exp[A.job] >= A.minutes)
- selectablecapes += A
- else
- max_eligable = FALSE
- if(max_eligable)
- selectablecapes += GLOB.skillcapes["max"]
-
- if(!selectablecapes.len)
- to_chat(user, "You have no availiable skillcapes!")
- return
- var/pickedskillcape = input(user, "Choose your Skillcape.", "Character Preference") as null|anything in (list("None") + selectablecapes)
- if(!pickedskillcape)
- return
- if(pickedskillcape == "None")
- skillcape_id = "None"
- else
- var/datum/skillcape/cape = pickedskillcape
- skillcape_id = cape.id
- if("flare")
- flare = !flare
- if("map")
- map = !map
- if("bar_choice")
- var/pickedbar = input(user, "Choose your bar.", "Character Preference", bar_choice) as null|anything in (GLOB.potential_box_bars|"Random")
- if(!pickedbar)
- return
- bar_choice = pickedbar
- if ("max_chat_length")
- var/desiredlength = input(user, "Choose the max character length of shown Runechat messages. Valid range is 1 to [CHAT_MESSAGE_MAX_LENGTH] (default: [initial(max_chat_length)]))", "Character Preference", max_chat_length) as null|num
- if (!isnull(desiredlength))
- max_chat_length = clamp(desiredlength, 1, CHAT_MESSAGE_MAX_LENGTH)
- if("alternative_announcers")
- disable_alternative_announcers = !disable_alternative_announcers
- if("balloon_alerts")
- disable_balloon_alerts = !disable_balloon_alerts
- else
- switch(href_list["preference"])
- if("publicity")
- if(unlock_content)
- toggles ^= MEMBER_PUBLIC
- if("gender")
- var/pickedGender = input(user, "Choose your gender.", "Character Preference", gender) as null|anything in friendlyGenders
- if(pickedGender && friendlyGenders[pickedGender] != gender)
- gender = friendlyGenders[pickedGender]
- underwear = random_underwear(gender)
- undershirt = random_undershirt(gender)
- socks = random_socks()
- facial_hair_style = random_facial_hair_style(gender)
- hair_style = random_hair_style(gender)
-
- if("hotkeys")
- hotkeys = !hotkeys
- if(hotkeys)
- winset(user, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED]")
- else
- winset(user, null, "input.focus=true input.background-color=[COLOR_INPUT_DISABLED]")
-
- if("keybindings_capture")
- var/datum/keybinding/kb = GLOB.keybindings_by_name[href_list["keybinding"]]
- var/old_key = href_list["old_key"]
- CaptureKeybinding(user, kb, old_key)
- return
-
- if("keybindings_set")
- var/kb_name = href_list["keybinding"]
- if(!kb_name)
- user << browse(null, "window=capturekeypress")
- ShowChoices(user)
- return
-
- var/clear_key = text2num(href_list["clear_key"])
- var/old_key = href_list["old_key"]
- if(clear_key)
- if(key_bindings[old_key])
- key_bindings[old_key] -= kb_name
- LAZYADD(key_bindings["Unbound"], kb_name)
- if(!length(key_bindings[old_key]))
- key_bindings -= old_key
- user << browse(null, "window=capturekeypress")
- user.client.set_macros()
- save_preferences()
- ShowChoices(user)
- return
-
- var/new_key = uppertext(href_list["key"])
- var/AltMod = text2num(href_list["alt"]) ? "Alt" : ""
- var/CtrlMod = text2num(href_list["ctrl"]) ? "Ctrl" : ""
- var/ShiftMod = text2num(href_list["shift"]) ? "Shift" : ""
- var/numpad = text2num(href_list["numpad"]) ? "Numpad" : ""
- // var/key_code = text2num(href_list["key_code"])
-
- if(GLOB._kbMap[new_key])
- new_key = GLOB._kbMap[new_key]
-
- var/full_key
- switch(new_key)
- if("Alt")
- full_key = "[new_key][CtrlMod][ShiftMod]"
- if("Ctrl")
- full_key = "[AltMod][new_key][ShiftMod]"
- if("Shift")
- full_key = "[AltMod][CtrlMod][new_key]"
- else
- full_key = "[AltMod][CtrlMod][ShiftMod][numpad][new_key]"
- if(kb_name in key_bindings[full_key]) //We pressed the same key combination that was already bound here, so let's remove to re-add and re-sort.
- key_bindings[full_key] -= kb_name
- if(key_bindings[old_key])
- key_bindings[old_key] -= kb_name
- if(!length(key_bindings[old_key]))
- key_bindings -= old_key
- key_bindings[full_key] += list(kb_name)
- key_bindings[full_key] = sortList(key_bindings[full_key])
-
- user << browse(null, "window=capturekeypress")
- user.client.set_macros()
- save_preferences()
-
- if("keybindings_reset")
- var/choice = tgalert(user, "Would you prefer 'hotkey' or 'classic' defaults?", "Setup keybindings", "Hotkey", "Classic", "Cancel")
- if(choice == "Cancel")
- ShowChoices(user)
- return
- hotkeys = (choice == "Hotkey")
- key_bindings = (hotkeys) ? deepCopyList(GLOB.hotkey_keybinding_list_by_key) : deepCopyList(GLOB.classic_keybinding_list_by_key)
- user.client.set_macros()
- if("chat_on_map")
- chat_on_map = !chat_on_map
- if("see_chat_non_mob")
- see_chat_non_mob = !see_chat_non_mob
- if("see_rc_emotes")
- see_rc_emotes = !see_rc_emotes
- if("action_buttons")
- buttons_locked = !buttons_locked
- if("tgui_fancy")
- tgui_fancy = !tgui_fancy
- if("tgui_lock")
- tgui_lock = !tgui_lock
- if("winflash")
- windowflashing = !windowflashing
-
- //here lies the badmins
- if("hear_adminhelps")
- user.client.toggleadminhelpsound()
- if("hear_prayers")
- user.client.toggle_prayer_sound()
- if("announce_login")
- user.client.toggleannouncelogin()
- if("combohud_lighting")
- toggles ^= COMBOHUD_LIGHTING
- if("toggle_dead_chat")
- user.client.deadchat()
- if("toggle_radio_chatter")
- user.client.toggle_hear_radio()
- if("toggle_split_admin_tabs")
- extra_toggles ^= SPLIT_ADMIN_TABS
- if("toggle_fast_mc_refresh")
- extra_toggles ^= FAST_MC_REFRESH
- if("toggle_prayers")
- user.client.toggleprayers()
- if("toggle_deadmin_always")
- toggles ^= DEADMIN_ALWAYS
- if("toggle_deadmin_antag")
- toggles ^= DEADMIN_ANTAGONIST
- if("toggle_deadmin_head")
- toggles ^= DEADMIN_POSITION_HEAD
- if("toggle_deadmin_security")
- toggles ^= DEADMIN_POSITION_SECURITY
- if("toggle_deadmin_silicon")
- toggles ^= DEADMIN_POSITION_SILICON
- if("toggle_deadmin_critical")
- toggles ^= DEADMIN_POSITION_CRITICAL
-
-
- if("be_special")
- var/be_special_type = href_list["be_special_type"]
- if(be_special_type in be_special)
- be_special -= be_special_type
- else
- be_special += be_special_type
-
- if("name")
- be_random_name = !be_random_name
-
- if("all")
- be_random_body = !be_random_body
-
- if("persistent_scars")
- persistent_scars = !persistent_scars
-
- if("clear_scars")
- var/path = "data/player_saves/[user.ckey[1]]/[user.ckey]/scars.sav"
- fdel(path)
- to_chat(user, span_notice("All scar slots cleared."))
-
- if("hear_midis")
- toggles ^= SOUND_MIDI
-
- if("lobby_music")
- toggles ^= SOUND_LOBBY
- if((toggles & SOUND_LOBBY) && user.client && isnewplayer(user))
- user.client.playtitlemusic()
- else
- user.stop_sound_channel(CHANNEL_LOBBYMUSIC)
-
- if("ghost_ears")
- chat_toggles ^= CHAT_GHOSTEARS
-
- if("ghost_sight")
- chat_toggles ^= CHAT_GHOSTSIGHT
-
- if("ghost_whispers")
- chat_toggles ^= CHAT_GHOSTWHISPER
-
- if("ghost_radio")
- chat_toggles ^= CHAT_GHOSTRADIO
-
- if("ghost_pda")
- chat_toggles ^= CHAT_GHOSTPDA
-
- if("income_pings")
- chat_toggles ^= CHAT_BANKCARD
-
- if("pull_requests")
- chat_toggles ^= CHAT_PULLR
-
- if("allow_midround_antag")
- toggles ^= MIDROUND_ANTAG
-
- if("parallaxup")
- parallax = WRAP(parallax + 1, PARALLAX_INSANE, PARALLAX_DISABLE + 1)
- if (parent && parent.mob && parent.mob.hud_used)
- parent.mob.hud_used.update_parallax_pref(parent.mob)
-
- if("parallaxdown")
- parallax = WRAP(parallax - 1, PARALLAX_INSANE, PARALLAX_DISABLE + 1)
- if (parent && parent.mob && parent.mob.hud_used)
- parent.mob.hud_used.update_parallax_pref(parent.mob)
-
- if("ambientocclusion")
- ambientocclusion = !ambientocclusion
- if(parent && parent.screen && parent.screen.len)
- var/atom/movable/screen/plane_master/game_world/PM = locate(/atom/movable/screen/plane_master/game_world) in parent.screen
- PM.backdrop(parent.mob)
-
- if("auto_fit_viewport")
- auto_fit_viewport = !auto_fit_viewport
- if(auto_fit_viewport && parent)
- parent.fit_viewport()
-
- if("widescreenpref")
- widescreenpref = !widescreenpref
- user.client.view_size.setDefault(getScreenSize(widescreenpref))
-
- if("pixel_size")
- switch(pixel_size)
- if(PIXEL_SCALING_AUTO)
- pixel_size = PIXEL_SCALING_1X
- if(PIXEL_SCALING_1X)
- pixel_size = PIXEL_SCALING_1_2X
- if(PIXEL_SCALING_1_2X)
- pixel_size = PIXEL_SCALING_2X
- if(PIXEL_SCALING_2X)
- pixel_size = PIXEL_SCALING_3X
- if(PIXEL_SCALING_3X)
- pixel_size = PIXEL_SCALING_AUTO
- user.client.view_size.apply() //Let's winset() it so it actually works
-
- if("scaling_method")
- switch(scaling_method)
- if(SCALING_METHOD_NORMAL)
- scaling_method = SCALING_METHOD_DISTORT
- if(SCALING_METHOD_DISTORT)
- scaling_method = SCALING_METHOD_BLUR
- if(SCALING_METHOD_BLUR)
- scaling_method = SCALING_METHOD_NORMAL
- user.client.view_size.setZoomMode()
-
-
- if("save")
- save_preferences()
- save_character()
-
- if("load")
- load_preferences()
- load_character()
-
- if("changeslot")
- if(!load_character(text2num(href_list["num"])))
- random_character()
- real_name = random_unique_name(gender)
- save_character()
-
- if("tab")
- if (href_list["tab"])
- current_tab = text2num(href_list["tab"])
-
- if("mood")
- yogtoggles ^= PREF_MOOD
-
- if("moodtailwagging")
- mood_tail_wagging = !mood_tail_wagging
- // yogs end
-
- ShowChoices(user)
- return 1
-
/datum/preferences/proc/copy_to(mob/living/carbon/human/character, icon_updates = 1, roundstart_checks = TRUE)
if(be_random_name)
real_name = pref_species.random_name(gender)
@@ -2345,7 +684,6 @@ GLOBAL_LIST_EMPTY(preferences_datums)
character.backbag = backbag
character.jumpsuit_style = jumpsuit_style
- character.id_in_pda = id_in_pda
var/datum/species/chosen_species
chosen_species = pref_species.type
@@ -2369,40 +707,3 @@ GLOBAL_LIST_EMPTY(preferences_datums)
character.update_body()
character.update_hair()
character.update_body_parts()
-
-/datum/preferences/proc/get_default_name(name_id)
- switch(name_id)
- if("human")
- return random_unique_name()
- if("ai")
- return pick(GLOB.ai_names)
- if("cyborg")
- return DEFAULT_CYBORG_NAME
- if("clown")
- return pick(GLOB.clown_names)
- if("mime")
- return pick(GLOB.mime_names)
- if("religion")
- return DEFAULT_RELIGION
- if("deity")
- return DEFAULT_DEITY
- return random_unique_name()
-
-/datum/preferences/proc/ask_for_custom_name(mob/user,name_id)
- var/namedata = GLOB.preferences_custom_names[name_id]
- if(!namedata)
- return
-
- var/raw_name = input(user, "Choose your character's [namedata["qdesc"]]:","Character Preference") as text|null
- if(!raw_name)
- if(namedata["allow_null"])
- custom_names[name_id] = get_default_name(name_id)
- else
- return
- else
- var/sanitized_name = reject_bad_name(raw_name,namedata["allow_numbers"])
- if(!sanitized_name)
- to_chat(user, "Invalid name. Your name should be at least 2 and at most [MAX_NAME_LEN] characters long. It may only contain the characters A-Z, a-z,[namedata["allow_numbers"] ? ",0-9," : ""] -, ' and .")
- return
- else
- custom_names[name_id] = sanitized_name
diff --git a/code/modules/client/preferences/README.md b/code/modules/client/preferences/README.md
new file mode 100644
index 000000000000..42f30d868ef6
--- /dev/null
+++ b/code/modules/client/preferences/README.md
@@ -0,0 +1,419 @@
+# Preferences (by Mothblocks)
+
+This does not contain all the information on specific values--you can find those as doc-comments in relevant paths, such as `/datum/preference`. Rather, this gives you an overview for creating *most* preferences, and getting your foot in the door to create more advanced ones.
+
+## Anatomy of a preference (A.K.A. how do I make one?)
+
+Most preferences consist of two parts:
+
+1. A `/datum/preference` type.
+2. A tgui representation in a TypeScript file.
+
+Every `/datum/preference` requires these three values be set:
+1. `category` - See [Categories](#Categories).
+2. `savefile_key` - The value which will be saved in the savefile. This will also be the identifier for tgui.
+3. `savefile_identifier` - Whether or not this is a character specific preference (`PREFERENCE_CHARACTER`) or one that affects the player (`PREFERENCE_PLAYER`). As an example: hair color is `PREFERENCE_CHARACTER` while your UI settings are `PREFERENCE_PLAYER`, since they do not change between characters.
+
+For the tgui representation, most preferences will create a `.tsx` file in `tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/`. If your preference is a character preference, make a new file in `character_preferences`. Otherwise, put it in `game_preferences`. The filename does not matter, and this file can hold multiple relevant preferences if you would like.
+
+From here, you will want to write code resembling:
+
+```ts
+import { Feature } from "../base";
+export const savefile_key_here: Feature = {
+ name: "Preference Name Here",
+ component: Component,
+ // Necessary for game preferences, unused for others
+ category: "CATEGORY",
+ // Optional, only shown in game preferences
+ description: "This preference will blow your mind!",
+}
+```
+
+`T` and `Component` depend on the type of preference you're making. Here are all common examples...
+
+## Numeric preferences
+
+Examples include age and FPS.
+
+A numeric preference derives from `/datum/preference/numeric`.
+
+```dm
+/datum/preference/numeric/legs
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "legs"
+ minimum = 1
+ maximum = 8
+```
+
+You can optionally provide a `step` field. This value is 1 by default, meaning only integers are accepted.
+
+Your `.tsx` file would look like:
+
+```ts
+import { Feature, FeatureNumberInput } from "../base";
+export const legs: Feature = {
+ name: "Legs",
+ component: FeatureNumberInput,
+}
+```
+
+## Toggle preferences
+
+Examples include enabling tooltips.
+
+```dm
+/datum/preference/toggle/enable_breathing
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "enable_breathing"
+ // Optional, TRUE by default
+ default_value = FALSE
+```
+
+Your `.tsx` file would look like:
+
+```ts
+import { CheckboxInput, FeatureToggle } from "../base";
+export const enable_breathing: Feature = {
+ name: "Enable breathing",
+ component: CheckboxInput,
+}
+```
+
+## Choiced preferences
+A choiced preference is one where the only options are in a distinct few amount of choices. Examples include skin tone, shirt, and UI style.
+
+To create one, derive from `/datum/preference/choiced`.
+
+```dm
+/datum/preference/choiced/favorite_drink
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "favorite_drink"
+```
+
+Now we need to tell the game what the choices are. We do this by overriding `init_possible_values()`. This will return a list of possible options.
+
+```dm
+/datum/preference/choiced/favorite_drink/init_possible_values()
+ return list(
+ "Milk",
+ "Cola",
+ "Water",
+ )
+```
+
+Your `.tsx` file would then look like:
+
+```tsx
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+export const favorite_drink: FeatureChoiced = {
+ name: "Favorite drink",
+ component: FeatureDropdownInput,
+};
+```
+
+This will create a dropdown input for your preference.
+
+### Choiced preferences - Icons
+Choiced preferences can generate icons. This is how the clothing/species preferences work, for instance. However, if we just want a basic dropdown input with icons, it would look like this:
+
+```dm
+/datum/preference/choiced/favorite_drink
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "favorite_drink"
+ should_generate_icons = TRUE // NEW! This is necessary.
+// Instead of returning a flat list, this now returns an assoc list
+// of values to icons.
+/datum/preference/choiced/favorite_drink/init_possible_values()
+ return list(
+ "Milk" = icon('drinks.dmi', "milk"),
+ "Cola" = icon('drinks.dmi', "cola"),
+ "Water" = icon('drinks.dmi', "water"),
+ )
+```
+
+Then, change your `.tsx` file to look like:
+
+```tsx
+import { FeatureChoiced, FeatureIconnedDropdownInput } from "../base";
+export const favorite_drink: FeatureChoiced = {
+ name: "Favorite drink",
+ component: FeatureIconnedDropdownInput,
+};
+```
+
+### Choiced preferences - Display names
+Sometimes the values you want to save in code aren't the same as the ones you want to display. You can specify display names to change this.
+
+The only thing you will add is "compiled data".
+
+```dm
+/datum/preference/choiced/favorite_drink/compile_constant_data()
+ var/list/data = ..()
+ // An assoc list of values to display names
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = list(
+ "Milk" = "Delicious Milk",
+ "Cola" = "Crisp Cola",
+ "Water" = "Plain Ol' Water",
+ )
+ return data
+```
+
+Your `.tsx` file does not change. The UI will figure it out for you!
+
+## Color preferences
+These refer to colors, such as your OOC color. When read, these values will be given as 6 hex digits, *without* the pound sign.
+
+```dm
+/datum/preference/color/eyeliner_color
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "eyeliner_color"
+```
+
+Your `.tsx` file would look like:
+
+```ts
+import { FeatureColorInput, Feature } from "../base";
+export const eyeliner_color: Feature = {
+ name: "Eyeliner color",
+ component: FeatureColorInput,
+};
+```
+
+## Name preferences
+These refer to an alternative name. Examples include AI names and backup human names.
+
+These exist in `code/modules/client/preferences/names.dm`.
+
+These do not need a `.ts` file, and will be created in the UI automatically.
+
+```dm
+/datum/preference/name/doctor
+ savefile_key = "doctor_name"
+ // The name on the UI
+ explanation = "Doctor name"
+ // This groups together with anything else with the same group
+ group = "medicine"
+ // Optional, if specified the UI will show this name actively
+ // when the player is a medical doctor.
+ relevant_job = /datum/job/medical_doctor
+```
+
+## Making your preference do stuff
+
+There are a handful of procs preferences can use to act on their own:
+
+```dm
+/// Apply this preference onto the given client.
+/// Called when the savefile_identifier == PREFERENCE_PLAYER.
+/datum/preference/proc/apply_to_client(client/client, value)
+/// Fired when the preference is updated.
+/// Calls apply_to_client by default, but can be overridden.
+/datum/preference/proc/apply_to_client_updated(client/client, value)
+/// Apply this preference onto the given human.
+/// Must be overriden by subtypes.
+/// Called when the savefile_identifier == PREFERENCE_CHARACTER.
+/datum/preference/proc/apply_to_human(mob/living/carbon/human/target, value)
+```
+
+For example, `/datum/preference/numeric/age` contains:
+
+```dm
+/datum/preference/numeric/age/apply_to_human(mob/living/carbon/human/target, value)
+ target.age = value
+```
+
+If your preference is `PREFERENCE_CHARACTER`, it MUST override `apply_to_human`, even if just to immediately `return`.
+
+You can also read preferences directly with `preferences.read_preference(/datum/preference/type/here)`, which will return the stored value.
+
+## Categories
+Every preference needs to be in a `category`. These can be found in `code/__DEFINES/preferences.dm`.
+
+```dm
+/// These will be shown in the character sidebar, but at the bottom.
+#define PREFERENCE_CATEGORY_FEATURES "features"
+/// Any preferences that will show to the sides of the character in the setup menu.
+#define PREFERENCE_CATEGORY_CLOTHING "clothing"
+/// Preferences that will be put into the 3rd list, and are not contextual.
+#define PREFERENCE_CATEGORY_NON_CONTEXTUAL "non_contextual"
+/// Will be put under the game preferences window.
+#define PREFERENCE_CATEGORY_GAME_PREFERENCES "game_preferences"
+/// These will show in the list to the right of the character preview.
+#define PREFERENCE_CATEGORY_SECONDARY_FEATURES "secondary_features"
+/// These are preferences that are supplementary for main features,
+/// such as hair color being affixed to hair.
+#define PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES "supplemental_features"
+```
+
+
+
+> SECONDARY_FEATURES or NON_CONTEXTUAL?
+Secondary features tend to be species specific. Non contextual features shouldn't change much from character to character.
+
+## Default values and randomization
+
+There are three procs to be aware of in regards to this topic:
+
+- `create_default_value()`. This is used when a value deserializes improperly or when a new character is created.
+- `create_informed_default_value(datum/preferences/preferences)` - Used for more complicated default values, like how names require the gender. Will call `create_default_value()` by default.
+- `create_random_value(datum/preferences/preferences)` - Explicitly used for random values, such as when a character is being randomized.
+
+`create_default_value()` in most preferences will create a random value. If this is a problem (like how default characters should always be human), you can override `create_default_value()`. By default (without overriding `create_random_value`), random values are just default values.
+
+## Advanced - Server data
+
+As previewed in [the display names implementation](#Choiced-preferences---Display-names), there exists a `compile_constant_data()` proc you can override.
+
+Compiled data is used wherever the server needs to give the client some value it can't figure out on its own. Skin tones use this to tell the client what colors they represent, for example.
+
+Compiled data is sent to the `serverData` field in the `FeatureValueProps`.
+
+## Advanced - Creating your own tgui component
+
+If you have good knowledge with tgui (especially TypeScript), you'll be able to create your own component to represent preferences.
+
+The `component` field in a feature accepts __any__ component that accepts `FeatureValueProps`.
+
+This will give you the fields:
+
+```ts
+act: typeof sendAct,
+featureId: string,
+handleSetValue: (newValue: TSending) => void,
+serverData: TServerData | undefined,
+shrink?: boolean,
+value: TReceiving,
+```
+
+`act` is the same as the one you get from `useBackend`.
+
+`featureId` is the savefile_key of the feature.
+
+`handleSetValue` is a function that, when called, will tell the server the new value, as well as changing the value immediately locally.
+
+`serverData` is the [server data](#Advanced---Server-data), if it has been fetched yet (and exists).
+
+`shrink` is whether or not the UI should appear smaller. This is only used for supplementary features.
+
+`value` is the current value, could be predicted (meaning that the value was changed locally, but has not yet reached the server).
+
+For a basic example of how this can look, observe `CheckboxInput`:
+
+```tsx
+export const CheckboxInput = (
+ props: FeatureValueProps
+) => {
+ return ( {
+ props.handleSetValue(!props.value);
+ }}
+ />);
+};
+```
+
+## Advanced - Middleware
+A `/datum/preference_middleware` is a way to inject your own data at specific points, as well as hijack actions.
+
+Middleware can hijack actions by specifying `action_delegations`:
+
+```dm
+/datum/preference_middleware/congratulations
+ action_delegations = list(
+ "congratulate_me" = .proc/congratulate_me,
+ )
+/datum/preference_middleware/congratulations/proc/congratulate_me(list/params, mob/user)
+ to_chat(user, span_notice("Wow, you did a great job learning about middleware!"))
+ return TRUE
+```
+
+Middleware can inject its own data at several points, such as providing new UI assets, compiled data (used by middleware such as quirks to tell the client what quirks exist), etc. Look at `code/modules/client/preferences/middleware/_middleware.dm` for full information.
+
+---
+
+## Antagonists
+
+In order to make an antagonist selectable, you must do a few things:
+
+1. Your antagonist needs an icon.
+2. Your antagonist must be in a Dynamic ruleset. The ruleset must specify the antagonist as its `antag_flag`.
+3. Your antagonist needs a file in `tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/filename.ts`. This file name MUST be the `antag_flag` of your ruleset, with nothing but letters remaining (e.g. "Nuclear Operative" -> `nuclearoperative`).
+4. Add it to `special_roles`.
+
+## Creating icons
+
+If you are satisfied with your icon just being a dude with some clothes, then you can specify `preview_outfit` in your `/datum/antagonist`.
+
+Space Ninja, for example, looks like:
+
+```dm
+/datum/antagonist/ninja
+ preview_outift = /datum/outfit/ninja
+```
+
+However, if you want to get creative, you can override `/get_preview_icon()`. This proc should return an icon of size `ANTAGONIST_PREVIEW_ICON_SIZE`x`ANTAGONIST_PREVIEW_ICON_SIZE`.
+
+There are some helper procs you can use as well. `render_preview_outfit(outfit_type)` will take an outfit and give you an icon of someone wearing those clothes. `finish_preview_outfit` will, given an icon, resize it appropriately and zoom in on the head. Note that this will look bad on anything that isn't a human, so if you have a non-human antagonist (such as sentient disease), just run `icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)`.
+
+For inspiration, here is changeling's:
+
+```dm
+/datum/antagonist/changeling/get_preview_icon()
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/changeling)
+ var/icon/split_icon = render_preview_outfit(/datum/outfit/job/engineer)
+ final_icon.Shift(WEST, world.icon_size / 2)
+ final_icon.Shift(EAST, world.icon_size / 2)
+ split_icon.Shift(EAST, world.icon_size / 2)
+ split_icon.Shift(WEST, world.icon_size / 2)
+ final_icon.Blend(split_icon, ICON_OVERLAY)
+ return finish_preview_icon(final_icon)
+```
+
+...which creates:
+
+
+
+## Creating the tgui representation
+
+In the `.ts` file you created earlier, you must now give the information of your antagonist. For reference, this is the changeling's:
+
+```ts
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+const Changeling: Antagonist = {
+ key: "changeling", // This must be the same as your filename
+ name: "Changeling",
+ description: [
+ multiline`
+ A highly intelligent alien predator that is capable of altering their
+ shape to flawlessly resemble a human.
+ `,
+ multiline`
+ Transform yourself or others into different identities, and buy from an
+ arsenal of biological weaponry with the DNA you collect.
+ `,
+ ],
+ category: Category.Roundstart, // Category.Roundstart, Category.Midround, or Category.Latejoin
+};
+export default Changeling;
+```
+
+## Readying the Dynamic ruleset
+
+You already need to create a Dynamic ruleset, so in order to get your antagonist recognized, you just need to specify `antag_flag`. This must be unique per ruleset.
+
+Two other values to note are `antag_flag_override` and `antag_preference`.
+
+`antag_flag_override` exists for cases where you want the banned antagonist to be separate from `antag_flag`. As an example: roundstart, midround, and latejoin traitors have separate `antag_flag`, but all have `antag_flag_override = ROLE_TRAITOR`. This is because admins want to ban a player from Traitor altogether, not specific rulesets.
+
+If `antag_preference` is set, it will refer to that preference instead of `antag_flag`. This is used for clown operatives, which we want to be on the same preference as standard nuke ops, but must specify a unique `antag_flag` for.
+
+## Updating special_roles
+
+In `code/__DEFINES/role_preferences.dm` (the same place you'll need to make your ROLE_\* defined), simply add your antagonist to the `special_roles` assoc list. The key is your ROLE, the value is the number of days since your first game in order to play as that antagonist.
diff --git a/code/modules/client/preferences/_preference.dm b/code/modules/client/preferences/_preference.dm
new file mode 100644
index 000000000000..9cbacf4ab089
--- /dev/null
+++ b/code/modules/client/preferences/_preference.dm
@@ -0,0 +1,532 @@
+// Priorities must be in order!
+/// The default priority level
+#define PREFERENCE_PRIORITY_DEFAULT 1
+
+/// The priority at which species runs, needed for external organs to apply properly.
+#define PREFERENCE_PRIORITY_SPECIES 2
+
+/// The priority at which gender is determined, needed for proper randomization.
+#define PREFERENCE_PRIORITY_GENDER 3
+
+/// The priority at which names are decided, needed for proper randomization.
+#define PREFERENCE_PRIORITY_NAMES 4
+
+/// The maximum preference priority, keep this updated, but don't use it for `priority`.
+#define MAX_PREFERENCE_PRIORITY PREFERENCE_PRIORITY_NAMES
+
+/// For choiced preferences, this key will be used to set display names in constant data.
+#define CHOICED_PREFERENCE_DISPLAY_NAMES "display_names"
+
+/// For main feature preferences, this key refers to a feature considered supplemental.
+/// For instance, hair color being supplemental to hair.
+#define SUPPLEMENTAL_FEATURE_KEY "supplemental_feature"
+
+/// An assoc list list of types to instantiated `/datum/preference` instances
+GLOBAL_LIST_INIT(preference_entries, init_preference_entries())
+
+/// An assoc list of preference entries by their `savefile_key`
+GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key())
+
+/proc/init_preference_entries()
+ var/list/output = list()
+ for (var/datum/preference/preference_type as anything in subtypesof(/datum/preference))
+ if (initial(preference_type.abstract_type) == preference_type)
+ continue
+ output[preference_type] = new preference_type
+ return output
+
+/proc/init_preference_entries_by_key()
+ var/list/output = list()
+ for (var/datum/preference/preference_type as anything in subtypesof(/datum/preference))
+ if (initial(preference_type.abstract_type) == preference_type)
+ continue
+ output[initial(preference_type.savefile_key)] = GLOB.preference_entries[preference_type]
+ return output
+
+/// Returns a flat list of preferences in order of their priority
+/proc/get_preferences_in_priority_order()
+ var/list/preferences[MAX_PREFERENCE_PRIORITY]
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ LAZYADD(preferences[preference.priority], preference)
+
+ var/list/flattened = list()
+ for (var/index in 1 to MAX_PREFERENCE_PRIORITY)
+ flattened += preferences[index]
+ return flattened
+
+/// Represents an individual preference.
+/datum/preference
+ /// The key inside the savefile to use.
+ /// This is also sent to the UI.
+ /// Once you pick this, don't change it.
+ var/savefile_key
+
+ /// The category of preference, for use by the PreferencesMenu.
+ /// This isn't used for anything other than as a key for UI data.
+ /// It is up to the PreferencesMenu UI itself to interpret it.
+ var/category = "misc"
+
+ /// Do not instantiate if type matches this.
+ var/abstract_type = /datum/preference
+
+ /// What savefile should this preference be read from?
+ /// Valid values are PREFERENCE_CHARACTER and PREFERENCE_PLAYER.
+ /// See the documentation in [code/__DEFINES/preferences.dm].
+ var/savefile_identifier
+
+ /// The priority of when to apply this preference.
+ /// Used for when you need to rely on another preference.
+ var/priority = PREFERENCE_PRIORITY_DEFAULT
+
+ /// If set, will be available to randomize, but only if the preference
+ /// is for PREFERENCE_CHARACTER.
+ var/can_randomize = TRUE
+
+ /// If randomizable (PREFERENCE_CHARACTER and can_randomize), whether
+ /// or not to enable randomization by default.
+ /// This doesn't mean it'll always be random, but rather if a player
+ /// DOES have random body on, will this already be randomized?
+ var/randomize_by_default = TRUE
+
+ /// If the selected species has this in its /datum/species/mutant_bodyparts,
+ /// will show the feature as selectable.
+ var/relevant_mutant_bodypart = null
+
+ /// If the selected species has this in its /datum/species/species_traits,
+ /// will show the feature as selectable.
+ var/relevant_species_trait = null
+
+/// Called on the saved input when retrieving.
+/// Also called by the value sent from the user through UI. Do not trust it.
+/// Input is the value inside the savefile, output is to tell other code
+/// what the value is.
+/// This is useful either for more optimal data saving or for migrating
+/// older data.
+/// Must be overridden by subtypes.
+/// Can return null if no value was found.
+/datum/preference/proc/deserialize(input, datum/preferences/preferences)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`deserialize()` was not implemented on [type]!")
+
+/// Called on the input while saving.
+/// Input is the current value, output is what to save in the savefile.
+/datum/preference/proc/serialize(input)
+ SHOULD_NOT_SLEEP(TRUE)
+ return input
+
+/// Produce a default, potentially random value for when no value for this
+/// preference is found in the savefile.
+/// Either this or create_informed_default_value must be overriden by subtypes.
+/datum/preference/proc/create_default_value()
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`create_default_value()` was not implemented on [type]!")
+
+/// Produce a default, potentially random value for when no value for this
+/// preference is found in the savefile.
+/// Unlike create_default_value(), will provide the preferences object if you
+/// need to use it.
+/// If not overriden, will call create_default_value() instead.
+/datum/preference/proc/create_informed_default_value(datum/preferences/preferences)
+ return create_default_value()
+
+/// Produce a random value for the purposes of character randomization.
+/// Will just create a default value by default.
+/datum/preference/proc/create_random_value(datum/preferences/preferences)
+ return create_informed_default_value(preferences)
+
+/// Returns whether or not a preference can be randomized.
+/datum/preference/proc/is_randomizable()
+ SHOULD_NOT_OVERRIDE(TRUE)
+ return savefile_identifier == PREFERENCE_CHARACTER && can_randomize
+
+/// Given a savefile, return either the saved data or an acceptable default.
+/// This will write to the savefile if a value was not found with the new value.
+/datum/preference/proc/read(savefile/savefile, datum/preferences/preferences)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ var/value
+
+ if (!isnull(savefile))
+ READ_FILE(savefile[savefile_key], value)
+
+ if (isnull(value))
+ return null
+ else
+ return deserialize(value, preferences)
+
+/// Given a savefile, writes the inputted value.
+/// Returns TRUE for a successful application.
+/// Return FALSE if it is invalid.
+/datum/preference/proc/write(savefile/savefile, value)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ if (!is_valid(value))
+ return FALSE
+
+ if (!isnull(savefile))
+ WRITE_FILE(savefile[savefile_key], serialize(value))
+
+ return TRUE
+
+/// Apply this preference onto the given client.
+/// Called when the savefile_identifier == PREFERENCE_PLAYER.
+/datum/preference/proc/apply_to_client(client/client, value)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ return
+
+/// Fired when the preference is updated.
+/// Calls apply_to_client by default, but can be overridden.
+/datum/preference/proc/apply_to_client_updated(client/client, value)
+ SHOULD_NOT_SLEEP(TRUE)
+ apply_to_client(client, value)
+
+/// Apply this preference onto the given human.
+/// Must be overriden by subtypes.
+/// Called when the savefile_identifier == PREFERENCE_CHARACTER.
+/datum/preference/proc/apply_to_human(mob/living/carbon/human/target, value)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`apply_to_human()` was not implemented for [type]!")
+
+/// Returns which savefile to use for a given savefile identifier
+/datum/preferences/proc/get_savefile_for_savefile_identifier(savefile_identifier)
+ RETURN_TYPE(/savefile)
+
+ if (!parent)
+ return null
+
+ switch (savefile_identifier)
+ if (PREFERENCE_CHARACTER)
+ return character_savefile
+ if (PREFERENCE_PLAYER)
+ if (!game_savefile)
+ game_savefile = new /savefile(path)
+ game_savefile.cd = "/"
+ return game_savefile
+ else
+ CRASH("Unknown savefile identifier [savefile_identifier]")
+
+/// Read a /datum/preference type and return its value.
+/// This will write to the savefile if a value was not found with the new value.
+/datum/preferences/proc/read_preference(preference_type)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_type]
+ if (isnull(preference_entry))
+ var/extra_info = ""
+
+ // Current initializing subsystem is important to know because it might be a problem with
+ // things running pre-assets-initialization.
+ if (!isnull(Master.current_initializing_subsystem))
+ extra_info = "Info was attempted to be retrieved while [Master.current_initializing_subsystem] was initializing."
+
+ CRASH("Preference type `[preference_type]` is invalid! [extra_info]")
+
+ if (preference_type in value_cache)
+ return value_cache[preference_type]
+
+ var/value = preference_entry.read(get_savefile_for_savefile_identifier(preference_entry.savefile_identifier), src)
+ if (isnull(value))
+ value = preference_entry.create_informed_default_value(src)
+ if (write_preference(preference_entry, value))
+ return value
+ else
+ CRASH("Couldn't write the default value for [preference_type] (received [value])")
+ value_cache[preference_type] = value
+ return value
+
+/// Set a /datum/preference entry.
+/// Returns TRUE for a successful preference application.
+/// Returns FALSE if it is invalid.
+/datum/preferences/proc/write_preference(datum/preference/preference, preference_value)
+ var/savefile = get_savefile_for_savefile_identifier(preference.savefile_identifier)
+ var/new_value = preference.deserialize(preference_value, src)
+ var/success = preference.write(savefile, new_value)
+ if (success)
+ value_cache[preference.type] = new_value
+ return success
+
+/// Will perform an update on the preference, but not write to the savefile.
+/// This will, for instance, update the character preference view.
+/// Performs sanity checks.
+/datum/preferences/proc/update_preference(datum/preference/preference, preference_value)
+ if (!preference.is_accessible(src))
+ return FALSE
+
+ var/new_value = preference.deserialize(preference_value, src)
+ var/success = preference.write(null, new_value)
+
+ if (!success)
+ return FALSE
+
+ recently_updated_keys |= preference.type
+ value_cache[preference.type] = new_value
+
+ if (preference.savefile_identifier == PREFERENCE_PLAYER)
+ preference.apply_to_client_updated(parent, read_preference(preference.type))
+ else
+ character_preview_view?.update_body()
+
+ return TRUE
+
+/// Checks that a given value is valid.
+/// Must be overriden by subtypes.
+/// Any type can be passed through.
+/datum/preference/proc/is_valid(value)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`is_valid()` was not implemented for [type]!")
+
+/// Returns data to be sent to users in the menu
+/datum/preference/proc/compile_ui_data(mob/user, value)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ return serialize(value)
+
+/// Returns data compiled into the preferences JSON asset
+/datum/preference/proc/compile_constant_data()
+ SHOULD_NOT_SLEEP(TRUE)
+
+ return null
+
+/// Returns whether or not this preference is accessible.
+/// If FALSE, will not show in the UI and will not be editable (by update_preference).
+/datum/preference/proc/is_accessible(datum/preferences/preferences)
+ SHOULD_CALL_PARENT(TRUE)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ if (!isnull(relevant_mutant_bodypart) || !isnull(relevant_species_trait))
+ var/species_type = preferences.read_preference(/datum/preference/choiced/species)
+
+ var/datum/species/species = new species_type
+ if (!(savefile_key in species.get_features()))
+ return FALSE
+
+ if (!should_show_on_page(preferences.current_window))
+ return FALSE
+
+ return TRUE
+
+/// Returns whether or not, given the PREFERENCE_TAB_*, this preference should
+/// appear.
+/datum/preference/proc/should_show_on_page(preference_tab)
+ var/is_on_character_page = preference_tab == PREFERENCE_TAB_CHARACTER_PREFERENCES
+ var/is_character_preference = savefile_identifier == PREFERENCE_CHARACTER
+ return is_on_character_page == is_character_preference
+
+/// A preference that is a choice of one option among a fixed set.
+/// Used for preferences such as clothing.
+/datum/preference/choiced
+ /// If this is TRUE, icons will be generated.
+ /// This is necessary for if your `init_possible_values()` override
+ /// returns an assoc list of names to atoms/icons.
+ var/should_generate_icons = FALSE
+
+ var/list/cached_values
+
+ /// If the preference is a main feature (PREFERENCE_CATEGORY_FEATURES or PREFERENCE_CATEGORY_CLOTHING)
+ /// this is the name of the feature that will be presented.
+ var/main_feature_name
+
+ abstract_type = /datum/preference/choiced
+
+/// Returns a list of every possible value.
+/// The first time this is called, will run `init_values()`.
+/// Return value can be in the form of:
+/// - A flat list of raw values, such as list(MALE, FEMALE, PLURAL).
+/// - An assoc list of raw values to atoms/icons.
+/datum/preference/choiced/proc/get_choices()
+ // Override `init_values()` instead.
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ if (isnull(cached_values))
+ cached_values = init_possible_values()
+ ASSERT(cached_values.len)
+
+ return cached_values
+
+/// Returns a list of every possible value, serialized.
+/// Return value can be in the form of:
+/// - A flat list of serialized values, such as list(MALE, FEMALE, PLURAL).
+/// - An assoc list of serialized values to atoms/icons.
+/datum/preference/choiced/proc/get_choices_serialized()
+ // Override `init_values()` instead.
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ var/list/serialized_choices = list()
+ var/choices = get_choices()
+
+ if (should_generate_icons)
+ for (var/choice in choices)
+ serialized_choices[serialize(choice)] = choices[choice]
+ else
+ for (var/choice in choices)
+ serialized_choices += serialize(choice)
+
+ return serialized_choices
+
+/// Returns a list of every possible value.
+/// This must be overriden by `/datum/preference/choiced` subtypes.
+/// Return value can be in the form of:
+/// - A flat list of raw values, such as list(MALE, FEMALE, PLURAL).
+/// - An assoc list of raw values to atoms/icons, in which case
+/// icons will be generated.
+/datum/preference/choiced/proc/init_possible_values()
+ CRASH("`init_possible_values()` was not implemented for [type]!")
+
+/datum/preference/choiced/is_valid(value)
+ return value in get_choices()
+
+/datum/preference/choiced/deserialize(input, datum/preferences/preferences)
+ return sanitize_inlist(input, get_choices(), create_default_value())
+
+/datum/preference/choiced/create_default_value()
+ return pick(get_choices())
+
+/datum/preference/choiced/compile_constant_data()
+ var/list/data = list()
+
+ var/list/choices = list()
+
+ for (var/choice in get_choices())
+ choices += choice
+
+ data["choices"] = choices
+
+ if (should_generate_icons)
+ var/list/icons = list()
+
+ for (var/choice in choices)
+ icons[choice] = get_spritesheet_key(choice)
+
+ data["icons"] = icons
+
+ if (!isnull(main_feature_name))
+ data["name"] = main_feature_name
+
+ return data
+
+/// A preference that represents an RGB color of something, crunched down to 3 hex numbers.
+/// Was used heavily in the past, but doesn't provide as much range and only barely conserves space.
+/datum/preference/color_legacy
+ abstract_type = /datum/preference/color_legacy
+
+/datum/preference/color_legacy/deserialize(input, datum/preferences/preferences)
+ return sanitize_hexcolor(input)
+
+/datum/preference/color_legacy/create_default_value()
+ return random_short_color()
+
+/datum/preference/color_legacy/is_valid(value)
+ var/static/regex/is_legacy_color = regex(@"^[0-9a-fA-F]{3}$")
+ return findtext(value, is_legacy_color)
+
+/// A preference that represents an RGB color of something.
+/// Will give the value as 6 hex digits, without a hash.
+/datum/preference/color
+ abstract_type = /datum/preference/color
+
+/datum/preference/color/deserialize(input, datum/preferences/preferences)
+ return sanitize_color(input)
+
+/datum/preference/color/create_default_value()
+ return random_color()
+
+/datum/preference/color/is_valid(value)
+ return findtext(value, GLOB.is_color)
+
+/// Takes an assoc list of names to /datum/sprite_accessory and returns a value
+/// fit for `/datum/preference/init_possible_values()`
+/proc/possible_values_for_sprite_accessory_list(list/datum/sprite_accessory/sprite_accessories)
+ var/list/possible_values = list()
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+ if (istype(sprite_accessory))
+ possible_values[name] = icon(sprite_accessory.icon, sprite_accessory.icon_state)
+ else
+ // This means it didn't have an icon state
+ possible_values[name] = icon('icons/mob/landmarks.dmi', "x")
+ return possible_values
+
+/// Takes an assoc list of names to /datum/sprite_accessory and returns a value
+/// fit for `/datum/preference/init_possible_values()`
+/// Different from `possible_values_for_sprite_accessory_list` in that it takes a list of layers
+/// such as BEHIND, FRONT, and ADJ.
+/// It also takes a "body part name", such as body_markings, moth_wings, etc
+/// They are expected to be in order from lowest to top.
+/proc/possible_values_for_sprite_accessory_list_for_body_part(
+ list/datum/sprite_accessory/sprite_accessories,
+ body_part,
+ list/layers,
+)
+ var/list/possible_values = list()
+
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+ if(sprite_accessory.locked)
+ continue
+
+ var/icon/final_icon
+
+ for (var/layer in layers)
+ var/icon/icon = icon(sprite_accessory.icon, "m_[body_part]_[sprite_accessory.icon_state]_[layer]")
+
+ if (isnull(final_icon))
+ final_icon = icon
+ else
+ final_icon.Blend(icon, ICON_OVERLAY)
+
+ possible_values[name] = final_icon
+
+ return possible_values
+
+/// A numeric preference with a minimum and maximum value
+/datum/preference/numeric
+ /// The minimum value
+ var/minimum
+
+ /// The maximum value
+ var/maximum
+
+ /// The step of the number, such as 1 for integers or 0.5 for half-steps.
+ var/step = 1
+
+ abstract_type = /datum/preference/numeric
+
+/datum/preference/numeric/deserialize(input, datum/preferences/preferences)
+ return sanitize_float(input, minimum, maximum, step, create_default_value())
+
+/datum/preference/numeric/serialize(input)
+ return sanitize_float(input, minimum, maximum, step, create_default_value())
+
+/datum/preference/numeric/create_default_value()
+ return rand(minimum, maximum)
+
+/datum/preference/numeric/is_valid(value)
+ return isnum(value) && value >= minimum && value <= maximum
+
+/datum/preference/numeric/compile_constant_data()
+ return list(
+ "minimum" = minimum,
+ "maximum" = maximum,
+ "step" = step,
+ )
+
+/// A prefernece whose value is always TRUE or FALSE
+/datum/preference/toggle
+ abstract_type = /datum/preference/toggle
+
+ /// The default value of the toggle, if create_default_value is not specified
+ var/default_value = TRUE
+
+/datum/preference/toggle/create_default_value()
+ return default_value
+
+/datum/preference/toggle/deserialize(input, datum/preferences/preferences)
+ return !!input
+
+/datum/preference/toggle/is_valid(value)
+ return value == TRUE || value == FALSE
diff --git a/code/modules/client/preferences/admin.dm b/code/modules/client/preferences/admin.dm
new file mode 100644
index 000000000000..9cc1026d473c
--- /dev/null
+++ b/code/modules/client/preferences/admin.dm
@@ -0,0 +1,41 @@
+/datum/preference/color/asay_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "asaycolor"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/color/asay_color/create_default_value()
+ return DEFAULT_ASAY_COLOR
+
+/datum/preference/color/asay_color/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return is_admin(preferences.parent) && CONFIG_GET(flag/allow_admin_asaycolor)
+
+/// What outfit to equip when spawning as a briefing officer for an ERT
+/datum/preference/choiced/brief_outfit
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "brief_outfit"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/brief_outfit/deserialize(input, datum/preferences/preferences)
+ var/path = text2path(input)
+ if (!ispath(path, /datum/outfit))
+ return create_default_value()
+
+ return path
+
+/datum/preference/choiced/brief_outfit/serialize(input)
+ return "[input]"
+
+/datum/preference/choiced/brief_outfit/create_default_value()
+ return /datum/outfit/centcom/commander
+
+/datum/preference/choiced/brief_outfit/init_possible_values()
+ return subtypesof(/datum/outfit)
+
+/datum/preference/choiced/brief_outfit/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return is_admin(preferences.parent)
diff --git a/code/modules/client/preferences/age.dm b/code/modules/client/preferences/age.dm
new file mode 100644
index 000000000000..cad9786ce1fe
--- /dev/null
+++ b/code/modules/client/preferences/age.dm
@@ -0,0 +1,10 @@
+/datum/preference/numeric/age
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "age"
+ savefile_identifier = PREFERENCE_CHARACTER
+
+ minimum = AGE_MIN
+ maximum = AGE_MAX
+
+/datum/preference/numeric/age/apply_to_human(mob/living/carbon/human/target, value)
+ target.age = value
diff --git a/code/modules/client/preferences/ai_core_display.dm b/code/modules/client/preferences/ai_core_display.dm
new file mode 100644
index 000000000000..dab61f2b323f
--- /dev/null
+++ b/code/modules/client/preferences/ai_core_display.dm
@@ -0,0 +1,25 @@
+/// What to show on the AI screen
+/datum/preference/choiced/ai_core_display
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "preferred_ai_core_display"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ai_core_display/init_possible_values()
+ var/list/values = list()
+
+ values["Random"] = icon('icons/mob/ai.dmi', "ai-empty")
+
+ for (var/screen in GLOB.ai_core_display_screens - "Portrait" - "Random")
+ values[screen] = icon('icons/mob/ai.dmi', resolve_ai_icon_sync(screen))
+
+ return values
+
+/datum/preference/choiced/ai_core_display/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return istype(preferences.get_highest_priority_job(), /datum/job/ai)
+
+/datum/preference/choiced/ai_core_display/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/ambient_occlusion.dm b/code/modules/client/preferences/ambient_occlusion.dm
new file mode 100644
index 000000000000..a81efca00bd8
--- /dev/null
+++ b/code/modules/client/preferences/ambient_occlusion.dm
@@ -0,0 +1,12 @@
+/// Whether or not to toggle ambient occlusion, the shadows around people
+/datum/preference/toggle/ambient_occlusion
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "ambientocclusion"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/ambient_occlusion/apply_to_client(client/client, value)
+ var/atom/movable/screen/plane_master/game_world/plane_master = locate() in client?.screen
+ if (!plane_master)
+ return
+
+ plane_master.backdrop(client.mob)
diff --git a/code/modules/client/preferences/assets.dm b/code/modules/client/preferences/assets.dm
new file mode 100644
index 000000000000..fbd4d0698ca4
--- /dev/null
+++ b/code/modules/client/preferences/assets.dm
@@ -0,0 +1,65 @@
+/// Assets generated from `/datum/preference` icons
+/datum/asset/spritesheet/preferences
+ name = "preferences"
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/preferences/create_spritesheets()
+ var/list/to_insert = list()
+
+ for (var/preference_key in GLOB.preference_entries_by_key)
+ var/datum/preference/choiced/preference = GLOB.preference_entries_by_key[preference_key]
+ if (!istype(preference))
+ continue
+
+ if (!preference.should_generate_icons)
+ continue
+
+ var/list/choices = preference.get_choices_serialized()
+ for (var/preference_value in choices)
+ var/create_icon_of = choices[preference_value]
+
+ var/icon/icon
+ var/icon_state
+
+ if (ispath(create_icon_of, /atom))
+ var/atom/atom_icon_source = create_icon_of
+ icon = initial(atom_icon_source.icon)
+ icon_state = initial(atom_icon_source.icon_state)
+ else if (isicon(create_icon_of))
+ icon = create_icon_of
+ else
+ CRASH("[create_icon_of] is an invalid preference value (from [preference_key]:[preference_value]).")
+
+ to_insert[preference.get_spritesheet_key(preference_value)] = list(icon, icon_state)
+
+ for (var/spritesheet_key in to_insert)
+ var/list/inserting = to_insert[spritesheet_key]
+ Insert(spritesheet_key, inserting[1], inserting[2])
+
+/// Returns the key that will be used in the spritesheet for a given value.
+/datum/preference/proc/get_spritesheet_key(value)
+ return "[savefile_key]___[sanitize_css_class_name(value)]"
+
+/// Sends information needed for shared details on individual preferences
+/datum/asset/json/preferences
+ name = "preferences"
+
+/datum/asset/json/preferences/generate()
+ var/list/preference_data = list()
+
+ for (var/middleware_type in subtypesof(/datum/preference_middleware))
+ var/datum/preference_middleware/middleware = new middleware_type
+ var/data = middleware.get_constant_data()
+ if (!isnull(data))
+ preference_data[middleware.key] = data
+
+ qdel(middleware)
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_type]
+ var/data = preference_entry.compile_constant_data()
+ if (!isnull(data))
+ preference_data[preference_entry.savefile_key] = data
+
+ return preference_data
diff --git a/code/modules/client/preferences/auto_fit_viewport.dm b/code/modules/client/preferences/auto_fit_viewport.dm
new file mode 100644
index 000000000000..3550af054549
--- /dev/null
+++ b/code/modules/client/preferences/auto_fit_viewport.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/auto_fit_viewport
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "auto_fit_viewport"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/auto_fit_viewport/apply_to_client_updated(client/client, value)
+ INVOKE_ASYNC(client, /client/verb/fit_viewport)
diff --git a/code/modules/client/preferences/buttons_locked.dm b/code/modules/client/preferences/buttons_locked.dm
new file mode 100644
index 000000000000..b4f54ff10f8f
--- /dev/null
+++ b/code/modules/client/preferences/buttons_locked.dm
@@ -0,0 +1,5 @@
+/datum/preference/toggle/buttons_locked
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "buttons_locked"
+ savefile_identifier = PREFERENCE_PLAYER
+ default_value = FALSE
diff --git a/code/modules/client/preferences/clothing.dm b/code/modules/client/preferences/clothing.dm
new file mode 100644
index 000000000000..c53d7d849a21
--- /dev/null
+++ b/code/modules/client/preferences/clothing.dm
@@ -0,0 +1,151 @@
+/proc/generate_values_for_underwear(list/accessory_list, list/icons, color)
+ var/icon/lower_half = icon('icons/blanks/32x32.dmi', "nothing")
+
+ for (var/icon in icons)
+ lower_half.Blend(icon('icons/mob/human_parts_greyscale.dmi', icon), ICON_OVERLAY)
+
+ var/list/values = list()
+
+ for (var/accessory_name in accessory_list)
+ var/icon/icon_with_socks = new(lower_half)
+
+ if (accessory_name != "Nude")
+ var/datum/sprite_accessory/accessory = accessory_list[accessory_name]
+
+ var/icon/accessory_icon = icon('icons/mob/clothing/sprite_accessories/underwear.dmi', accessory.icon_state)
+ if (color/* && !accessory.use_static*/)
+ accessory_icon.Blend(color, ICON_MULTIPLY)
+ icon_with_socks.Blend(accessory_icon, ICON_OVERLAY)
+
+ icon_with_socks.Crop(10, 1, 22, 13)
+ icon_with_socks.Scale(32, 32)
+
+ values[accessory_name] = icon_with_socks
+
+ return values
+
+/// Backpack preference
+/datum/preference/choiced/backpack
+ savefile_key = "backpack"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Backpack"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/backpack/init_possible_values()
+ var/list/values = list()
+
+ values[GBACKPACK] = /obj/item/storage/backpack
+ values[GSATCHEL] = /obj/item/storage/backpack/satchel
+ values[LSATCHEL] = /obj/item/storage/backpack/satchel/leather
+ values[GDUFFELBAG] = /obj/item/storage/backpack/duffelbag
+
+ // In a perfect world, these would be your department's backpack.
+ // However, this doesn't factor in assistants, or no high slot, and would
+ // also increase the spritesheet size a lot.
+ // I play medical doctor, and so medical doctor you get.
+ values[DBACKPACK] = /obj/item/storage/backpack/medic
+ values[DSATCHEL] = /obj/item/storage/backpack/satchel/med
+ values[DDUFFELBAG] = /obj/item/storage/backpack/duffelbag/med
+
+ return values
+
+/datum/preference/choiced/backpack/apply_to_human(mob/living/carbon/human/target, value)
+ target.back = value
+
+/// Jumpsuit preference
+/datum/preference/choiced/jumpsuit
+ savefile_key = "jumpsuit_style"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Jumpsuit"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/jumpsuit/init_possible_values()
+ var/list/values = list()
+
+ values[PREF_SUIT] = /obj/item/clothing/under/color/grey
+ values[PREF_SKIRT] = /obj/item/clothing/under/skirt/color/grey
+
+ return values
+
+/datum/preference/choiced/jumpsuit/apply_to_human(mob/living/carbon/human/target, value)
+ target.jumpsuit_style = value
+
+/// Socks preference
+/datum/preference/choiced/socks
+ savefile_key = "socks"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Socks"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/socks/init_possible_values()
+ return generate_values_for_underwear(GLOB.socks_list, list("human_r_leg", "human_l_leg"))
+
+/datum/preference/choiced/socks/apply_to_human(mob/living/carbon/human/target, value)
+ target.socks = value
+
+/// Undershirt preference
+/datum/preference/choiced/undershirt
+ savefile_key = "undershirt"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Undershirt"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/undershirt/init_possible_values()
+ var/icon/body = icon('icons/mob/human_parts_greyscale.dmi', "human_r_leg")
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_l_leg"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_r_arm"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_l_arm"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_r_hand"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_l_hand"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_chest_m"), ICON_OVERLAY)
+
+ var/list/values = list()
+
+ for (var/accessory_name in GLOB.undershirt_list)
+ var/icon/icon_with_undershirt = icon(body)
+
+ if (accessory_name != "Nude")
+ var/datum/sprite_accessory/accessory = GLOB.undershirt_list[accessory_name]
+ icon_with_undershirt.Blend(icon('icons/mob/clothing/sprite_accessories/underwear.dmi', accessory.icon_state), ICON_OVERLAY)
+
+ icon_with_undershirt.Crop(9, 9, 23, 23)
+ icon_with_undershirt.Scale(32, 32)
+ values[accessory_name] = icon_with_undershirt
+
+ return values
+
+/datum/preference/choiced/undershirt/apply_to_human(mob/living/carbon/human/target, value)
+ target.undershirt = value
+
+/// Underwear preference
+/datum/preference/choiced/underwear
+ savefile_key = "underwear"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Underwear"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/underwear/init_possible_values()
+ return generate_values_for_underwear(GLOB.underwear_list, list("human_chest_m", "human_r_leg", "human_l_leg"), COLOR_ALMOST_BLACK)
+
+/datum/preference/choiced/underwear/apply_to_human(mob/living/carbon/human/target, value)
+ target.underwear = value
+
+/datum/preference/choiced/underwear/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ var/species_type = preferences.read_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+ return !(NO_UNDERWEAR in species.species_traits)
+
+/datum/preference/choiced/underwear/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "underwear_color"
+
+ return data
diff --git a/code/modules/client/preferences/engineering_department.dm b/code/modules/client/preferences/engineering_department.dm
new file mode 100644
index 000000000000..fca727ad1f8d
--- /dev/null
+++ b/code/modules/client/preferences/engineering_department.dm
@@ -0,0 +1,22 @@
+/// Which department to put station engineers in
+/datum/preference/choiced/engineering_department
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ can_randomize = FALSE
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "prefered_engineering_department"
+
+// This is what that #warn wants you to remove :)
+/datum/preference/choiced/engineering_department/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.engineering_depts_prefs))
+ return ENG_DEPT_NONE
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/engineering_department/init_possible_values()
+ return GLOB.engineering_depts_prefs
+
+/datum/preference/choiced/engineering_department/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/engineering_department/create_default_value()
+ return ENG_DEPT_NONE
diff --git a/code/modules/client/preferences/fps.dm b/code/modules/client/preferences/fps.dm
new file mode 100644
index 000000000000..f6cd8bf23bec
--- /dev/null
+++ b/code/modules/client/preferences/fps.dm
@@ -0,0 +1,20 @@
+/datum/preference/numeric/fps
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "clientfps"
+ savefile_identifier = PREFERENCE_PLAYER
+
+ minimum = -1
+ maximum = 240
+
+/datum/preference/numeric/fps/create_default_value()
+ return -1
+
+/datum/preference/numeric/fps/apply_to_client(client/client, value)
+ client.fps = (value < 0) ? RECOMMENDED_FPS : value
+
+/datum/preference/numeric/fps/compile_constant_data()
+ var/list/data = ..()
+
+ data["recommended_fps"] = RECOMMENDED_FPS
+
+ return data
diff --git a/code/modules/client/preferences/gender.dm b/code/modules/client/preferences/gender.dm
new file mode 100644
index 000000000000..d24b1954c2c6
--- /dev/null
+++ b/code/modules/client/preferences/gender.dm
@@ -0,0 +1,13 @@
+/// Gender preference
+/datum/preference/choiced/gender
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "gender"
+ priority = PREFERENCE_PRIORITY_GENDER
+
+/datum/preference/choiced/gender/init_possible_values()
+ return list(MALE, FEMALE, PLURAL)
+
+/datum/preference/choiced/gender/apply_to_human(mob/living/carbon/human/target, value)
+ if(!target.dna.species.sexes)
+ value = PLURAL //disregard gender preferences on this species
+ target.gender = value
diff --git a/code/modules/client/preferences/ghost.dm b/code/modules/client/preferences/ghost.dm
new file mode 100644
index 000000000000..b6ce3511e4d8
--- /dev/null
+++ b/code/modules/client/preferences/ghost.dm
@@ -0,0 +1,178 @@
+/// Determines what accessories your ghost will look like they have.
+/datum/preference/choiced/ghost_accessories
+ savefile_key = "ghost_accs"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/ghost_accessories/init_possible_values()
+ return list(GHOST_ACCS_NONE, GHOST_ACCS_DIR, GHOST_ACCS_FULL)
+
+/datum/preference/choiced/ghost_accessories/create_default_value()
+ return GHOST_ACCS_DEFAULT_OPTION
+
+/datum/preference/choiced/ghost_accessories/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ ghost.ghost_accs = value
+ ghost.update_icon()
+
+/datum/preference/choiced/ghost_accessories/deserialize(input, datum/preferences/preferences)
+ // Old ghost preferences used to be 1/50/100.
+ // Whoever did that wasted an entire day of my time trying to get those sent
+ // properly, so I'm going to buck them.
+ if (isnum(input))
+ switch (input)
+ if (1)
+ input = GHOST_ACCS_NONE
+ if (50)
+ input = GHOST_ACCS_DIR
+ if (100)
+ input = GHOST_ACCS_FULL
+
+ return ..(input)
+
+/// Determines the appearance of your ghost to others, when you are a BYOND member
+/datum/preference/choiced/ghost_form
+ savefile_key = "ghost_form"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ should_generate_icons = TRUE
+
+ var/static/list/ghost_forms = list(
+ "catghost" = "Cat",
+ "ghost" = "Default",
+ "ghost_black" = "Black",
+ "ghost_blazeit" = "Blaze it",
+ "ghost_blue" = "Blue",
+ "ghost_camo" = "Camo",
+ "ghost_cyan" = "Cyan",
+ "ghost_dblue" = "Dark blue",
+ "ghost_dcyan" = "Dark cyan",
+ "ghost_dgreen" = "Dark green",
+ "ghost_dpink" = "Dark pink",
+ "ghost_dred" = "Dark red",
+ "ghost_dyellow" = "Dark yellow",
+ "ghost_fire" = "Fire",
+ "ghost_funkypurp" = "Funky purple",
+ "ghost_green" = "Green",
+ "ghost_grey" = "Grey",
+ "ghost_mellow" = "Mellow",
+ "ghost_pink" = "Pink",
+ "ghost_pinksherbert" = "Pink Sherbert",
+ "ghost_purpleswirl" = "Purple Swirl",
+ "ghost_rainbow" = "Rainbow",
+ "ghost_red" = "Red",
+ "ghost_yellow" = "Yellow",
+ "ghostian2" = "Ian",
+ "ghostking" = "King",
+ "skeleghost" = "Skeleton",
+ )
+
+/datum/preference/choiced/ghost_form/init_possible_values()
+ var/list/values = list()
+
+ for (var/ghost_form in ghost_forms)
+ values[ghost_form] = icon('icons/mob/mob.dmi', ghost_form)
+
+ return values
+
+/datum/preference/choiced/ghost_form/create_default_value()
+ return "ghost"
+
+/datum/preference/choiced/ghost_form/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ if (!client.is_content_unlocked())
+ return
+
+ ghost.update_icon(ALL, value)
+
+/datum/preference/choiced/ghost_form/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = ghost_forms
+
+ return data
+
+/// Toggles the HUD for ghosts
+/datum/preference/toggle/ghost_hud
+ savefile_key = "ghost_hud"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/toggle/ghost_hud/apply_to_client(client/client, value)
+ if (isobserver(client?.mob))
+ client?.mob.hud_used?.show_hud()
+
+/// Determines what ghosts orbiting look like to you.
+/datum/preference/choiced/ghost_orbit
+ savefile_key = "ghost_orbit"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/ghost_orbit/init_possible_values()
+ return list(
+ GHOST_ORBIT_CIRCLE,
+ GHOST_ORBIT_TRIANGLE,
+ GHOST_ORBIT_SQUARE,
+ GHOST_ORBIT_HEXAGON,
+ GHOST_ORBIT_PENTAGON,
+ )
+
+/datum/preference/choiced/ghost_orbit/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ if (!client.is_content_unlocked())
+ return
+
+ ghost.ghost_orbit = value
+
+/// Determines how to show other ghosts
+/datum/preference/choiced/ghost_others
+ savefile_key = "ghost_others"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/ghost_others/init_possible_values()
+ return list(
+ GHOST_OTHERS_SIMPLE,
+ GHOST_OTHERS_DEFAULT_SPRITE,
+ GHOST_OTHERS_THEIR_SETTING,
+ )
+
+/datum/preference/choiced/ghost_others/create_default_value()
+ return GHOST_OTHERS_DEFAULT_OPTION
+
+/datum/preference/choiced/ghost_others/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ ghost.update_sight()
+
+/datum/preference/choiced/ghost_others/deserialize(input, datum/preferences/preferences)
+ // Old ghost preferences used to be 1/50/100.
+ // Whoever did that wasted an entire day of my time trying to get those sent
+ // properly, so I'm going to buck them.
+ if (isnum(input))
+ switch (input)
+ if (1)
+ input = GHOST_OTHERS_SIMPLE
+ if (50)
+ input = GHOST_OTHERS_DEFAULT_SPRITE
+ if (100)
+ input = GHOST_OTHERS_THEIR_SETTING
+
+ return ..(input, preferences)
+
+/// Whether or not ghosts can examine things by clicking on them.
+/datum/preference/toggle/inquisitive_ghost
+ savefile_key = "inquisitive_ghost"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
diff --git a/code/modules/client/preferences/hotkeys.dm b/code/modules/client/preferences/hotkeys.dm
new file mode 100644
index 000000000000..b96b68286d60
--- /dev/null
+++ b/code/modules/client/preferences/hotkeys.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/hotkeys
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "hotkeys"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/hotkeys/apply_to_client(client/client, value)
+ client.hotkeys = value
diff --git a/code/modules/client/preferences/jobless_role.dm b/code/modules/client/preferences/jobless_role.dm
new file mode 100644
index 000000000000..ed9aaa1ff11e
--- /dev/null
+++ b/code/modules/client/preferences/jobless_role.dm
@@ -0,0 +1,12 @@
+/datum/preference/choiced/jobless_role
+ savefile_key = "joblessrole"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/jobless_role/create_default_value()
+ return BEOVERFLOW
+
+/datum/preference/choiced/jobless_role/init_possible_values()
+ return list(BEOVERFLOW, BERANDOMJOB, RETURNTOLOBBY)
+
+/datum/preference/choiced/jobless_role/should_show_on_page(preference_tab)
+ return preference_tab == PREFERENCE_TAB_CHARACTER_PREFERENCES
diff --git a/code/modules/client/preferences/middleware/_middleware.dm b/code/modules/client/preferences/middleware/_middleware.dm
new file mode 100644
index 000000000000..8f47f73642c8
--- /dev/null
+++ b/code/modules/client/preferences/middleware/_middleware.dm
@@ -0,0 +1,52 @@
+/// Preference middleware is code that helps to decentralize complicated preference features.
+/datum/preference_middleware
+ /// The preferences datum
+ var/datum/preferences/preferences
+
+ /// The key that will be used for get_constant_data().
+ /// If null, will use the typepath minus /datum/preference_middleware.
+ var/key = null
+
+ /// Map of ui_act actions -> proc paths to call.
+ /// Signature is `(list/params, mob/user) -> TRUE/FALSE.
+ /// Return output is the same as ui_act--TRUE if it should update, FALSE if it should not
+ var/list/action_delegations = list()
+
+/datum/preference_middleware/New(datum/preferences)
+ src.preferences = preferences
+
+ if (isnull(key))
+ // + 2 coming from the off-by-one of copytext, and then another from the slash
+ key = copytext("[type]", length("[parent_type]") + 2)
+
+/datum/preference_middleware/Destroy()
+ preferences = null
+ return ..()
+
+/// Append all of these into ui_data
+/datum/preference_middleware/proc/get_ui_data(mob/user)
+ return list()
+
+/// Append all of these into ui_static_data
+/datum/preference_middleware/proc/get_ui_static_data(mob/user)
+ return list()
+
+/// Append all of these into ui_assets
+/datum/preference_middleware/proc/get_ui_assets()
+ return list()
+
+/// Append all of these into /datum/asset/json/preferences.
+/datum/preference_middleware/proc/get_constant_data()
+ return null
+
+/// Merge this into the result of compile_character_preferences.
+/datum/preference_middleware/proc/get_character_preferences(mob/user)
+ return null
+
+/// Called every set_preference, returns TRUE if this handled it.
+/datum/preference_middleware/proc/pre_set_preference(mob/user, preference, value)
+ return FALSE
+
+/// Called when a character is changed.
+/datum/preference_middleware/proc/on_new_character(mob/user)
+ return
diff --git a/code/modules/client/preferences/middleware/antags.dm b/code/modules/client/preferences/middleware/antags.dm
new file mode 100644
index 000000000000..ecd1fcc19b19
--- /dev/null
+++ b/code/modules/client/preferences/middleware/antags.dm
@@ -0,0 +1,164 @@
+/datum/preference_middleware/antags
+ action_delegations = list(
+ "set_antags" = .proc/set_antags,
+ )
+
+/datum/preference_middleware/antags/get_ui_static_data(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+
+ var/list/data = list()
+
+ var/list/selected_antags = list()
+
+ for (var/antag in preferences.be_special)
+ selected_antags += serialize_antag_name(antag)
+
+ data["selected_antags"] = selected_antags
+
+ var/list/antag_bans = get_antag_bans()
+ if (antag_bans.len)
+ data["antag_bans"] = antag_bans
+
+ var/list/antag_days_left = get_antag_days_left()
+ if (antag_days_left?.len)
+ data["antag_days_left"] = antag_days_left
+
+ return data
+
+/datum/preference_middleware/antags/get_ui_assets()
+ return list(
+ get_asset_datum(/datum/asset/spritesheet/antagonists),
+ )
+
+/datum/preference_middleware/antags/proc/set_antags(list/params, mob/user)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ var/sent_antags = params["antags"]
+ var/toggled = params["toggled"]
+
+ var/antags = list()
+
+ var/serialized_antags = get_serialized_antags()
+
+ for (var/sent_antag in sent_antags)
+ var/special_role = serialized_antags[sent_antag]
+ if (!special_role)
+ continue
+
+ antags += special_role
+
+ if (toggled)
+ preferences.be_special |= antags
+ else
+ preferences.be_special -= antags
+
+ // This is predicted on the client
+ return FALSE
+
+/datum/preference_middleware/antags/proc/get_antag_bans()
+ var/list/antag_bans = list()
+
+ for (var/datum/dynamic_ruleset/dynamic_ruleset as anything in subtypesof(/datum/dynamic_ruleset))
+ var/antag_flag = initial(dynamic_ruleset.antag_flag)
+ var/antag_flag_override = initial(dynamic_ruleset.antag_flag_override)
+
+ if (isnull(antag_flag))
+ continue
+
+ if (is_banned_from(preferences.parent.ckey, list(antag_flag_override || antag_flag, ROLE_SYNDICATE)))
+ antag_bans += serialize_antag_name(antag_flag)
+
+ return antag_bans
+
+/datum/preference_middleware/antags/proc/get_antag_days_left()
+ if (!CONFIG_GET(flag/use_age_restriction_for_jobs))
+ return
+
+ var/list/antag_days_left = list()
+
+ for (var/datum/dynamic_ruleset/dynamic_ruleset as anything in subtypesof(/datum/dynamic_ruleset))
+ var/antag_flag = initial(dynamic_ruleset.antag_flag)
+ var/antag_flag_override = initial(dynamic_ruleset.antag_flag_override)
+
+ if (isnull(antag_flag))
+ continue
+
+ /*var/days_needed = preferences.parent?.get_remaining_days(
+ GLOB.special_roles[antag_flag_override || antag_flag]
+ )*/
+
+ var/days_needed = 0
+
+ if (days_needed > 0)
+ antag_days_left[serialize_antag_name(antag_flag)] = days_needed
+
+ return antag_days_left
+
+/datum/preference_middleware/antags/proc/get_serialized_antags()
+ var/list/serialized_antags
+
+ if (isnull(serialized_antags))
+ serialized_antags = list()
+
+ for (var/special_role in GLOB.special_roles)
+ serialized_antags[serialize_antag_name(special_role)] = special_role
+
+ return serialized_antags
+
+/// Sprites generated for the antagonists panel
+/datum/asset/spritesheet/antagonists
+ name = "antagonists"
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/antagonists/create_spritesheets()
+ // Antagonists that don't have a dynamic ruleset, but do have a preference
+ var/static/list/non_ruleset_antagonists = list(
+ ROLE_LONE_OPERATIVE = /datum/antagonist/nukeop/lone,
+ )
+
+ var/list/antagonists = non_ruleset_antagonists.Copy()
+
+ for (var/datum/dynamic_ruleset/ruleset as anything in subtypesof(/datum/dynamic_ruleset))
+ var/datum/antagonist/antagonist_type = initial(ruleset.antag_datum)
+ if (isnull(antagonist_type))
+ continue
+
+ // antag_flag is guaranteed to be unique by unit tests.
+ antagonists[initial(ruleset.antag_flag)] = antagonist_type
+
+ var/list/generated_icons = list()
+ var/list/to_insert = list()
+
+ for (var/antag_flag in antagonists)
+ var/datum/antagonist/antagonist_type = antagonists[antag_flag]
+
+ // antag_flag is guaranteed to be unique by unit tests.
+ var/spritesheet_key = serialize_antag_name(antag_flag)
+
+ if (!isnull(generated_icons[antagonist_type]))
+ to_insert[spritesheet_key] = generated_icons[antagonist_type]
+ continue
+
+ var/datum/antagonist/antagonist = new antagonist_type
+ var/icon/preview_icon = antagonist.get_preview_icon()
+
+ if (isnull(preview_icon))
+ continue
+
+ qdel(antagonist)
+
+ // preview_icons are not scaled at this stage INTENTIONALLY.
+ // If an icon is not prepared to be scaled to that size, it looks really ugly, and this
+ // makes it harder to figure out what size it *actually* is.
+ generated_icons[antagonist_type] = preview_icon
+ to_insert[spritesheet_key] = preview_icon
+
+ for (var/spritesheet_key in to_insert)
+ Insert(spritesheet_key, to_insert[spritesheet_key])
+
+/// Serializes an antag name to be used for preferences UI
+/proc/serialize_antag_name(antag_name)
+ // These are sent through CSS, so they need to be safe to use as class names.
+ return lowertext(sanitize_css_class_name(antag_name))
diff --git a/code/modules/client/preferences/middleware/jobs.dm b/code/modules/client/preferences/middleware/jobs.dm
new file mode 100644
index 000000000000..3f97278e7bc7
--- /dev/null
+++ b/code/modules/client/preferences/middleware/jobs.dm
@@ -0,0 +1,119 @@
+/datum/preference_middleware/jobs
+ action_delegations = list(
+ "set_job_preference" = .proc/set_job_preference,
+ )
+
+/datum/preference_middleware/jobs/proc/set_job_preference(list/params, mob/user)
+ var/job_title = params["job"]
+ var/level = params["level"]
+
+ if (level != null && level != JP_LOW && level != JP_MEDIUM && level != JP_HIGH)
+ return FALSE
+
+ var/datum/job/job = SSjob.GetJob(job_title)
+
+ if (isnull(job))
+ return FALSE
+
+ //if (job.faction != FACTION_STATION)
+ // return FALSE
+
+ if (!preferences.set_job_preference_level(job, level))
+ return FALSE
+
+ preferences.character_preview_view?.update_body()
+
+ return TRUE
+
+/datum/preference_middleware/jobs/get_constant_data()
+ log_world("jobs/get_constant_data called!")
+
+ var/list/data = list()
+
+ var/list/departments = list()
+ var/list/jobs = list()
+
+ for (var/datum/job/job as anything in SSjob.occupations)
+ if (isnull(job.description))
+ stack_trace("[job] does not have a description set, yet is a joinable occupation!")
+ continue
+
+ var/department_name = job.exp_type_department
+ /*var/is_command = (job in GLOB.command_positions)
+ if (!is_command && isnull(departments[department_name]))
+ var/list/heads = job.department_head
+ if (heads && heads.len >= 1)
+ departments[department_name] = list(
+ "head" = heads[1],
+ )*/
+
+ if (isnull(departments[department_name]))
+ departments[department_name] = list(
+ "head" = null,
+ )
+
+ jobs[job.title] = list(
+ "description" = job.description,
+ "department" = department_name,
+ )
+
+ data["departments"] = departments
+ data["jobs"] = jobs
+
+ return data
+
+/datum/preference_middleware/jobs/get_ui_data(mob/user)
+ var/list/data = list()
+
+ data["job_preferences"] = preferences.job_preferences
+
+ return data
+
+/datum/preference_middleware/jobs/get_ui_static_data(mob/user)
+ var/list/data = list()
+
+ var/list/required_job_playtime = get_required_job_playtime(user)
+ if (!isnull(required_job_playtime))
+ data += required_job_playtime
+
+ var/list/job_bans = get_job_bans(user)
+ if (job_bans.len)
+ data["job_bans"] = job_bans
+
+ return data.len > 0 ? data : null
+
+/datum/preference_middleware/jobs/proc/get_required_job_playtime(mob/user)
+ var/list/data = list()
+
+ var/list/job_days_left = list()
+ var/list/job_required_experience = list()
+
+ for (var/datum/job/job as anything in SSjob.occupations)
+ var/required_playtime_remaining = job.required_playtime_remaining(user.client)
+ if (required_playtime_remaining)
+ job_required_experience[job.title] = list(
+ "experience_type" = job.get_exp_req_type(),
+ "required_playtime" = required_playtime_remaining,
+ )
+
+ continue
+
+ if (!job.player_old_enough(user.client))
+ job_days_left[job.title] = job.available_in_days(user.client)
+
+ if (job_days_left.len)
+ data["job_days_left"] = job_days_left
+
+ if (job_required_experience)
+ data["job_required_experience"] = job_required_experience
+
+ return data
+
+/datum/preference_middleware/jobs/proc/get_job_bans(mob/user)
+ var/list/data = list()
+
+ for (var/datum/job/job as anything in SSjob.occupations)
+ if (is_banned_from(user.client?.ckey, job.title))
+ data += job.title
+
+ return data
diff --git a/code/modules/client/preferences/middleware/keybindings.dm b/code/modules/client/preferences/middleware/keybindings.dm
new file mode 100644
index 000000000000..7cb41269743a
--- /dev/null
+++ b/code/modules/client/preferences/middleware/keybindings.dm
@@ -0,0 +1,97 @@
+#define MAX_HOTKEY_SLOTS 3
+
+/// Middleware to handle keybindings
+/datum/preference_middleware/keybindings
+ action_delegations = list(
+ "reset_all_keybinds" = .proc/reset_all_keybinds,
+ "reset_keybinds_to_defaults" = .proc/reset_keybinds_to_defaults,
+ "set_keybindings" = .proc/set_keybindings,
+ )
+
+/datum/preference_middleware/keybindings/get_ui_static_data(mob/user)
+ if (preferences.current_window == PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+
+ var/list/keybindings = preferences.key_bindings
+
+ return list(
+ "keybindings" = keybindings,
+ )
+
+/datum/preference_middleware/keybindings/get_ui_assets()
+ return list(
+ get_asset_datum(/datum/asset/json/keybindings)
+ )
+
+/datum/preference_middleware/keybindings/proc/reset_all_keybinds(list/params, mob/user)
+ preferences.key_bindings = deepCopyList(GLOB.default_hotkeys)
+ preferences.key_bindings_by_key = preferences.get_key_bindings_by_key(preferences.key_bindings)
+ preferences.update_static_data(user)
+
+ return TRUE
+
+/datum/preference_middleware/keybindings/proc/reset_keybinds_to_defaults(list/params, mob/user)
+ var/keybind_name = params["keybind_name"]
+ var/datum/keybinding/keybinding = GLOB.keybindings_by_name[keybind_name]
+
+ if (isnull(keybinding))
+ return FALSE
+
+ preferences.key_bindings[keybind_name] = preferences.parent.hotkeys ? keybinding.hotkey_keys : keybinding.classic_keys
+ preferences.key_bindings_by_key = preferences.get_key_bindings_by_key(preferences.key_bindings)
+
+ preferences.update_static_data(user)
+
+ return TRUE
+
+/datum/preference_middleware/keybindings/proc/set_keybindings(list/params)
+ var/keybind_name = params["keybind_name"]
+
+ if (isnull(GLOB.keybindings_by_name[keybind_name]))
+ return FALSE
+
+ var/list/raw_hotkeys = params["hotkeys"]
+ if (!istype(raw_hotkeys))
+ return FALSE
+
+ if (raw_hotkeys.len > MAX_HOTKEY_SLOTS)
+ return FALSE
+
+ // There's no optimal, easy way to check if something is an array
+ // and not an object in BYOND, so just sanitize it to make sure.
+ var/list/hotkeys = list()
+ for (var/hotkey in raw_hotkeys)
+ if (!istext(hotkey))
+ return FALSE
+
+ // Fairly arbitrary number, it's just so you don't save enormous fake keybinds.
+ if (length(hotkey) > 100)
+ return FALSE
+
+ hotkeys += hotkey
+
+ preferences.key_bindings[keybind_name] = hotkeys
+ preferences.key_bindings_by_key = preferences.get_key_bindings_by_key(preferences.key_bindings)
+
+ return TRUE
+
+/datum/asset/json/keybindings
+ name = "keybindings"
+
+/datum/asset/json/keybindings/generate()
+ var/list/keybindings = list()
+
+ for (var/name in GLOB.keybindings_by_name)
+ var/datum/keybinding/keybinding = GLOB.keybindings_by_name[name]
+
+ if (!(keybinding.category in keybindings))
+ keybindings[keybinding.category] = list()
+
+ keybindings[keybinding.category][keybinding.name] = list(
+ "name" = keybinding.full_name,
+ "description" = keybinding.description,
+ )
+
+ return keybindings
+
+#undef MAX_HOTKEY_SLOTS
diff --git a/code/modules/client/preferences/middleware/legacy_toggles.dm b/code/modules/client/preferences/middleware/legacy_toggles.dm
new file mode 100644
index 000000000000..25d11eb3054f
--- /dev/null
+++ b/code/modules/client/preferences/middleware/legacy_toggles.dm
@@ -0,0 +1,127 @@
+/// In the before times, toggles were all stored in one bitfield.
+/// In order to preserve this existing data (and code) without massive
+/// migrations, this middleware attempts to handle this in a way
+/// transparent to the preferences UI itself.
+/// In the future, the existing toggles data should just be migrated to
+/// individual `/datum/preference/toggle`s.
+/datum/preference_middleware/legacy_toggles
+ // DO NOT ADD ANY NEW TOGGLES HERE!
+ // Use `/datum/preference/toggle` instead.
+ var/static/list/legacy_toggles = list(
+ "announce_login" = ANNOUNCE_LOGIN,
+ "combohud_lighting" = COMBOHUD_LIGHTING,
+ "deadmin_always" = DEADMIN_ALWAYS,
+ "deadmin_antagonist" = DEADMIN_ANTAGONIST,
+ "deadmin_position_head" = DEADMIN_POSITION_HEAD,
+ "deadmin_position_security" = DEADMIN_POSITION_SECURITY,
+ "deadmin_position_silicon" = DEADMIN_POSITION_SILICON,
+ "disable_arrivalrattle" = DISABLE_ARRIVALRATTLE,
+ "disable_deathrattle" = DISABLE_DEATHRATTLE,
+ "member_public" = MEMBER_PUBLIC,
+ "sound_adminhelp" = SOUND_ADMINHELP,
+ "sound_ambience" = SOUND_AMBIENCE,
+ "sound_announcements" = SOUND_ANNOUNCEMENTS,
+ "sound_instruments" = SOUND_INSTRUMENTS,
+ "sound_lobby" = SOUND_LOBBY,
+ "sound_midi" = SOUND_MIDI,
+ "sound_prayers" = SOUND_PRAYERS,
+ "sound_ship_ambience" = SOUND_SHIP_AMBIENCE,
+ "split_admin_tabs" = SPLIT_ADMIN_TABS,
+ )
+
+ var/list/legacy_chat_toggles = list(
+ "chat_bankcard" = CHAT_BANKCARD,
+ "chat_dead" = CHAT_DEAD,
+ "chat_ghostears" = CHAT_GHOSTEARS,
+ "chat_ghostpda" = CHAT_GHOSTPDA,
+ "chat_ghostradio" = CHAT_GHOSTRADIO,
+ "chat_ghostsight" = CHAT_GHOSTSIGHT,
+ "chat_ghostwhisper" = CHAT_GHOSTWHISPER,
+ "chat_ooc" = CHAT_OOC,
+ "chat_prayer" = CHAT_PRAYER,
+ "chat_pullr" = CHAT_PULLR,
+ )
+
+/datum/preference_middleware/legacy_toggles/get_character_preferences(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_GAME_PREFERENCES)
+ return list()
+
+ var/static/list/admin_only_legacy_toggles = list(
+ "admin_ignore_cult_ghost",
+ "announce_login",
+ "combohud_lighting",
+ "deadmin_always",
+ "deadmin_antagonist",
+ "deadmin_position_head",
+ "deadmin_position_security",
+ "deadmin_position_silicon",
+ "sound_adminhelp",
+ "sound_prayers",
+ "split_admin_tabs",
+ )
+
+ var/static/list/admin_only_chat_toggles = list(
+ "chat_dead",
+ "chat_prayer",
+ )
+
+ var/static/list/deadmin_flags = list(
+ "deadmin_antagonist",
+ "deadmin_position_head",
+ "deadmin_position_security",
+ "deadmin_position_silicon",
+ )
+
+ var/list/new_game_preferences = list()
+ var/is_admin = is_admin(user.client)
+
+ for (var/toggle_name in legacy_toggles)
+ if (!is_admin && (toggle_name in admin_only_legacy_toggles))
+ continue
+
+ if (is_admin && (toggle_name in deadmin_flags) && (preferences.toggles & DEADMIN_ALWAYS))
+ continue
+
+ if (toggle_name == "member_public" && !preferences.unlock_content)
+ continue
+
+ new_game_preferences[toggle_name] = (preferences.toggles & legacy_toggles[toggle_name]) != 0
+
+ for (var/toggle_name in legacy_chat_toggles)
+ if (!is_admin && (toggle_name in admin_only_chat_toggles))
+ continue
+
+ new_game_preferences[toggle_name] = (preferences.chat_toggles & legacy_chat_toggles[toggle_name]) != 0
+
+ return list(
+ PREFERENCE_CATEGORY_GAME_PREFERENCES = new_game_preferences,
+ )
+
+/datum/preference_middleware/legacy_toggles/pre_set_preference(mob/user, preference, value)
+ var/legacy_flag = legacy_toggles[preference]
+ if (!isnull(legacy_flag))
+ if (value)
+ preferences.toggles |= legacy_flag
+ else
+ preferences.toggles &= ~legacy_flag
+
+ // I know this looks silly, but this is the only one that cares
+ // and NO NEW LEGACY TOGGLES should ever be added.
+ if (legacy_flag == SOUND_LOBBY)
+ if (value && isnewplayer(user))
+ user.client?.playtitlemusic()
+ else
+ user.stop_sound_channel(CHANNEL_LOBBYMUSIC)
+
+ return TRUE
+
+ var/legacy_chat_flag = legacy_chat_toggles[preference]
+ if (!isnull(legacy_chat_flag))
+ if (value)
+ preferences.chat_toggles |= legacy_chat_flag
+ else
+ preferences.chat_toggles &= ~legacy_chat_flag
+
+ return TRUE
+
+ return FALSE
diff --git a/code/modules/client/preferences/middleware/names.dm b/code/modules/client/preferences/middleware/names.dm
new file mode 100644
index 000000000000..34e97f0f727a
--- /dev/null
+++ b/code/modules/client/preferences/middleware/names.dm
@@ -0,0 +1,56 @@
+/// Middleware that handles telling the UI which name to show, and waht names
+/// they have.
+/datum/preference_middleware/names
+ action_delegations = list(
+ "randomize_name" = .proc/randomize_name,
+ )
+
+/datum/preference_middleware/names/get_constant_data()
+ var/list/data = list()
+
+ var/list/types = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/name/name_preference = GLOB.preference_entries[preference_type]
+ if (!istype(name_preference))
+ continue
+
+ types[name_preference.savefile_key] = list(
+ "can_randomize" = name_preference.is_randomizable(),
+ "explanation" = name_preference.explanation,
+ "group" = name_preference.group,
+ )
+
+ data["types"] = types
+
+ return data
+
+/datum/preference_middleware/names/get_ui_data(mob/user)
+ var/list/data = list()
+
+ data["name_to_use"] = get_name_to_use()
+
+ return data
+
+/datum/preference_middleware/names/proc/get_name_to_use()
+ var/highest_priority_job = preferences.get_highest_priority_job()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/name/name_preference = GLOB.preference_entries[preference_type]
+ if (!istype(name_preference))
+ continue
+
+ if (isnull(name_preference.relevant_job))
+ continue
+
+ if (istype(highest_priority_job, name_preference.relevant_job))
+ return name_preference.savefile_key
+
+ return "real_name"
+
+/datum/preference_middleware/names/proc/randomize_name(list/params, mob/user)
+ var/datum/preference/name/name_preference = GLOB.preference_entries_by_key[params["preference"]]
+ if (!istype(name_preference))
+ return FALSE
+
+ return preferences.update_preference(name_preference, name_preference.create_random_value(preferences))
diff --git a/code/modules/client/preferences/middleware/quirks.dm b/code/modules/client/preferences/middleware/quirks.dm
new file mode 100644
index 000000000000..78947d2de9fa
--- /dev/null
+++ b/code/modules/client/preferences/middleware/quirks.dm
@@ -0,0 +1,89 @@
+/// Middleware to handle quirks
+/datum/preference_middleware/quirks
+ var/tainted = FALSE
+
+ action_delegations = list(
+ "give_quirk" = .proc/give_quirk,
+ "remove_quirk" = .proc/remove_quirk,
+ )
+
+/datum/preference_middleware/quirks/get_ui_static_data(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+
+ var/list/data = list()
+
+ data["selected_quirks"] = get_selected_quirks()
+
+ return data
+
+/datum/preference_middleware/quirks/get_ui_data(mob/user)
+ var/list/data = list()
+
+ if (tainted)
+ tainted = FALSE
+ data["selected_quirks"] = get_selected_quirks()
+
+ return data
+
+/datum/preference_middleware/quirks/get_constant_data()
+ var/list/quirk_info = list()
+
+ for (var/quirk_name in SSquirks.quirks)
+ var/datum/quirk/quirk = SSquirks.quirks[quirk_name]
+ quirk_info[sanitize_css_class_name(quirk_name)] = list(
+ "description" = initial(quirk.desc),
+ "icon" = "TODO",//initial(quirk.icon),
+ "name" = quirk_name,
+ "value" = initial(quirk.value),
+ )
+
+ return list(
+ "max_positive_quirks" = MAX_QUIRKS,
+ "quirk_info" = quirk_info,
+ "quirk_blacklist" = SSquirks.quirk_blacklist,
+ )
+
+/datum/preference_middleware/quirks/on_new_character(mob/user)
+ tainted = TRUE
+
+/datum/preference_middleware/quirks/proc/give_quirk(list/params, mob/user)
+ var/quirk_name = params["quirk"]
+
+ var/list/new_quirks = preferences.all_quirks | quirk_name
+ if (SSquirks.filter_invalid_quirks(new_quirks) != new_quirks)
+ // If the client is sending an invalid give_quirk, that means that
+ // something went wrong with the client prediction, so we should
+ // catch it back up to speed.
+ preferences.update_static_data(user)
+ return TRUE
+
+ preferences.all_quirks = new_quirks
+
+ return TRUE
+
+/datum/preference_middleware/quirks/proc/remove_quirk(list/params, mob/user)
+ var/quirk_name = params["quirk"]
+
+ var/list/new_quirks = preferences.all_quirks - quirk_name
+ if ( \
+ !(quirk_name in preferences.all_quirks) \
+ || SSquirks.filter_invalid_quirks(new_quirks) != new_quirks \
+ )
+ // If the client is sending an invalid remove_quirk, that means that
+ // something went wrong with the client prediction, so we should
+ // catch it back up to speed.
+ preferences.update_static_data(user)
+ return TRUE
+
+ preferences.all_quirks = new_quirks
+
+ return TRUE
+
+/datum/preference_middleware/quirks/proc/get_selected_quirks()
+ var/list/selected_quirks = list()
+
+ for (var/quirk in preferences.all_quirks)
+ selected_quirks += sanitize_css_class_name(quirk)
+
+ return selected_quirks
diff --git a/code/modules/client/preferences/middleware/random.dm b/code/modules/client/preferences/middleware/random.dm
new file mode 100644
index 000000000000..17bc259e2c25
--- /dev/null
+++ b/code/modules/client/preferences/middleware/random.dm
@@ -0,0 +1,84 @@
+/// Middleware for handling randomization preferences
+/datum/preference_middleware/random
+ action_delegations = list(
+ "randomize_character" = .proc/randomize_character,
+ "set_random_preference" = .proc/set_random_preference,
+ )
+
+/datum/preference_middleware/random/get_character_preferences(mob/user)
+ return list(
+ "randomization" = preferences.randomise,
+ )
+
+/datum/preference_middleware/random/get_constant_data()
+ var/list/randomizable = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (!preference.is_randomizable())
+ continue
+
+ randomizable += preference.savefile_key
+
+ return list(
+ "randomizable" = randomizable,
+ )
+
+/datum/preference_middleware/random/proc/randomize_character()
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preferences.should_randomize(preference))
+ preferences.write_preference(preference, preference.create_random_value(src))
+
+ preferences.character_preview_view.update_body()
+
+ return TRUE
+
+/datum/preference_middleware/random/proc/set_random_preference(list/params, mob/user)
+ var/requested_preference_key = params["preference"]
+ var/value = params["value"]
+
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
+
+ if (!requested_preference.is_randomizable())
+ return FALSE
+
+ if (value == RANDOM_ANTAG_ONLY)
+ preferences.randomise[requested_preference_key] = RANDOM_ANTAG_ONLY
+ else if (value == RANDOM_ENABLED)
+ preferences.randomise[requested_preference_key] = RANDOM_ENABLED
+ else if (value == RANDOM_DISABLED)
+ preferences.randomise -= requested_preference_key
+ else
+ return FALSE
+
+ return TRUE
+
+/// Returns if a preference should be randomized.
+/datum/preferences/proc/should_randomize(datum/preference/preference, is_antag)
+ if (!preference.is_randomizable())
+ return FALSE
+
+ var/requested_randomization = randomise[preference.savefile_key]
+
+ if (istype(preference, /datum/preference/name))
+ requested_randomization = read_preference(/datum/preference/choiced/random_name)
+
+ switch (requested_randomization)
+ if (RANDOM_ENABLED)
+ return TRUE
+ if (RANDOM_ANTAG_ONLY)
+ return is_antag
+ else
+ return FALSE
+
+/// Given randomization flags, will return whether or not this preference should be randomized.
+/datum/preference/proc/included_in_randomization_flags(randomize_flags)
+ return TRUE
+
+/datum/preference/name/included_in_randomization_flags(randomize_flags)
+ return !!(randomize_flags & RANDOMIZE_NAME)
+
+/datum/preference/choiced/species/included_in_randomization_flags(randomize_flags)
+ return !!(randomize_flags & RANDOMIZE_SPECIES)
diff --git a/code/modules/client/preferences/middleware/species.dm b/code/modules/client/preferences/middleware/species.dm
new file mode 100644
index 000000000000..02efe1e223a5
--- /dev/null
+++ b/code/modules/client/preferences/middleware/species.dm
@@ -0,0 +1,35 @@
+/// Handles the assets for species icons
+/datum/preference_middleware/species
+
+/datum/preference_middleware/species/get_ui_assets()
+ return list(
+ get_asset_datum(/datum/asset/spritesheet/species),
+ )
+
+/datum/asset/spritesheet/species
+ name = "species"
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/species/create_spritesheets()
+ var/list/to_insert = list()
+
+ for (var/species_id in get_selectable_species())
+ var/datum/species/species_type = GLOB.species_list[species_id]
+
+ var/mob/living/carbon/human/dummy/consistent/dummy = new
+ dummy.set_species(species_type)
+ dummy.equipOutfit(/datum/outfit/job/assistant/consistent, visualsOnly = TRUE)
+ dummy.dna.species.prepare_human_for_preview(dummy)
+ COMPILE_OVERLAYS(dummy)
+
+ var/icon/dummy_icon = getFlatIcon(dummy)
+ dummy_icon.Scale(64, 64)
+ dummy_icon.Crop(15, 64, 15 + 31, 64 - 31)
+ dummy_icon.Scale(64, 64)
+ to_insert[sanitize_css_class_name(initial(species_type.name))] = dummy_icon
+
+ SSatoms.prepare_deletion(dummy)
+
+ for (var/spritesheet_key in to_insert)
+ Insert(spritesheet_key, to_insert[spritesheet_key])
diff --git a/code/modules/client/preferences/names.dm b/code/modules/client/preferences/names.dm
new file mode 100644
index 000000000000..35281f17ccb3
--- /dev/null
+++ b/code/modules/client/preferences/names.dm
@@ -0,0 +1,144 @@
+/// A preference for a name. Used not just for normal names, but also for clown names, etc.
+/datum/preference/name
+ category = "names"
+ priority = PREFERENCE_PRIORITY_NAMES
+ savefile_identifier = PREFERENCE_CHARACTER
+ abstract_type = /datum/preference/name
+
+ /// The display name when showing on the "other names" panel
+ var/explanation
+
+ /// These will be grouped together on the preferences menu
+ var/group
+
+ /// Whether or not to allow numbers in the person's name
+ var/allow_numbers = FALSE
+
+ /// If the highest priority job matches this, will prioritize this name in the UI
+ var/relevant_job
+
+/datum/preference/name/apply_to_human(mob/living/carbon/human/target, value)
+ // Only real_name applies directly, everything else is applied by something else
+ return
+
+/datum/preference/name/deserialize(input, datum/preferences/preferences)
+ return reject_bad_name("[input]", allow_numbers)
+
+/datum/preference/name/serialize(input)
+ // `is_valid` should always be run before `serialize`, so it should not
+ // be possible for this to return `null`.
+ return reject_bad_name(input, allow_numbers)
+
+/datum/preference/name/is_valid(value)
+ return istext(value) && !isnull(reject_bad_name(value, allow_numbers))
+
+/// A character's real name
+/datum/preference/name/real_name
+ explanation = "Name"
+ // The `_` makes it first in ABC order.
+ group = "_real_name"
+ savefile_key = "real_name"
+
+/datum/preference/name/real_name/apply_to_human(mob/living/carbon/human/target, value)
+ target.real_name = value
+ target.name = value
+
+/datum/preference/name/real_name/create_informed_default_value(datum/preferences/preferences)
+ var/species_type = preferences.read_preference(/datum/preference/choiced/species)
+ var/gender = preferences.read_preference(/datum/preference/choiced/gender)
+
+ var/datum/species/species = new species_type
+
+ return species.random_name(gender, unique = TRUE)
+
+/datum/preference/name/real_name/deserialize(input, datum/preferences/preferences)
+ input = ..(input)
+ if (!input)
+ return input
+
+ if (CONFIG_GET(flag/humans_need_surnames) && preferences.read_preference(/datum/preference/choiced/species) == /datum/species/human)
+ var/first_space = findtext(input, " ")
+ if(!first_space) //we need a surname
+ input += " [pick(GLOB.last_names)]"
+ else if(first_space == length(input))
+ input += "[pick(GLOB.last_names)]"
+
+ return reject_bad_name(input, allow_numbers)
+
+/// The name for a backup human, when nonhumans are made into head of staff
+/datum/preference/name/backup_human
+ explanation = "Backup human name"
+ group = "backup_human"
+ savefile_key = "human_name"
+
+/datum/preference/name/backup_human/create_informed_default_value(datum/preferences/preferences)
+ var/gender = preferences.read_preference(/datum/preference/choiced/gender)
+
+ return random_unique_name(gender)
+
+/datum/preference/name/clown
+ savefile_key = "clown_name"
+
+ explanation = "Clown name"
+ group = "fun"
+ relevant_job = /datum/job/clown
+
+/datum/preference/name/clown/create_default_value()
+ return pick(GLOB.clown_names)
+
+/datum/preference/name/mime
+ savefile_key = "mime_name"
+
+ explanation = "Mime name"
+ group = "fun"
+ relevant_job = /datum/job/mime
+
+/datum/preference/name/mime/create_default_value()
+ return pick(GLOB.mime_names)
+
+/datum/preference/name/cyborg
+ savefile_key = "cyborg_name"
+
+ allow_numbers = TRUE
+ can_randomize = FALSE
+
+ explanation = "Cyborg name"
+ group = "silicons"
+ relevant_job = /datum/job/cyborg
+
+/datum/preference/name/cyborg/create_default_value()
+ return DEFAULT_CYBORG_NAME
+
+/datum/preference/name/ai
+ savefile_key = "ai_name"
+
+ allow_numbers = TRUE
+ explanation = "AI name"
+ group = "silicons"
+ relevant_job = /datum/job/ai
+
+/datum/preference/name/ai/create_default_value()
+ return pick(GLOB.ai_names)
+
+/datum/preference/name/religion
+ savefile_key = "religion_name"
+
+ allow_numbers = TRUE
+
+ explanation = "Religion name"
+ group = "religion"
+
+/datum/preference/name/religion/create_default_value()
+ return DEFAULT_RELIGION
+
+/datum/preference/name/deity
+ savefile_key = "deity_name"
+
+ allow_numbers = TRUE
+ can_randomize = FALSE
+
+ explanation = "Deity name"
+ group = "religion"
+
+/datum/preference/name/deity/create_default_value()
+ return DEFAULT_DEITY
diff --git a/code/modules/client/preferences/ooc.dm b/code/modules/client/preferences/ooc.dm
new file mode 100644
index 000000000000..de436391edc9
--- /dev/null
+++ b/code/modules/client/preferences/ooc.dm
@@ -0,0 +1,14 @@
+/// The color admins will speak in for OOC.
+/datum/preference/color/ooc_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "ooccolor"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/color/ooc_color/create_default_value()
+ return "#c43b23"
+
+/datum/preference/color/ooc_color/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return is_admin(preferences.parent) || preferences.unlock_content
diff --git a/code/modules/client/preferences/parallax.dm b/code/modules/client/preferences/parallax.dm
new file mode 100644
index 000000000000..24cccce2da62
--- /dev/null
+++ b/code/modules/client/preferences/parallax.dm
@@ -0,0 +1,38 @@
+/// Determines parallax, "fancy space"
+/datum/preference/choiced/parallax
+ savefile_key = "parallax"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/parallax/init_possible_values()
+ return list(
+ PARALLAX_INSANE,
+ PARALLAX_HIGH,
+ PARALLAX_MED,
+ PARALLAX_LOW,
+ PARALLAX_DISABLE,
+ )
+
+/datum/preference/choiced/parallax/create_default_value()
+ return PARALLAX_HIGH
+
+/datum/preference/choiced/parallax/apply_to_client(client/client, value)
+ client.mob?.hud_used?.update_parallax_pref(client?.mob)
+
+/datum/preference/choiced/parallax/deserialize(input, datum/preferences/preferences)
+ // Old preferences were numbers, which causes annoyances when
+ // sending over as lists that isn't worth dealing with.
+ if (isnum(input))
+ switch (input)
+ if (-1)
+ input = PARALLAX_INSANE
+ if (0)
+ input = PARALLAX_HIGH
+ if (1)
+ input = PARALLAX_MED
+ if (2)
+ input = PARALLAX_LOW
+ if (3)
+ input = PARALLAX_DISABLE
+
+ return ..(input)
diff --git a/code/modules/client/preferences/pda.dm b/code/modules/client/preferences/pda.dm
new file mode 100644
index 000000000000..fcbf4f1ad4be
--- /dev/null
+++ b/code/modules/client/preferences/pda.dm
@@ -0,0 +1,17 @@
+/// The color of a PDA
+/datum/preference/color/pda_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pda_color"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/color/pda_color/create_default_value()
+ return COLOR_OLIVE
+
+/// The visual style of a PDA
+/datum/preference/choiced/pda_style
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pda_style"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/pda_style/init_possible_values()
+ return GLOB.pda_styles
diff --git a/code/modules/client/preferences/pixel_size.dm b/code/modules/client/preferences/pixel_size.dm
new file mode 100644
index 000000000000..cb166e0139ac
--- /dev/null
+++ b/code/modules/client/preferences/pixel_size.dm
@@ -0,0 +1,15 @@
+/datum/preference/numeric/pixel_size
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pixel_size"
+ savefile_identifier = PREFERENCE_PLAYER
+
+ minimum = 0
+ maximum = 3
+
+ step = 0.5
+
+/datum/preference/numeric/pixel_size/create_default_value()
+ return 0
+
+/datum/preference/numeric/pixel_size/apply_to_client(client/client, value)
+ client?.view_size?.resetFormat()
diff --git a/code/modules/client/preferences/preferred_map.dm b/code/modules/client/preferences/preferred_map.dm
new file mode 100644
index 000000000000..8c9f87e82322
--- /dev/null
+++ b/code/modules/client/preferences/preferred_map.dm
@@ -0,0 +1,52 @@
+/// During map rotation, this will help determine the chosen map.
+/datum/preference/choiced/preferred_map
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "preferred_map"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/preferred_map/init_possible_values()
+ var/list/maps = list()
+ maps += ""
+
+ for (var/map in config.maplist)
+ var/datum/map_config/map_config = config.maplist[map]
+ if (!map_config.votable)
+ continue
+
+ maps += map
+
+ return maps
+
+/datum/preference/choiced/preferred_map/create_default_value()
+ return ""
+
+/datum/preference/choiced/preferred_map/compile_constant_data()
+ var/list/data = ..()
+
+ var/display_names = list()
+
+ if (config.defaultmap)
+ display_names[""] = "Default ([config.defaultmap.map_name])"
+ else
+ display_names[""] = "Default"
+
+ for (var/choice in get_choices())
+ if (choice == "")
+ continue
+
+ var/datum/map_config/map_config = config.maplist[choice]
+
+ var/map_name = map_config.map_name
+ if (map_config.voteweight <= 0)
+ map_name += " (disabled)"
+ display_names[choice] = map_name
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = display_names
+
+ return data
+
+/datum/preference/choiced/preferred_map/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return CONFIG_GET(flag/preference_map_voting)
diff --git a/code/modules/client/preferences/random.dm b/code/modules/client/preferences/random.dm
new file mode 100644
index 000000000000..e8555e847487
--- /dev/null
+++ b/code/modules/client/preferences/random.dm
@@ -0,0 +1,53 @@
+/datum/preference/choiced/random_body
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "random_body"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/random_body/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/random_body/init_possible_values()
+ return list(
+ RANDOM_ANTAG_ONLY,
+ RANDOM_DISABLED,
+ RANDOM_ENABLED,
+ )
+
+/datum/preference/choiced/random_body/create_default_value()
+ return RANDOM_DISABLED
+
+/*/datum/preference/toggle/random_hardcore
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "random_hardcore"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+ default_value = FALSE
+
+/datum/preference/toggle/random_hardcore/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/toggle/random_hardcore/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return preferences.parent.get_exp_living(pure_numeric = TRUE) >= PLAYTIME_HARDCORE_RANDOM
+*/
+/datum/preference/choiced/random_name
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "random_name"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/random_name/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/random_name/init_possible_values()
+ return list(
+ RANDOM_ANTAG_ONLY,
+ RANDOM_DISABLED,
+ RANDOM_ENABLED,
+ )
+
+/datum/preference/choiced/random_name/create_default_value()
+ return RANDOM_DISABLED
diff --git a/code/modules/client/preferences/runechat.dm b/code/modules/client/preferences/runechat.dm
new file mode 100644
index 000000000000..83282fefe36c
--- /dev/null
+++ b/code/modules/client/preferences/runechat.dm
@@ -0,0 +1,25 @@
+/datum/preference/toggle/enable_runechat
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "chat_on_map"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/enable_runechat_non_mobs
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "see_chat_non_mob"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/see_rc_emotes
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "see_rc_emotes"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/numeric/max_chat_length
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "max_chat_length"
+ savefile_identifier = PREFERENCE_PLAYER
+
+ minimum = 1
+ maximum = CHAT_MESSAGE_MAX_LENGTH
+
+/datum/preference/numeric/max_chat_length/create_default_value()
+ return CHAT_MESSAGE_MAX_LENGTH
diff --git a/code/modules/client/preferences/scaling_method.dm b/code/modules/client/preferences/scaling_method.dm
new file mode 100644
index 000000000000..63235abaf996
--- /dev/null
+++ b/code/modules/client/preferences/scaling_method.dm
@@ -0,0 +1,14 @@
+/// The scaling method to show the world in, e.g. nearest neighbor
+/datum/preference/choiced/scaling_method
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "scaling_method"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/scaling_method/create_default_value()
+ return SCALING_METHOD_DISTORT
+
+/datum/preference/choiced/scaling_method/init_possible_values()
+ return list(SCALING_METHOD_DISTORT, SCALING_METHOD_BLUR, SCALING_METHOD_NORMAL)
+
+/datum/preference/choiced/scaling_method/apply_to_client(client/client, value)
+ client?.view_size?.setZoomMode()
diff --git a/code/modules/client/preferences/security_department.dm b/code/modules/client/preferences/security_department.dm
new file mode 100644
index 000000000000..e0f1eee1b1d9
--- /dev/null
+++ b/code/modules/client/preferences/security_department.dm
@@ -0,0 +1,22 @@
+/// Which department to put security officers in
+/datum/preference/choiced/security_department
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ can_randomize = FALSE
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "prefered_security_department"
+
+// This is what that #warn wants you to remove :)
+/datum/preference/choiced/security_department/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.security_depts_prefs))
+ return SEC_DEPT_NONE
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/security_department/init_possible_values()
+ return GLOB.security_depts_prefs
+
+/datum/preference/choiced/security_department/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/security_department/create_default_value()
+ return SEC_DEPT_NONE
diff --git a/code/modules/client/preferences/skillcape.dm b/code/modules/client/preferences/skillcape.dm
new file mode 100644
index 000000000000..5b8d05f34eda
--- /dev/null
+++ b/code/modules/client/preferences/skillcape.dm
@@ -0,0 +1,40 @@
+/*
+// TODO: Implement
+/datum/preference/choiced/skillcape
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ can_randomize = FALSE
+ savefile_identifier = PREFERENCE_PLAYER
+ savefile_key = "skillcape"
+
+/datum/preference/choiced/skillcape/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.skillcapes))
+ return "None"
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/skillcape/init_possible_values()
+ // TODO: I dont know if there is a way to filter it per client
+ var/list/selectablecapes = list()
+ var/max_eligable = TRUE
+ for(var/id in GLOB.skillcapes)
+ var/datum/skillcape/A = GLOB.skillcapes[id]
+ if(!A.job)
+ continue
+
+ var/my_exp = C.calc_exp_type(get_exp_req_type())
+ if(user.client.prefs.exp[A.job] >= A.minutes)
+ selectablecapes += A
+ else
+ max_eligable = FALSE
+
+ if(max_eligable)
+ selectablecapes += GLOB.skillcapes["max"]
+
+ return selectablecapes
+
+/datum/preference/choiced/skillcape/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/skillcape/create_default_value()
+ return "None"
+*/
diff --git a/code/modules/client/preferences/skin_tone.dm b/code/modules/client/preferences/skin_tone.dm
new file mode 100644
index 000000000000..70aebc3521ec
--- /dev/null
+++ b/code/modules/client/preferences/skin_tone.dm
@@ -0,0 +1,36 @@
+/datum/preference/choiced/skin_tone
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "skin_tone"
+
+/datum/preference/choiced/skin_tone/init_possible_values()
+ return GLOB.skin_tones
+
+/datum/preference/choiced/skin_tone/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = GLOB.skin_tone_names
+
+ var/list/to_hex = list()
+ for (var/choice in get_choices())
+ var/hex_value = skintone2hex(choice)
+ var/list/hsl = rgb2num("#[hex_value]", COLORSPACE_HSL)
+
+ to_hex[choice] = list(
+ "lightness" = hsl[3],
+ "value" = hex_value,
+ )
+
+ data["to_hex"] = to_hex
+
+ return data
+
+/datum/preference/choiced/skin_tone/apply_to_human(mob/living/carbon/human/target, value)
+ target.skin_tone = value
+
+/datum/preference/choiced/skin_tone/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ var/datum/species/species_type = preferences.read_preference(/datum/preference/choiced/species)
+ return initial(species_type.use_skintones)
diff --git a/code/modules/client/preferences/species.dm b/code/modules/client/preferences/species.dm
new file mode 100644
index 000000000000..2ad93c3b14bf
--- /dev/null
+++ b/code/modules/client/preferences/species.dm
@@ -0,0 +1,52 @@
+/// Species preference
+/datum/preference/choiced/species
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "species"
+ priority = PREFERENCE_PRIORITY_SPECIES
+ randomize_by_default = FALSE
+
+/datum/preference/choiced/species/deserialize(input, datum/preferences/preferences)
+ return GLOB.species_list[sanitize_inlist(input, get_choices_serialized(), "human")]
+
+/datum/preference/choiced/species/serialize(input)
+ var/datum/species/species = input
+ return initial(species.id)
+
+/datum/preference/choiced/species/create_default_value()
+ return /datum/species/human
+
+/datum/preference/choiced/species/create_random_value(datum/preferences/preferences)
+ return pick(get_choices())
+
+/datum/preference/choiced/species/init_possible_values()
+ var/list/values = list()
+
+ for (var/species_id in get_selectable_species())
+ values += GLOB.species_list[species_id]
+
+ return values
+
+/datum/preference/choiced/species/apply_to_human(mob/living/carbon/human/target, value)
+ target.set_species(value, icon_update = FALSE, pref_load = TRUE)
+
+/datum/preference/choiced/species/compile_constant_data()
+ var/list/data = list()
+
+ for (var/species_id in get_selectable_species())
+ var/species_type = GLOB.species_list[species_id]
+ var/datum/species/species = new species_type()
+
+ data[species_id] = list()
+ data[species_id]["name"] = species.name
+ data[species_id]["desc"] = species.get_species_description()
+ data[species_id]["lore"] = species.get_species_lore()
+ data[species_id]["icon"] = sanitize_css_class_name(species.name)
+ data[species_id]["use_skintones"] = species.use_skintones
+ data[species_id]["sexes"] = species.sexes
+ data[species_id]["enabled_features"] = species.get_features()
+ data[species_id]["perks"] = species.get_species_perks()
+ data[species_id]["diet"] = species.get_species_diet()
+
+ qdel(species)
+
+ return data
diff --git a/code/modules/client/preferences/species_features/basic.dm b/code/modules/client/preferences/species_features/basic.dm
new file mode 100644
index 000000000000..aa5b1f243540
--- /dev/null
+++ b/code/modules/client/preferences/species_features/basic.dm
@@ -0,0 +1,101 @@
+/proc/generate_possible_values_for_sprite_accessories_on_head(accessories)
+ var/list/values = possible_values_for_sprite_accessory_list(accessories)
+
+ var/icon/head_icon = icon('icons/mob/human_parts_greyscale.dmi', "human_head_m")
+ head_icon.Blend("#[skintone2hex("caucasian1")]", ICON_MULTIPLY)
+
+ for (var/name in values)
+ var/datum/sprite_accessory/accessory = accessories[name]
+ if (accessory == null || accessory.icon_state == null)
+ continue
+
+ var/icon/final_icon = new(head_icon)
+
+ var/icon/beard_icon = values[name]
+ beard_icon.Blend(COLOR_DARK_BROWN, ICON_MULTIPLY)
+ final_icon.Blend(beard_icon, ICON_OVERLAY)
+
+ final_icon.Crop(10, 19, 22, 31)
+ final_icon.Scale(32, 32)
+
+ values[name] = final_icon
+
+ return values
+
+/datum/preference/color_legacy/eye_color
+ savefile_key = "eye_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_species_trait = EYECOLOR
+
+/datum/preference/color_legacy/eye_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.eye_color = value
+
+ var/obj/item/organ/eyes/eyes_organ = target.getorgan(/obj/item/organ/eyes)
+ if (istype(eyes_organ))
+ if (!initial(eyes_organ.eye_color))
+ eyes_organ.eye_color = value
+ eyes_organ.old_eye_color = value
+
+/datum/preference/color_legacy/eye_color/create_default_value()
+ return random_eye_color()
+
+/datum/preference/choiced/facial_hairstyle
+ savefile_key = "facial_style_name"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Facial hair"
+ should_generate_icons = TRUE
+ relevant_species_trait = FACEHAIR
+
+/datum/preference/choiced/facial_hairstyle/init_possible_values()
+ return generate_possible_values_for_sprite_accessories_on_head(GLOB.hair_styles_list)
+
+/datum/preference/choiced/facial_hairstyle/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_style = value
+
+/datum/preference/choiced/facial_hairstyle/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "facial_hair_color"
+
+ return data
+
+/datum/preference/color_legacy/facial_hair_color
+ savefile_key = "facial_hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_species_trait = FACEHAIR
+
+/datum/preference/color_legacy/facial_hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.facial_hair_color = value
+
+/datum/preference/color_legacy/hair_color
+ savefile_key = "hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_species_trait = HAIR
+
+/datum/preference/color_legacy/hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_color = value
+
+/datum/preference/choiced/hairstyle
+ savefile_key = "hairstyle_name"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Hairstyle"
+ should_generate_icons = TRUE
+ relevant_species_trait = HAIR
+
+/datum/preference/choiced/hairstyle/init_possible_values()
+ return generate_possible_values_for_sprite_accessories_on_head(GLOB.hair_styles_list)
+
+/datum/preference/choiced/hairstyle/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_style = value
+
+/datum/preference/choiced/hairstyle/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "hair_color"
+
+ return data
diff --git a/code/modules/client/preferences/species_features/ethereal.dm b/code/modules/client/preferences/species_features/ethereal.dm
new file mode 100644
index 000000000000..5a0ac1e5be5f
--- /dev/null
+++ b/code/modules/client/preferences/species_features/ethereal.dm
@@ -0,0 +1,33 @@
+/datum/preference/choiced/ethereal_color
+ savefile_key = "feature_ethcolor"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Ethereal color"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ethereal_color/init_possible_values()
+ var/list/values = list()
+
+ var/icon/ethereal_base = icon('icons/mob/human_parts_greyscale.dmi', "ethereal_head_m")
+ ethereal_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "ethereal_chest_m"), ICON_OVERLAY)
+ ethereal_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "ethereal_l_arm"), ICON_OVERLAY)
+ ethereal_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "ethereal_r_arm"), ICON_OVERLAY)
+
+ var/icon/eyes = icon('icons/mob/human_face.dmi', "eyes")
+ eyes.Blend(COLOR_BLACK, ICON_MULTIPLY)
+ ethereal_base.Blend(eyes, ICON_OVERLAY)
+
+ ethereal_base.Scale(64, 64)
+ ethereal_base.Crop(15, 64, 15 + 31, 64 - 31)
+
+ for (var/name in GLOB.color_list_ethereal)
+ var/color = GLOB.color_list_ethereal[name]
+
+ var/icon/icon = new(ethereal_base)
+ icon.Blend("#[color]", ICON_MULTIPLY)
+ values[name] = icon
+
+ return values
+
+/datum/preference/choiced/ethereal_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ethcolor"] = GLOB.color_list_ethereal[value]
diff --git a/code/modules/client/preferences/species_features/felinid.dm b/code/modules/client/preferences/species_features/felinid.dm
new file mode 100644
index 000000000000..bc5445cd2fd0
--- /dev/null
+++ b/code/modules/client/preferences/species_features/felinid.dm
@@ -0,0 +1,33 @@
+/datum/preference/choiced/tail_human
+ savefile_key = "feature_human_tail"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ can_randomize = FALSE
+ relevant_mutant_bodypart = "tail_human"
+
+/datum/preference/choiced/tail_human/init_possible_values()
+ return assoc_to_keys(GLOB.tails_list_human)
+
+/datum/preference/choiced/tail_human/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["tail_human"] = value
+
+/datum/preference/choiced/tail_human/create_default_value()
+ var/datum/sprite_accessory/tails/human/cat/tail = /datum/sprite_accessory/tails/human/cat
+ return initial(tail.name)
+
+/datum/preference/choiced/ears
+ savefile_key = "feature_human_ears"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ can_randomize = FALSE
+ relevant_mutant_bodypart = "ears"
+
+/datum/preference/choiced/ears/init_possible_values()
+ return assoc_to_keys(GLOB.ears_list)
+
+/datum/preference/choiced/ears/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ears"] = value
+
+/datum/preference/choiced/ears/create_default_value()
+ var/datum/sprite_accessory/ears/cat/ears = /datum/sprite_accessory/ears/cat
+ return initial(ears.name)
diff --git a/code/modules/client/preferences/species_features/lizard.dm b/code/modules/client/preferences/species_features/lizard.dm
new file mode 100644
index 000000000000..732639fd63ee
--- /dev/null
+++ b/code/modules/client/preferences/species_features/lizard.dm
@@ -0,0 +1,141 @@
+/proc/generate_lizard_side_shots(list/sprite_accessories, key, include_snout = TRUE)
+ var/list/values = list()
+
+ var/icon/lizard = icon('icons/mob/human_parts_greyscale.dmi', "lizard_head_m", EAST)
+
+ var/icon/eyes = icon('icons/mob/human_face.dmi', "eyes", EAST)
+ eyes.Blend(COLOR_GRAY, ICON_MULTIPLY)
+ lizard.Blend(eyes, ICON_OVERLAY)
+
+ if (include_snout)
+ lizard.Blend(icon('icons/mob/mutant_bodyparts.dmi', "m_snout_round_ADJ", EAST), ICON_OVERLAY)
+
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+
+ var/icon/final_icon = icon(lizard)
+
+ if (sprite_accessory.icon_state != "none")
+ var/icon/accessory_icon = icon(sprite_accessory.icon, "m_[key]_[sprite_accessory.icon_state]_ADJ", EAST)
+ final_icon.Blend(accessory_icon, ICON_OVERLAY)
+
+ final_icon.Crop(11, 20, 23, 32)
+ final_icon.Scale(32, 32)
+ final_icon.Blend(COLOR_VIBRANT_LIME, ICON_MULTIPLY)
+
+ values[name] = final_icon
+
+ return values
+
+/datum/preference/choiced/lizard_body_markings
+ savefile_key = "feature_lizard_body_markings"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Body markings"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "body_markings"
+
+/datum/preference/choiced/lizard_body_markings/init_possible_values()
+ var/list/values = list()
+
+ var/icon/lizard = icon('icons/mob/human_parts_greyscale.dmi', "lizard_chest_m")
+
+ for (var/name in GLOB.body_markings_list)
+ var/datum/sprite_accessory/sprite_accessory = GLOB.body_markings_list[name]
+
+ var/icon/final_icon = icon(lizard)
+
+ if (sprite_accessory.icon_state != "none")
+ var/icon/body_markings_icon = icon(
+ 'icons/mob/mutant_bodyparts.dmi',
+ "m_body_markings_[sprite_accessory.icon_state]_ADJ",
+ )
+
+ final_icon.Blend(body_markings_icon, ICON_OVERLAY)
+
+ final_icon.Blend(COLOR_VIBRANT_LIME, ICON_MULTIPLY)
+ final_icon.Crop(10, 8, 22, 23)
+ final_icon.Scale(26, 32)
+ final_icon.Crop(-2, 1, 29, 32)
+
+ values[name] = final_icon
+
+ return values
+
+/datum/preference/choiced/lizard_body_markings/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["body_markings"] = value
+
+/datum/preference/choiced/lizard_frills
+ savefile_key = "feature_lizard_frills"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Frills"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/lizard_frills/init_possible_values()
+ return generate_lizard_side_shots(GLOB.frills_list, "frills")
+
+/datum/preference/choiced/lizard_frills/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["frills"] = value
+
+/datum/preference/choiced/lizard_horns
+ savefile_key = "feature_lizard_horns"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Horns"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/lizard_horns/init_possible_values()
+ return generate_lizard_side_shots(GLOB.horns_list, "horns")
+
+/datum/preference/choiced/lizard_horns/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["horns"] = value
+
+/datum/preference/choiced/lizard_legs
+ savefile_key = "feature_lizard_legs"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_mutant_bodypart = "legs"
+
+/datum/preference/choiced/lizard_legs/init_possible_values()
+ return assoc_to_keys(GLOB.legs_list)
+
+/datum/preference/choiced/lizard_legs/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["legs"] = value
+
+/datum/preference/choiced/lizard_snout
+ savefile_key = "feature_lizard_snout"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Snout"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/lizard_snout/init_possible_values()
+ return generate_lizard_side_shots(GLOB.snouts_list, "snout", include_snout = FALSE)
+
+/datum/preference/choiced/lizard_snout/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["snout"] = value
+
+/datum/preference/choiced/lizard_spines
+ savefile_key = "feature_lizard_spines"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_mutant_bodypart = "spines"
+
+/datum/preference/choiced/lizard_spines/init_possible_values()
+ return assoc_to_keys(GLOB.spines_list)
+
+/datum/preference/choiced/lizard_spines/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["spines"] = value
+
+/datum/preference/choiced/lizard_tail
+ savefile_key = "feature_lizard_tail"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_mutant_bodypart = "tail_lizard"
+
+/datum/preference/choiced/lizard_tail/init_possible_values()
+ return assoc_to_keys(GLOB.tails_list_lizard)
+
+/datum/preference/choiced/lizard_tail/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["tail_lizard"] = value
diff --git a/code/modules/client/preferences/species_features/moth.dm b/code/modules/client/preferences/species_features/moth.dm
new file mode 100644
index 000000000000..a2042304ff00
--- /dev/null
+++ b/code/modules/client/preferences/species_features/moth.dm
@@ -0,0 +1,22 @@
+/datum/preference/choiced/moth_wings
+ savefile_key = "feature_moth_wings"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Moth wings"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/moth_wings/init_possible_values()
+ var/list/icon/values = possible_values_for_sprite_accessory_list_for_body_part(
+ GLOB.moth_wings_list,
+ "moth_wings",
+ list("BEHIND", "FRONT"),
+ )
+
+ // Moth wings are in a stupid dimension
+ for (var/name in values)
+ values[name].Crop(1, 1, 32, 32)
+
+ return values
+
+/datum/preference/choiced/moth_wings/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["moth_wings"] = value
diff --git a/code/modules/client/preferences/species_features/mutants.dm b/code/modules/client/preferences/species_features/mutants.dm
new file mode 100644
index 000000000000..3d79bcb5c342
--- /dev/null
+++ b/code/modules/client/preferences/species_features/mutants.dm
@@ -0,0 +1,20 @@
+/datum/preference/color_legacy/mutant_color
+ savefile_key = "feature_mcolor"
+ savefile_identifier = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_species_trait = MUTCOLORS
+
+/datum/preference/color_legacy/mutant_color/create_default_value()
+ return sanitize_hexcolor("[pick("7F", "FF")][pick("7F", "FF")][pick("7F", "FF")]")
+
+/datum/preference/color_legacy/mutant_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["mcolor"] = value
+
+/datum/preference/color_legacy/mutant_color/is_valid(value)
+ if (!..(value))
+ return FALSE
+
+ if (is_color_dark(expand_three_digit_color(value)))
+ return FALSE
+
+ return TRUE
diff --git a/code/modules/client/preferences/tgui_prefs_migration.dm b/code/modules/client/preferences/tgui_prefs_migration.dm
new file mode 100644
index 000000000000..3b416fd4c1bf
--- /dev/null
+++ b/code/modules/client/preferences/tgui_prefs_migration.dm
@@ -0,0 +1,114 @@
+/// Handle the migrations necessary from pre-tgui prefs to post-tgui prefs
+/datum/preferences/proc/migrate_preferences_to_tgui_prefs_menu()
+ //migrate_antagonists()
+ migrate_key_bindings()
+
+/// Handle the migrations necessary from pre-tgui prefs to post-tgui prefs, for characters
+/datum/preferences/proc/migrate_character_to_tgui_prefs_menu()
+ migrate_randomization()
+
+// Key bindings used to be "key" -> list("action"),
+// such as "X" -> list("swap_hands").
+// This made it impossible to determine any order, meaning placing a new
+// hotkey would produce non-deterministic order.
+// tgui prefs menu moves this over to "swap_hands" -> list("X").
+/datum/preferences/proc/migrate_key_bindings()
+ var/new_key_bindings = list()
+
+ for (var/unbound_hotkey in key_bindings["Unbound"])
+ new_key_bindings[unbound_hotkey] = list()
+
+ for (var/hotkey in key_bindings)
+ if (hotkey == "Unbound")
+ continue
+
+ for (var/keybind in key_bindings[hotkey])
+ if (keybind in new_key_bindings)
+ new_key_bindings[keybind] |= hotkey
+ else
+ new_key_bindings[keybind] = list(hotkey)
+
+ key_bindings = new_key_bindings
+
+/*// Before tgui preferences menu, "traitor" would handle both roundstart, midround, and latejoin.
+// These were split apart.
+/datum/preferences/proc/migrate_antagonists()
+ migrate_antagonist(ROLE_HERETIC, list(ROLE_HERETIC_SMUGGLER))
+ migrate_antagonist(ROLE_MALF, list(ROLE_MALF_MIDROUND))
+ migrate_antagonist(ROLE_OPERATIVE, list(ROLE_OPERATIVE_MIDROUND, ROLE_LONE_OPERATIVE))
+ migrate_antagonist(ROLE_REV_HEAD, list(ROLE_PROVOCATEUR))
+ migrate_antagonist(ROLE_TRAITOR, list(ROLE_SYNDICATE_INFILTRATOR, ROLE_SLEEPER_AGENT))
+ migrate_antagonist(ROLE_WIZARD, list(ROLE_WIZARD_MIDROUND))
+
+ // "Familes [sic] Antagonists" was the old name of the catch-all.
+ migrate_antagonist("Familes Antagonists", list(ROLE_FAMILIES, ROLE_FAMILY_HEAD_ASPIRANT))
+
+/datum/preferences/proc/migrate_antagonist(will_exist, list/to_add)
+ if (will_exist in be_special)
+ for (var/add in to_add)
+ be_special += add
+*/
+
+// Randomization used to be an assoc list of fields to TRUE.
+// Antagonist randomization was not even available to all options.
+// tgui prefs menu changes from list("random_socks" = TRUE, "random_name_antag" = TRUE)
+// to list("socks" = "enabled", "name" = "antag")
+// as well as removing anything that was set to FALSE, as this can be extrapolated.
+/datum/preferences/proc/migrate_randomization()
+ var/static/list/random_settings = list(
+ "random_age" = "age",
+ "random_backpack" = "backpack",
+ "random_eye_color" = "eye_color",
+ "random_facial_hair_color" = "facial_hair_color",
+ "random_facial_hairstyle" = "facial_hairstyle",
+ "random_gender" = "gender",
+ "random_hair_color" = "hair_color",
+ "random_hairstyle" = "hairstyle",
+ "random_jumpsuit_style" = "jumpsuit_style",
+ "random_skin_tone" = "skin_tone",
+ "random_socks" = "socks",
+ "random_species" = "species",
+ "random_undershirt" = "undershirt",
+ "random_underwear" = "underwear",
+ "random_underwear_color" = "underwear_color",
+ )
+
+ var/static/list/random_antag_settings = list(
+ "random_age_antag" = "age",
+ "random_gender_antag" = "gender",
+ "random_name_antag" = "name",
+ )
+
+ var/list/new_randomise = list()
+
+ for (var/old_setting in random_settings)
+ if (randomise[old_setting])
+ new_randomise[random_settings[old_setting]] = RANDOM_ENABLED
+
+ for (var/old_antag_setting in random_antag_settings)
+ if (randomise[old_antag_setting])
+ new_randomise[random_settings[old_antag_setting]] = RANDOM_ANTAG_ONLY
+
+ migrate_randomization_to_new_pref(
+ /datum/preference/choiced/random_body,
+ "random_body",
+ "random_body_antag",
+ )
+
+ migrate_randomization_to_new_pref(
+ /datum/preference/choiced/random_name,
+ "random_name",
+ "random_name_antag",
+ )
+
+ randomise = new_randomise
+
+/datum/preferences/proc/migrate_randomization_to_new_pref(
+ preference_type,
+ key,
+ key_antag,
+)
+ if (randomise[key_antag])
+ write_preference(GLOB.preference_entries[preference_type], RANDOM_ANTAG_ONLY)
+ else if (randomise[key])
+ write_preference(GLOB.preference_entries[preference_type], RANDOM_ENABLED)
diff --git a/code/modules/client/preferences/ui_style.dm b/code/modules/client/preferences/ui_style.dm
new file mode 100644
index 000000000000..08f1af6c7dd5
--- /dev/null
+++ b/code/modules/client/preferences/ui_style.dm
@@ -0,0 +1,26 @@
+/// UI style preference
+/datum/preference/choiced/ui_style
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_identifier = PREFERENCE_PLAYER
+ savefile_key = "UI_style"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ui_style/init_possible_values()
+ var/list/values = list()
+
+ for (var/style in GLOB.available_ui_styles)
+ var/icon/icons = GLOB.available_ui_styles[style]
+
+ var/icon/icon = icon(icons, "hand_r")
+ icon.Crop(1, 1, world.icon_size * 2, world.icon_size)
+ icon.Blend(icon(icons, "hand_l"), ICON_OVERLAY, world.icon_size)
+
+ values[style] = icon
+
+ return values
+
+/datum/preference/choiced/ui_style/create_default_value()
+ return GLOB.available_ui_styles[1]
+
+/datum/preference/choiced/ui_style/apply_to_client(client/client, value)
+ client.mob?.hud_used?.update_ui_style(ui_style2icon(value))
diff --git a/code/modules/client/preferences/uplink_location.dm b/code/modules/client/preferences/uplink_location.dm
new file mode 100644
index 000000000000..8ae892bb4024
--- /dev/null
+++ b/code/modules/client/preferences/uplink_location.dm
@@ -0,0 +1,26 @@
+/datum/preference/choiced/uplink_location
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "uplink_loc"
+ can_randomize = FALSE
+
+/datum/preference/choiced/uplink_location/init_possible_values()
+ return list(UPLINK_PDA, UPLINK_RADIO, UPLINK_PEN, UPLINK_IMPLANT)
+
+/datum/preference/choiced/uplink_location/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = list(
+ UPLINK_PDA = "PDA",
+ UPLINK_RADIO = "Radio",
+ UPLINK_PEN = "Pen",
+ UPLINK_IMPLANT = "Implant ([UPLINK_IMPLANT_TELECRYSTAL_COST]TC)",
+ )
+
+ return data
+
+/datum/preference/choiced/uplink_location/create_default_value()
+ return UPLINK_PDA
+
+/datum/preference/choiced/uplink_location/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/widescreen.dm b/code/modules/client/preferences/widescreen.dm
new file mode 100644
index 000000000000..1041a4f6f272
--- /dev/null
+++ b/code/modules/client/preferences/widescreen.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/widescreen
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "widescreenpref"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/widescreen/apply_to_client(client/client, value)
+ client.view_size?.setDefault(getScreenSize(value))
diff --git a/code/modules/client/preferences/window_flashing.dm b/code/modules/client/preferences/window_flashing.dm
new file mode 100644
index 000000000000..687315387c63
--- /dev/null
+++ b/code/modules/client/preferences/window_flashing.dm
@@ -0,0 +1,5 @@
+/// Enables flashing the window in your task tray for important events
+/datum/preference/toggle/window_flashing
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "windowflashing"
+ savefile_identifier = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences_menu.dm b/code/modules/client/preferences_menu.dm
new file mode 100644
index 000000000000..d3c05e6dd145
--- /dev/null
+++ b/code/modules/client/preferences_menu.dm
@@ -0,0 +1,25 @@
+/datum/verbs/menu/Preferences/verb/open_character_preferences()
+ set category = "OOC"
+ set name = "Open Character Preferences"
+ set desc = "Open Character Preferences"
+
+ var/datum/preferences/preferences = usr?.client?.prefs
+ if (!preferences)
+ return
+
+ preferences.current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
+
+/datum/verbs/menu/Preferences/verb/open_game_preferences()
+ set category = "OOC"
+ set name = "Open Game Preferences"
+ set desc = "Open Game Preferences"
+
+ var/datum/preferences/preferences = usr?.client?.prefs
+ if (!preferences)
+ return
+
+ preferences.current_window = PREFERENCE_TAB_GAME_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
diff --git a/code/modules/client/preferences_savefile.dm b/code/modules/client/preferences_savefile.dm
index 814ba86be77a..5b698bb8f204 100644
--- a/code/modules/client/preferences_savefile.dm
+++ b/code/modules/client/preferences_savefile.dm
@@ -5,7 +5,7 @@
// You do not need to raise this if you are adding new values that have sane defaults.
// Only raise this value when changing the meaning/format/name/layout of an existing value
// where you would want the updater procs below to run
-#define SAVEFILE_VERSION_MAX 39
+#define SAVEFILE_VERSION_MAX 40
/*
SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Carn
@@ -47,15 +47,16 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
if(LAZYFIND(be_special,"Ragin"))
be_special -= "Ragin"
be_special += "Ragin Mages"
+
if (current_version < 35)
toggles |= SOUND_ALT
+
if (current_version < 37)
chat_toggles |= CHAT_TYPING_INDICATOR
- if (current_version < 39)
- key_bindings = (hotkeys) ? deepCopyList(GLOB.hotkey_keybinding_list_by_key) : deepCopyList(GLOB.classic_keybinding_list_by_key)
- parent.set_macros()
- to_chat(parent, "Empty keybindings, setting default to [hotkeys ? "Hotkey" : "Classic"] mode")
- return
+
+ if (current_version < 40)
+ migrate_preferences_to_tgui_prefs_menu()
+
/datum/preferences/proc/update_character(current_version, savefile/S)
if(current_version < 19)
@@ -155,32 +156,36 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
preferred_map = null
if(current_version < 34) // default to on
toggles |= SOUND_VOX
+
+ if (current_version < 40)
+ migrate_character_to_tgui_prefs_menu()
/// checks through keybindings for outdated unbound keys and updates them
/datum/preferences/proc/check_keybindings()
if(!parent)
return
- var/list/user_binds = list()
- for (var/key in key_bindings)
- for(var/kb_name in key_bindings[key])
- user_binds[kb_name] += list(key)
+ var/list/binds_by_key = get_key_bindings_by_key(key_bindings)
var/list/notadded = list()
for (var/name in GLOB.keybindings_by_name)
var/datum/keybinding/kb = GLOB.keybindings_by_name[name]
- if(length(user_binds[kb.name]))
+ if(kb.name in key_bindings)
continue // key is unbound and or bound to something
+
var/addedbind = FALSE
- if(hotkeys)
+ key_bindings[kb.name] = list()
+
+ if(parent.hotkeys)
for(var/hotkeytobind in kb.hotkey_keys)
- if(!length(key_bindings[hotkeytobind]) || hotkeytobind == "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
- LAZYADD(key_bindings[hotkeytobind], kb.name)
+ if(!length(binds_by_key[hotkeytobind]) && hotkeytobind != "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
+ key_bindings[kb.name] |= hotkeytobind
addedbind = TRUE
else
for(var/classickeytobind in kb.classic_keys)
- if(!length(key_bindings[classickeytobind]) || classickeytobind == "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
- LAZYADD(key_bindings[classickeytobind], kb.name)
+ if(!length(binds_by_key[classickeytobind]) && classickeytobind != "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
+ key_bindings[kb.name] |= classickeytobind
addedbind = TRUE
+
if(!addedbind)
notadded += kb
save_preferences() //Save the players pref so that new keys that were set to Unbound as default are permanently stored
@@ -189,7 +194,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
/datum/preferences/proc/announce_conflict(list/notadded)
to_chat(parent, "Keybinding Conflict\n\
- There are new keybindings that default to keys you've already bound. The new ones will be unbound.")
+ There are new keybindings that default to keys you've already bound. The new ones will be unbound.")
for(var/item in notadded)
var/datum/keybinding/conflicted = item
to_chat(parent, span_danger("[conflicted.category]: [conflicted.full_name] needs updating"))
@@ -212,52 +217,23 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
var/needs_update = savefile_needs_update(S)
if(needs_update == -2) //fatal, can't load any data
+ var/bacpath = "[path].updatebac" //todo: if the savefile version is higher then the server, check the backup, and give the player a prompt to load the backup
+ if (fexists(bacpath))
+ fdel(bacpath) //only keep 1 version of backup
+ fcopy(S, bacpath) //byond helpfully lets you use a savefile for the first arg.
return FALSE
+
+ apply_all_client_preferences()
//general preferences
- READ_FILE(S["asaycolor"], asaycolor)
- READ_FILE(S["ooccolor"], ooccolor)
READ_FILE(S["lastchangelog"], lastchangelog)
- READ_FILE(S["UI_style"], UI_style)
- READ_FILE(S["hotkeys"], hotkeys)
- READ_FILE(S["chat_on_map"], chat_on_map)
- READ_FILE(S["max_chat_length"], max_chat_length)
- READ_FILE(S["see_chat_non_mob"] , see_chat_non_mob)
- READ_FILE(S["see_rc_emotes"] , see_rc_emotes)
- READ_FILE(S["tgui_fancy"], tgui_fancy)
- READ_FILE(S["tgui_lock"], tgui_lock)
- READ_FILE(S["buttons_locked"], buttons_locked)
- READ_FILE(S["windowflash"], windowflashing)
READ_FILE(S["be_special"] , be_special)
- READ_FILE(S["player_alt_titles"], player_alt_titles)
READ_FILE(S["default_slot"], default_slot)
READ_FILE(S["chat_toggles"], chat_toggles)
READ_FILE(S["extra_toggles"], extra_toggles)
READ_FILE(S["toggles"], toggles)
- READ_FILE(S["ghost_form"], ghost_form)
- READ_FILE(S["ghost_orbit"], ghost_orbit)
- READ_FILE(S["ghost_accs"], ghost_accs)
- READ_FILE(S["ghost_others"], ghost_others)
- READ_FILE(S["preferred_map"], preferred_map)
READ_FILE(S["ignoring"], ignoring)
- READ_FILE(S["ghost_hud"], ghost_hud)
- READ_FILE(S["inquisitive_ghost"], inquisitive_ghost)
- READ_FILE(S["uses_glasses_colour"], uses_glasses_colour)
- READ_FILE(S["clientfps"], clientfps)
- READ_FILE(S["parallax"], parallax)
- READ_FILE(S["ambientocclusion"], ambientocclusion)
- READ_FILE(S["auto_fit_viewport"], auto_fit_viewport)
- READ_FILE(S["widescreenpref"], widescreenpref)
- READ_FILE(S["pixel_size"], pixel_size)
- READ_FILE(S["scaling_method"], scaling_method)
- READ_FILE(S["menuoptions"], menuoptions)
- READ_FILE(S["enable_tips"], enable_tips)
- READ_FILE(S["tip_delay"], tip_delay)
- READ_FILE(S["pda_style"], pda_style)
- READ_FILE(S["pda_color"], pda_color)
- READ_FILE(S["pda_theme"], pda_theme)
- READ_FILE(S["id_in_pda"], id_in_pda)
READ_FILE(S["skillcape"], skillcape)
READ_FILE(S["skillcape_id"], skillcape_id)
@@ -268,88 +244,17 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
READ_FILE(S["alternative_announcers"], disable_alternative_announcers)
READ_FILE(S["balloon_alerts"], disable_balloon_alerts)
READ_FILE(S["key_bindings"], key_bindings)
-
-
- // yogs start - Donor features
- READ_FILE(S["donor_pda"], donor_pda)
- READ_FILE(S["donor_hat"], donor_hat)
- READ_FILE(S["borg_hat"], borg_hat)
- READ_FILE(S["donor_item"], donor_item)
- READ_FILE(S["purrbation"], purrbation)
- READ_FILE(S["yogtoggles"], yogtoggles)
-
- READ_FILE(S["accent"], accent) // Accents, too!
-
- READ_FILE(S["mood_tail_wagging"], mood_tail_wagging)
- // yogs end
- check_keybindings()
//try to fix any outdated data if necessary
if(needs_update >= 0)
update_preferences(needs_update, S) //needs_update = savefile_version if we need an update (positive integer)
//Sanitize
- asaycolor = sanitize_ooccolor(sanitize_hexcolor(asaycolor, 6, 1, initial(asaycolor)))
- ooccolor = sanitize_ooccolor(sanitize_hexcolor(ooccolor, 6, 1, initial(ooccolor)))
lastchangelog = sanitize_text(lastchangelog, initial(lastchangelog))
UI_style = sanitize_inlist(UI_style, GLOB.available_ui_styles, GLOB.available_ui_styles[1])
- hotkeys = sanitize_integer(hotkeys, FALSE, TRUE, initial(hotkeys))
- chat_on_map = sanitize_integer(chat_on_map, FALSE, TRUE, initial(chat_on_map))
- max_chat_length = sanitize_integer(max_chat_length, 1, CHAT_MESSAGE_MAX_LENGTH, initial(max_chat_length))
- see_chat_non_mob = sanitize_integer(see_chat_non_mob, FALSE, TRUE, initial(see_chat_non_mob))
- see_rc_emotes = sanitize_integer(see_rc_emotes, FALSE, TRUE, initial(see_rc_emotes))
- tgui_fancy = sanitize_integer(tgui_fancy, FALSE, TRUE, initial(tgui_fancy))
- tgui_lock = sanitize_integer(tgui_lock, FALSE, TRUE, initial(tgui_lock))
- buttons_locked = sanitize_integer(buttons_locked, FALSE, TRUE, initial(buttons_locked))
- windowflashing = sanitize_integer(windowflashing, FALSE, TRUE, initial(windowflashing))
default_slot = sanitize_integer(default_slot, 1, max_save_slots, initial(default_slot))
toggles = sanitize_integer(toggles, 0, ~0, initial(toggles)) // Yogs -- Fixes toggles not having >16 bits of flagspace
- clientfps = sanitize_integer(clientfps, 0, 1000, 0)
- parallax = sanitize_integer(parallax, PARALLAX_INSANE, PARALLAX_DISABLE, null)
- ambientocclusion = sanitize_integer(ambientocclusion, FALSE, TRUE, initial(ambientocclusion))
- auto_fit_viewport = sanitize_integer(auto_fit_viewport, FALSE, TRUE, initial(auto_fit_viewport))
- widescreenpref = sanitize_integer(widescreenpref, FALSE, TRUE, initial(widescreenpref))
- pixel_size = sanitize_integer(pixel_size, PIXEL_SCALING_AUTO, PIXEL_SCALING_3X, initial(pixel_size))
- scaling_method = sanitize_text(scaling_method, initial(scaling_method))
- ghost_form = sanitize_inlist(ghost_form, GLOB.ghost_forms, initial(ghost_form))
- ghost_orbit = sanitize_inlist(ghost_orbit, GLOB.ghost_orbits, initial(ghost_orbit))
- ghost_accs = sanitize_inlist(ghost_accs, GLOB.ghost_accs_options, GHOST_ACCS_DEFAULT_OPTION)
- ghost_others = sanitize_inlist(ghost_others, GLOB.ghost_others_options, GHOST_OTHERS_DEFAULT_OPTION)
- menuoptions = SANITIZE_LIST(menuoptions)
be_special = SANITIZE_LIST(be_special)
- pda_style = sanitize_inlist(pda_style, GLOB.pda_styles, initial(pda_style))
- pda_color = sanitize_hexcolor(pda_color, 6, 1, initial(pda_color))
- pda_theme = sanitize_inlist(pda_theme, GLOB.pda_themes, initial(pda_theme))
- skillcape = sanitize_integer(skillcape, 1, 82, initial(skillcape))
- skillcape_id = sanitize_text(skillcape_id, initial(skillcape_id))
-
- if(skillcape_id != "None" && !(skillcape_id in GLOB.skillcapes))
- skillcape_id = "None"
-
- map = sanitize_integer(map, FALSE, TRUE, initial(map))
- flare = sanitize_integer(flare, FALSE, TRUE, initial(flare))
- bar_choice = sanitize_text(bar_choice, initial(bar_choice))
- disable_alternative_announcers = sanitize_integer(disable_alternative_announcers, FALSE, TRUE, initial(disable_alternative_announcers))
- disable_balloon_alerts = sanitize_integer(disable_balloon_alerts, FALSE, TRUE, initial(disable_balloon_alerts))
- key_bindings = sanitize_islist(key_bindings, list())
-
- var/bar_sanitize = FALSE
- for(var/A in GLOB.potential_box_bars)
- if(bar_choice == A)
- bar_sanitize = TRUE
- break
- if(!bar_sanitize)
- bar_choice = "Random"
- if(!player_alt_titles) player_alt_titles = new()
- show_credits = sanitize_integer(show_credits, FALSE, TRUE, initial(show_credits))
-
- // yogs start - Donor features & yogtoggles
- yogtoggles = sanitize_integer(yogtoggles, 0, (1 << 23), initial(yogtoggles))
- donor_pda = sanitize_integer(donor_pda, 1, GLOB.donor_pdas.len, 1)
- purrbation = sanitize_integer(purrbation, FALSE, TRUE, initial(purrbation))
-
- accent = sanitize_text(accent, initial(accent)) // Can't use sanitize_inlist since it doesn't support falsely default values.
- // yogs end
return TRUE
@@ -364,74 +269,23 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
WRITE_FILE(S["version"] , SAVEFILE_VERSION_MAX) //updates (or failing that the sanity checks) will ensure data is not invalid at load. Assume up-to-date
//general preferences
- WRITE_FILE(S["asaycolor"], asaycolor)
- WRITE_FILE(S["ooccolor"], ooccolor)
WRITE_FILE(S["lastchangelog"], lastchangelog)
- WRITE_FILE(S["UI_style"], UI_style)
- WRITE_FILE(S["hotkeys"], hotkeys)
- WRITE_FILE(S["chat_on_map"], chat_on_map)
- WRITE_FILE(S["max_chat_length"], max_chat_length)
- WRITE_FILE(S["see_chat_non_mob"], see_chat_non_mob)
- WRITE_FILE(S["see_rc_emotes"], see_rc_emotes)
- WRITE_FILE(S["tgui_fancy"], tgui_fancy)
- WRITE_FILE(S["tgui_lock"], tgui_lock)
- WRITE_FILE(S["buttons_locked"], buttons_locked)
- WRITE_FILE(S["windowflash"], windowflashing)
WRITE_FILE(S["be_special"], be_special)
- WRITE_FILE(S["player_alt_titles"], player_alt_titles)
WRITE_FILE(S["default_slot"], default_slot)
WRITE_FILE(S["toggles"], toggles)
WRITE_FILE(S["chat_toggles"], chat_toggles)
WRITE_FILE(S["extra_toggles"], extra_toggles)
- WRITE_FILE(S["ghost_form"], ghost_form)
- WRITE_FILE(S["ghost_orbit"], ghost_orbit)
- WRITE_FILE(S["ghost_accs"], ghost_accs)
- WRITE_FILE(S["ghost_others"], ghost_others)
- WRITE_FILE(S["preferred_map"], preferred_map)
WRITE_FILE(S["ignoring"], ignoring)
- WRITE_FILE(S["ghost_hud"], ghost_hud)
- WRITE_FILE(S["inquisitive_ghost"], inquisitive_ghost)
- WRITE_FILE(S["uses_glasses_colour"], uses_glasses_colour)
- WRITE_FILE(S["clientfps"], clientfps)
- WRITE_FILE(S["parallax"], parallax)
- WRITE_FILE(S["ambientocclusion"], ambientocclusion)
- WRITE_FILE(S["auto_fit_viewport"], auto_fit_viewport)
- WRITE_FILE(S["widescreenpref"], widescreenpref)
- WRITE_FILE(S["pixel_size"], pixel_size)
- WRITE_FILE(S["scaling_method"], scaling_method)
- WRITE_FILE(S["menuoptions"], menuoptions)
- WRITE_FILE(S["enable_tips"], enable_tips)
- WRITE_FILE(S["tip_delay"], tip_delay)
- WRITE_FILE(S["pda_style"], pda_style)
- WRITE_FILE(S["pda_color"], pda_color)
- WRITE_FILE(S["pda_theme"], pda_theme)
- WRITE_FILE(S["id_in_pda"], id_in_pda)
- WRITE_FILE(S["skillcape"], skillcape)
- WRITE_FILE(S["skillcape_id"], skillcape_id)
- WRITE_FILE(S["show_credits"], show_credits)
- WRITE_FILE(S["map"], map)
- WRITE_FILE(S["flare"], flare)
- WRITE_FILE(S["bar_choice"], bar_choice)
- WRITE_FILE(S["alternative_announcers"], disable_alternative_announcers)
- WRITE_FILE(S["balloon_alerts"], disable_balloon_alerts)
WRITE_FILE(S["key_bindings"], key_bindings)
- // yogs start - Donor features & Yogstoggle
WRITE_FILE(S["yogtoggles"], yogtoggles)
- WRITE_FILE(S["donor_pda"], donor_pda)
- WRITE_FILE(S["donor_hat"], donor_hat)
- WRITE_FILE(S["borg_hat"], borg_hat)
- WRITE_FILE(S["donor_item"], donor_item)
- WRITE_FILE(S["purrbation"], purrbation)
- WRITE_FILE(S["accent"], accent) // Accents, too!
-
- WRITE_FILE(S["mood_tail_wagging"], mood_tail_wagging)
- // yogs end
return TRUE
/datum/preferences/proc/load_character(slot)
+ SHOULD_NOT_SLEEP(TRUE)
+
if(!path)
return FALSE
if(!fexists(path))
@@ -448,91 +302,23 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
WRITE_FILE(S["default_slot"] , slot)
S.cd = "/character[slot]"
+ character_savefile = S
var/needs_update = savefile_needs_update(S)
if(needs_update == -2) //fatal, can't load any data
return FALSE
+
+ // Read everything into cache
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.savefile_identifier != PREFERENCE_CHARACTER)
+ continue
- //Species
- var/species_id
- READ_FILE(S["species"], species_id)
- if(species_id)
- var/newtype = GLOB.species_list[species_id]
- if(newtype)
- pref_species = new newtype
-
- if(!S["features["mcolor"]"] || S["features["mcolor"]"] == "#000")
- WRITE_FILE(S["features["mcolor"]"] , "#FFF")
-
- if(!S["feature_ethcolor"] || S["feature_ethcolor"] == "#000")
- WRITE_FILE(S["feature_ethcolor"] , "9c3030")
-
- if(!S["feature_pretcolor"] || S["feature_pretcolor"] == "#000")
- WRITE_FILE(S["feature_pretcolor"] , "9c3030")
+ value_cache -= preference_type
+ read_preference(preference_type)
//Character
- READ_FILE(S["real_name"], real_name)
- READ_FILE(S["name_is_always_random"], be_random_name)
- READ_FILE(S["body_is_always_random"], be_random_body)
- READ_FILE(S["gender"], gender)
- READ_FILE(S["age"], age)
- READ_FILE(S["hair_color"], hair_color)
- READ_FILE(S["facial_hair_color"], facial_hair_color)
- READ_FILE(S["eye_color"], eye_color)
- READ_FILE(S["skin_tone"], skin_tone)
- READ_FILE(S["hair_style_name"], hair_style)
- READ_FILE(S["facial_style_name"], facial_hair_style)
- READ_FILE(S["underwear"], underwear)
- READ_FILE(S["undershirt"], undershirt)
- READ_FILE(S["socks"], socks)
- READ_FILE(S["backbag"], backbag)
- READ_FILE(S["jumpsuit_style"], jumpsuit_style)
- READ_FILE(S["uplink_loc"], uplink_spawn_loc)
- READ_FILE(S["feature_mcolor"], features["mcolor"])
- READ_FILE(S["feature_gradientstyle"], features["gradientstyle"])
- READ_FILE(S["feature_gradientcolor"], features["gradientcolor"])
- READ_FILE(S["feature_ethcolor"], features["ethcolor"])
- READ_FILE(S["feature_pretcolor"], features["pretcolor"])
- READ_FILE(S["feature_lizard_tail"], features["tail_lizard"])
- READ_FILE(S["feature_lizard_snout"], features["snout"])
- READ_FILE(S["feature_lizard_horns"], features["horns"])
- READ_FILE(S["feature_lizard_frills"], features["frills"])
- READ_FILE(S["feature_lizard_spines"], features["spines"])
- READ_FILE(S["feature_lizard_body_markings"], features["body_markings"])
- READ_FILE(S["feature_lizard_legs"], features["legs"])
- READ_FILE(S["feature_moth_wings"], features["moth_wings"])
- READ_FILE(S["feature_polysmorph_tail"], features["tail_polysmorph"])
- READ_FILE(S["feature_polysmorph_teeth"], features["teeth"])
- READ_FILE(S["feature_polysmorph_dome"], features["dome"])
- READ_FILE(S["feature_polysmorph_dorsal_tubes"], features["dorsal_tubes"])
- READ_FILE(S["feature_ethereal_mark"], features["ethereal_mark"])
- READ_FILE(S["feature_pod_hair"], features["pod_hair"])
- READ_FILE(S["feature_pod_flower"], features["pod_flower"])
- READ_FILE(S["feature_ipc_screen"], features["ipc_screen"])
- READ_FILE(S["feature_ipc_antenna"], features["ipc_antenna"])
- READ_FILE(S["feature_ipc_chassis"], features["ipc_chassis"])
- READ_FILE(S["feature_plasmaman_helmet"], features["plasmaman_helmet"])
-
READ_FILE(S["persistent_scars"], persistent_scars)
- if(!CONFIG_GET(flag/join_with_mutant_humans))
- features["tail_human"] = "none"
- features["ears"] = "none"
- else
- READ_FILE(S["feature_human_tail"], features["tail_human"])
- READ_FILE(S["feature_human_ears"], features["ears"])
-
- //Custom names
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/savefile_slot_name = custom_name_id + "_name" //TODO remove this
- READ_FILE(S[savefile_slot_name], custom_names[custom_name_id])
-
- READ_FILE(S["preferred_ai_core_display"], preferred_ai_core_display)
- READ_FILE(S["prefered_security_department"], prefered_security_department)
- READ_FILE(S["prefered_engineering_department"], prefered_engineering_department)
-
- //Jobs
- READ_FILE(S["joblessrole"], joblessrole)
- //Load prefs
-
+
READ_FILE(S["job_preferences"], job_preferences)
if(!job_preferences)
@@ -545,83 +331,14 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
if(needs_update >= 0)
update_character(needs_update, S) //needs_update == savefile_version if we need an update (positive integer)
- //Sanitize
-
- real_name = reject_bad_name(real_name, pref_species.allow_numbers_in_name)
- gender = sanitize_gender(gender)
- if(!real_name)
- real_name = random_unique_name(gender)
-
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/namedata = GLOB.preferences_custom_names[custom_name_id]
- custom_names[custom_name_id] = reject_bad_name(custom_names[custom_name_id],namedata["allow_numbers"])
- if(!custom_names[custom_name_id])
- custom_names[custom_name_id] = get_default_name(custom_name_id)
-
- if(!features["mcolor"] || features["mcolor"] == "#000")
- features["mcolor"] = pick("FFFFFF","7F7F7F", "7FFF7F", "7F7FFF", "FF7F7F", "7FFFFF", "FF7FFF", "FFFF7F")
-
- if(!features["ethcolor"] || features["ethcolor"] == "#000")
- features["ethcolor"] = GLOB.color_list_ethereal[pick(GLOB.color_list_ethereal)]
+ check_keybindings() // this apparently fails every time and overwrites any unloaded prefs with the default values, so don't load anything after this line or it won't actually save
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
- if(!features["pretcolor"] || features["pretcolor"] == "#000")
- features["pretcolor"] = GLOB.color_list_preternis[pick(GLOB.color_list_preternis)]
+ //Sanitize
be_random_name = sanitize_integer(be_random_name, 0, 1, initial(be_random_name))
be_random_body = sanitize_integer(be_random_body, 0, 1, initial(be_random_body))
- if(gender == MALE)
- hair_style = sanitize_inlist(hair_style, GLOB.hair_styles_male_list)
- facial_hair_style = sanitize_inlist(facial_hair_style, GLOB.facial_hair_styles_male_list)
- underwear = sanitize_inlist(underwear, GLOB.underwear_m)
- undershirt = sanitize_inlist(undershirt, GLOB.undershirt_m)
- else if(gender == FEMALE)
- hair_style = sanitize_inlist(hair_style, GLOB.hair_styles_female_list)
- facial_hair_style = sanitize_inlist(facial_hair_style, GLOB.facial_hair_styles_female_list)
- underwear = sanitize_inlist(underwear, GLOB.underwear_f)
- undershirt = sanitize_inlist(undershirt, GLOB.undershirt_f)
- else
- hair_style = sanitize_inlist(hair_style, GLOB.hair_styles_list)
- facial_hair_style = sanitize_inlist(facial_hair_style, GLOB.facial_hair_styles_list)
- underwear = sanitize_inlist(underwear, GLOB.underwear_list)
- undershirt = sanitize_inlist(undershirt, GLOB.undershirt_list)
-
-
- socks = sanitize_inlist(socks, GLOB.socks_list)
- age = sanitize_integer(age, AGE_MIN, AGE_MAX, initial(age))
- hair_color = sanitize_hexcolor(hair_color, 3, 0)
- facial_hair_color = sanitize_hexcolor(facial_hair_color, 3, 0)
- eye_color = sanitize_hexcolor(eye_color, 3, 0)
- skin_tone = sanitize_inlist(skin_tone, GLOB.skin_tones)
- backbag = sanitize_inlist(backbag, GLOB.backbaglist, initial(backbag))
- jumpsuit_style = sanitize_inlist(jumpsuit_style, GLOB.jumpsuitlist, initial(jumpsuit_style))
- uplink_spawn_loc = sanitize_inlist(uplink_spawn_loc, GLOB.uplink_spawn_loc_list, initial(uplink_spawn_loc))
- features["mcolor"] = sanitize_hexcolor(features["mcolor"], 3, 0)
- features["gradientstyle"] = sanitize_inlist(features["gradientstyle"], GLOB.hair_gradients_list)
- features["gradientcolor"] = sanitize_hexcolor(features["gradientcolor"], 3, 0)
- features["ethcolor"] = copytext_char(features["ethcolor"], 1, 7)
- features["pretcolor"] = copytext_char(features["pretcolor"], 1, 7)
- features["tail_lizard"] = sanitize_inlist(features["tail_lizard"], GLOB.tails_list_lizard)
- features["tail_polysmorph"] = sanitize_inlist(features["tail_polysmorph"], GLOB.tails_list_polysmorph)
- features["tail_human"] = sanitize_inlist(features["tail_human"], GLOB.tails_list_human, "None")
- features["snout"] = sanitize_inlist(features["snout"], GLOB.snouts_list)
- features["horns"] = sanitize_inlist(features["horns"], GLOB.horns_list)
- features["ears"] = sanitize_inlist(features["ears"], GLOB.ears_list, "None")
- features["frills"] = sanitize_inlist(features["frills"], GLOB.frills_list)
- features["spines"] = sanitize_inlist(features["spines"], GLOB.spines_list)
- features["body_markings"] = sanitize_inlist(features["body_markings"], GLOB.body_markings_list)
- features["feature_lizard_legs"] = sanitize_inlist(features["legs"], GLOB.legs_list, "Normal Legs")
- features["moth_wings"] = sanitize_inlist(features["moth_wings"], GLOB.moth_wings_list, "Plain")
- features["teeth"] = sanitize_inlist(features["teeth"], GLOB.teeth_list)
- features["dome"] = sanitize_inlist(features["dome"], GLOB.dome_list)
- features["dorsal_tubes"] = sanitize_inlist(features["dorsal_tubes"], GLOB.dorsal_tubes_list)
- features["ethereal_mark"] = sanitize_inlist(features["ethereal_mark"], GLOB.ethereal_mark_list)
- features["pod_hair"] = sanitize_inlist(features["pod_hair"], GLOB.pod_hair_list)
- features["pod_flower"] = sanitize_inlist(features["pod_flower"], GLOB.pod_flower_list)
- features["ipc_screen"] = sanitize_inlist(features["ipc_screen"], GLOB.ipc_screens_list)
- features["ipc_antenna"] = sanitize_inlist(features["ipc_antenna"], GLOB.ipc_antennas_list)
- features["ipc_chassis"] = sanitize_inlist(features["ipc_chassis"], GLOB.ipc_chassis_list)
-
persistent_scars = sanitize_integer(persistent_scars)
joblessrole = sanitize_integer(joblessrole, 1, 3, initial(joblessrole))
@@ -635,6 +352,8 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
return TRUE
/datum/preferences/proc/save_character()
+ SHOULD_NOT_SLEEP(TRUE)
+
if(!path)
return FALSE
var/savefile/S = new /savefile(path)
@@ -642,66 +361,23 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
return FALSE
S.cd = "/character[default_slot]"
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preference.savefile_identifier != PREFERENCE_CHARACTER)
+ continue
+
+ if (!(preference.type in recently_updated_keys))
+ continue
+
+ recently_updated_keys -= preference.type
+
+ if (preference.type in value_cache)
+ write_preference(preference, preference.serialize(value_cache[preference.type]))
+
WRITE_FILE(S["version"] , SAVEFILE_VERSION_MAX) //load_character will sanitize any bad data, so assume up-to-date.)
//Character
- WRITE_FILE(S["real_name"] , real_name)
- WRITE_FILE(S["name_is_always_random"] , be_random_name)
- WRITE_FILE(S["body_is_always_random"] , be_random_body)
- WRITE_FILE(S["gender"] , gender)
- WRITE_FILE(S["age"] , age)
- WRITE_FILE(S["hair_color"] , hair_color)
- WRITE_FILE(S["facial_hair_color"] , facial_hair_color)
- WRITE_FILE(S["eye_color"] , eye_color)
- WRITE_FILE(S["skin_tone"] , skin_tone)
- WRITE_FILE(S["hair_style_name"] , hair_style)
- WRITE_FILE(S["facial_style_name"] , facial_hair_style)
- WRITE_FILE(S["underwear"] , underwear)
- WRITE_FILE(S["undershirt"] , undershirt)
- WRITE_FILE(S["socks"] , socks)
- WRITE_FILE(S["backbag"] , backbag)
- WRITE_FILE(S["jumpsuit_style"] , jumpsuit_style)
- WRITE_FILE(S["uplink_loc"] , uplink_spawn_loc)
- WRITE_FILE(S["species"] , pref_species.id)
- WRITE_FILE(S["feature_mcolor"] , features["mcolor"])
- WRITE_FILE(S["feature_gradientstyle"] , features["gradientstyle"])
- WRITE_FILE(S["feature_gradientcolor"] , features["gradientcolor"])
- WRITE_FILE(S["feature_ethcolor"] , features["ethcolor"])
- WRITE_FILE(S["feature_pretcolor"] , features["pretcolor"])
- WRITE_FILE(S["feature_lizard_tail"] , features["tail_lizard"])
- WRITE_FILE(S["feature_polysmorph_tail"] , features["tail_polysmorph"])
- WRITE_FILE(S["feature_human_tail"] , features["tail_human"])
- WRITE_FILE(S["feature_lizard_snout"] , features["snout"])
- WRITE_FILE(S["feature_lizard_horns"] , features["horns"])
- WRITE_FILE(S["feature_human_ears"] , features["ears"])
- WRITE_FILE(S["feature_lizard_frills"] , features["frills"])
- WRITE_FILE(S["feature_lizard_spines"] , features["spines"])
- WRITE_FILE(S["feature_lizard_body_markings"] , features["body_markings"])
- WRITE_FILE(S["feature_lizard_legs"] , features["legs"])
- WRITE_FILE(S["feature_moth_wings"] , features["moth_wings"])
- WRITE_FILE(S["feature_polysmorph_teeth"] , features["teeth"])
- WRITE_FILE(S["feature_polysmorph_dome"] , features["dome"])
- WRITE_FILE(S["feature_polysmorph_dorsal_tubes"] , features["dorsal_tubes"])
- WRITE_FILE(S["feature_ethereal_mark"] , features["ethereal_mark"])
- WRITE_FILE(S["feature_pod_hair"] , features["pod_hair"])
- WRITE_FILE(S["feature_pod_flower"] , features["pod_flower"])
WRITE_FILE(S["persistent_scars"] , persistent_scars)
- WRITE_FILE(S["feature_ipc_screen"] , features["ipc_screen"])
- WRITE_FILE(S["feature_ipc_antenna"] , features["ipc_antenna"])
- WRITE_FILE(S["feature_ipc_chassis"] , features["ipc_chassis"])
- WRITE_FILE(S["feature_plasmaman_helmet"] , features["plasmaman_helmet"])
-
- //Custom names
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/savefile_slot_name = custom_name_id + "_name" //TODO remove this
- WRITE_FILE(S[savefile_slot_name],custom_names[custom_name_id])
-
- WRITE_FILE(S["preferred_ai_core_display"] , preferred_ai_core_display)
- WRITE_FILE(S["prefered_security_department"] , prefered_security_department)
- WRITE_FILE(S["prefered_engineering_department"] , prefered_engineering_department)
-
- //Jobs
- WRITE_FILE(S["joblessrole"] , joblessrole)
+
//Write prefs
WRITE_FILE(S["job_preferences"] , job_preferences)
diff --git a/code/modules/client/preferences_toggles.dm b/code/modules/client/preferences_toggles.dm
deleted file mode 100644
index 9c0568a4ff0b..000000000000
--- a/code/modules/client/preferences_toggles.dm
+++ /dev/null
@@ -1,521 +0,0 @@
-//this works as is to create a single checked item, but has no back end code for toggleing the check yet
-#define TOGGLE_CHECKBOX(PARENT, CHILD) PARENT/CHILD/abstract = TRUE;PARENT/CHILD/checkbox = CHECKBOX_TOGGLE;PARENT/CHILD/verb/CHILD
-
-//Example usage TOGGLE_CHECKBOX(datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_ears)()
-
-//override because we don't want to save preferences twice.
-/datum/verbs/menu/Settings/Set_checked(client/C, verbpath)
- if (checkbox == CHECKBOX_GROUP)
- C.prefs.menuoptions[type] = verbpath
- else if (checkbox == CHECKBOX_TOGGLE)
- var/checked = Get_checked(C)
- C.prefs.menuoptions[type] = !checked
- winset(C, "[verbpath]", "is-checked = [!checked]")
-
-/datum/verbs/menu/Settings/verb/setup_character()
- set name = "Game Preferences"
- set category = "Preferences"
- set desc = "Open Game Preferences Window"
- usr.client.prefs.current_tab = 1
- usr.client.prefs.ShowChoices(usr)
-
-//toggles
-/datum/verbs/menu/Settings/Ghost/chatterbox
- name = "Chat Box Spam"
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_ears)()
- set name = "Show/Hide GhostEars"
- set category = "Preferences"
- set desc = "See All Speech"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTEARS
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTEARS) ? "see all speech in the world" : "only see speech from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Ears", "[usr.client.prefs.chat_toggles & CHAT_GHOSTEARS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_ears/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTEARS
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_sight)()
- set name = "Show/Hide GhostSight"
- set category = "Preferences"
- set desc = "See All Emotes"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTSIGHT
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTSIGHT) ? "see all emotes in the world" : "only see emotes from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Sight", "[usr.client.prefs.chat_toggles & CHAT_GHOSTSIGHT ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_sight/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTSIGHT
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_whispers)()
- set name = "Show/Hide GhostWhispers"
- set category = "Preferences"
- set desc = "See All Whispers"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTWHISPER
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTWHISPER) ? "see all whispers in the world" : "only see whispers from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Whispers", "[usr.client.prefs.chat_toggles & CHAT_GHOSTWHISPER ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_whispers/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTWHISPER
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_radio)()
- set name = "Show/Hide GhostRadio"
- set category = "Preferences"
- set desc = "See All Radio Chatter"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTRADIO
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTRADIO) ? "see radio chatter" : "not see radio chatter"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Radio", "[usr.client.prefs.chat_toggles & CHAT_GHOSTRADIO ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! //social experiment, increase the generation whenever you copypaste this shamelessly GENERATION 1
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_radio/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTRADIO
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_pda)()
- set name = "Show/Hide GhostPDA"
- set category = "Preferences"
- set desc = "See All PDA Messages"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTPDA
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTPDA) ? "see all pda messages in the world" : "only see pda messages from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost PDA", "[usr.client.prefs.chat_toggles & CHAT_GHOSTPDA ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_pda/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTPDA
-
-/datum/verbs/menu/Settings/Ghost/chatterbox/Events
- name = "Events"
-
-//please be aware that the following two verbs have inverted stat output, so that "Toggle Deathrattle|1" still means you activated it
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox/Events, toggle_deathrattle)()
- set name = "Toggle Deathrattle"
- set category = "Preferences"
- set desc = "Death"
- usr.client.prefs.toggles ^= DISABLE_DEATHRATTLE
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.prefs.toggles & DISABLE_DEATHRATTLE) ? "no longer" : "now"] get messages when a sentient mob dies.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Deathrattle", "[!(usr.client.prefs.toggles & DISABLE_DEATHRATTLE) ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, maybe you should spend some time reading the comments.
-/datum/verbs/menu/Settings/Ghost/chatterbox/Events/toggle_deathrattle/Get_checked(client/C)
- return !(C.prefs.toggles & DISABLE_DEATHRATTLE)
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox/Events, toggle_arrivalrattle)()
- set name = "Toggle Arrivalrattle"
- set category = "Preferences"
- set desc = "New Player Arrival"
- usr.client.prefs.toggles ^= DISABLE_ARRIVALRATTLE
- to_chat(usr, "You will [(usr.client.prefs.toggles & DISABLE_ARRIVALRATTLE) ? "no longer" : "now"] get messages when someone joins the station.")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Arrivalrattle", "[!(usr.client.prefs.toggles & DISABLE_ARRIVALRATTLE) ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, maybe you should rethink where your life went so wrong.
-/datum/verbs/menu/Settings/Ghost/chatterbox/Events/toggle_arrivalrattle/Get_checked(client/C)
- return !(C.prefs.toggles & DISABLE_ARRIVALRATTLE)
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost, togglemidroundantag)()
- set name = "Toggle Midround Antagonist"
- set category = "Preferences"
- set desc = "Midround Antagonist"
- usr.client.prefs.toggles ^= MIDROUND_ANTAG
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.prefs.toggles & MIDROUND_ANTAG) ? "now" : "no longer"] be considered for midround antagonist positions.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Midround Antag", "[usr.client.prefs.toggles & MIDROUND_ANTAG ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/togglemidroundantag/Get_checked(client/C)
- return C.prefs.toggles & MIDROUND_ANTAG
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggletitlemusic)()
- set name = "Hear/Silence Lobby Music"
- set category = "Preferences"
- set desc = "Hear Music In Lobby"
- usr.client.prefs.toggles ^= SOUND_LOBBY
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_LOBBY)
- to_chat(usr, "You will now hear music in the game lobby.")
- if(isnewplayer(usr))
- usr.client.playtitlemusic()
- else
- to_chat(usr, "You will no longer hear music in the game lobby.")
- usr.stop_sound_channel(CHANNEL_LOBBYMUSIC)
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Lobby Music", "[usr.client.prefs.toggles & SOUND_LOBBY ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggletitlemusic/Get_checked(client/C)
- return C.prefs.toggles & SOUND_LOBBY
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, togglemidis)()
- set name = "Hear/Silence Midis"
- set category = "Preferences"
- set desc = "Hear Admin Triggered Sounds (Midis)"
- usr.client.prefs.toggles ^= SOUND_MIDI
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_MIDI)
- to_chat(usr, "You will now hear any sounds uploaded by admins.")
- else
- to_chat(usr, "You will no longer hear sounds uploaded by admins")
- usr.stop_sound_channel(CHANNEL_ADMIN)
- var/client/C = usr.client
- C?.tgui_panel?.stop_music()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Hearing Midis", "[usr.client.prefs.toggles & SOUND_MIDI ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/togglemidis/Get_checked(client/C)
- return C.prefs.toggles & SOUND_MIDI
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_instruments)()
- set name = "Hear/Silence Instruments"
- set category = "Preferences"
- set desc = "Hear In-game Instruments"
- usr.client.prefs.toggles ^= SOUND_INSTRUMENTS
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_INSTRUMENTS)
- to_chat(usr, "You will now hear people playing musical instruments.")
- else
- to_chat(usr, "You will no longer hear musical instruments.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Instruments", "[usr.client.prefs.toggles & SOUND_INSTRUMENTS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_instruments/Get_checked(client/C)
- return C.prefs.toggles & SOUND_INSTRUMENTS
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_jukebox)()
- set name = "Hear/Silence Jukeboxes"
- set category = "Preferences"
- set desc = "Hear In-game Jukeboxes"
- usr.client.prefs.toggles ^= SOUND_JUKEBOX
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_JUKEBOX)
- to_chat(usr, "You will now hear jukeboxes.")
- else
- to_chat(usr, "You will no longer hear jukeboxes.")
- usr.client.update_playing_music()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Jukeboxes", "[usr.client.prefs.toggles & SOUND_JUKEBOX ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_jukebox/Get_checked(client/C)
- return C.prefs.toggles & SOUND_JUKEBOX
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, Toggle_Soundscape)()
- set name = "Hear/Silence Ambience"
- set category = "Preferences"
- set desc = "Hear Ambient Sound Effects"
- usr.client.prefs.toggles ^= SOUND_AMBIENCE
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_AMBIENCE)
- to_chat(usr, "You will now hear ambient sounds.")
- else
- to_chat(usr, "You will no longer hear ambient sounds.")
- usr.stop_sound_channel(CHANNEL_AMBIENCE)
- usr.stop_sound_channel(CHANNEL_BUZZ)
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ambience", "[usr.client.prefs.toggles & SOUND_AMBIENCE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/Toggle_Soundscape/Get_checked(client/C)
- return C.prefs.toggles & SOUND_AMBIENCE
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_ship_ambience)()
- set name = "Hear/Silence Ship Ambience"
- set category = "Preferences"
- set desc = "Hear Ship Ambience Roar"
- usr.client.prefs.toggles ^= SOUND_SHIP_AMBIENCE
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_SHIP_AMBIENCE)
- to_chat(usr, "You will now hear ship ambience.")
- else
- to_chat(usr, "You will no longer hear ship ambience.")
- usr.stop_sound_channel(CHANNEL_BUZZ)
- usr.client.ambience_playing = 0
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ship Ambience", "[usr.client.prefs.toggles & SOUND_SHIP_AMBIENCE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, I bet you read this comment expecting to see the same thing :^)
-/datum/verbs/menu/Settings/Sound/toggle_ship_ambience/Get_checked(client/C)
- return C.prefs.toggles & SOUND_SHIP_AMBIENCE
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_announcement_sound)()
- set name = "Hear/Silence Announcements"
- set category = "Preferences"
- set desc = "Hear Announcement Sound"
- usr.client.prefs.toggles ^= SOUND_ANNOUNCEMENTS
- to_chat(usr, "You will now [(usr.client.prefs.toggles & SOUND_ANNOUNCEMENTS) ? "hear announcement sounds" : "no longer hear announcements"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Announcement Sound", "[usr.client.prefs.toggles & SOUND_ANNOUNCEMENTS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_announcement_sound/Get_checked(client/C)
- return C.prefs.toggles & SOUND_ANNOUNCEMENTS
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_vox)()
- set name = "Hear/Silence VOX"
- set category = "Preferences"
- set desc = "Hear VOX Announcements"
- usr.client.prefs.toggles ^= SOUND_VOX
- to_chat(usr, "You will now [(usr.client.prefs.toggles & SOUND_VOX) ? "hear VOX announcements" : "no longer hear VOX announcements"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle VOX", "[usr.client.prefs.toggles & SOUND_VOX ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_vox/Get_checked(client/C)
- return C.prefs.toggles & SOUND_VOX
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_alt)()
- set name = "Hear/Silence Alternative Sounds"
- set category = "Preferences"
- set desc = "Hear potentially annoying \"alternative\" sounds"
- usr.client.prefs.toggles ^= SOUND_ALT
- to_chat(usr, "You will now [(usr.client.prefs.toggles & SOUND_ALT) ? "hear alternative sounds" : "no longer hear alternative sounds"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Alternative Sounds", "[usr.client.prefs.toggles & SOUND_ALT ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_alt/Get_checked(client/C)
- return C.prefs.toggles & SOUND_ALT
-
-/datum/verbs/menu/Settings/Sound/verb/stop_client_sounds()
- set name = "Stop Sounds"
- set category = "Preferences"
- set desc = "Stop Current Sounds"
- SEND_SOUND(usr, sound(null))
- var/client/C = usr.client
- C?.tgui_panel?.stop_music()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Stop Self Sounds")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, listen_ooc)()
- set name = "Show/Hide OOC"
- set category = "Preferences"
- set desc = "Show OOC Chat"
- usr.client.prefs.chat_toggles ^= CHAT_OOC
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.prefs.chat_toggles & CHAT_OOC) ? "now" : "no longer"] see messages on the OOC channel.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Seeing OOC", "[usr.client.prefs.chat_toggles & CHAT_OOC ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/listen_ooc/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_OOC
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, listen_bank_card)()
- set name = "Show/Hide Income Updates"
- set category = "Preferences"
- set desc = "Show or hide updates to your income"
- usr.client.prefs.chat_toggles ^= CHAT_BANKCARD
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.prefs.chat_toggles & CHAT_BANKCARD) ? "now" : "no longer"] be notified when you get paid.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Income Notifications", "[(usr.client.prefs.chat_toggles & CHAT_BANKCARD) ? "Enabled" : "Disabled"]"))
-/datum/verbs/menu/Settings/listen_bank_card/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_BANKCARD
-
-
-GLOBAL_LIST_INIT(ghost_forms, list("ghost","ghostking","ghostian2","skeleghost","ghost_red","ghost_black", \
- "ghost_blue","ghost_yellow","ghost_green","ghost_pink", \
- "ghost_cyan","ghost_dblue","ghost_dred","ghost_dgreen", \
- "ghost_dcyan","ghost_grey","ghost_dyellow","ghost_dpink", "ghost_purpleswirl","ghost_funkypurp","ghost_pinksherbert","ghost_blazeit",\
- "ghost_mellow","ghost_rainbow","ghost_camo","ghost_fire", "catghost"))
-/client/proc/pick_form()
- if(!is_content_unlocked())
- tgui_alert(usr,"This setting is for accounts with BYOND premium only.")
- return
- var/new_form = input(src, "Thanks for supporting BYOND - Choose your ghostly form:","Thanks for supporting BYOND",null) as null|anything in GLOB.ghost_forms
- if(new_form)
- prefs.ghost_form = new_form
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.update_icon(new_form)
-
-GLOBAL_LIST_INIT(ghost_orbits, list(GHOST_ORBIT_CIRCLE,GHOST_ORBIT_TRIANGLE,GHOST_ORBIT_SQUARE,GHOST_ORBIT_HEXAGON,GHOST_ORBIT_PENTAGON))
-
-/client/proc/pick_ghost_orbit()
- if(!is_content_unlocked())
- tgui_alert(usr,"This setting is for accounts with BYOND premium only.")
- return
- var/new_orbit = input(src, "Thanks for supporting BYOND - Choose your ghostly orbit:","Thanks for supporting BYOND",null) as null|anything in GLOB.ghost_orbits
- if(new_orbit)
- prefs.ghost_orbit = new_orbit
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.ghost_orbit = new_orbit
-
-/client/proc/pick_ghost_accs()
- var/new_ghost_accs = tgui_alert(usr,"Do you want your ghost to show full accessories where possible, hide accessories but still use the directional sprites where possible, or also ignore the directions and stick to the default sprites?",,list("full accessories", "only directional sprites", "default sprites"))
- if(new_ghost_accs)
- switch(new_ghost_accs)
- if("full accessories")
- prefs.ghost_accs = GHOST_ACCS_FULL
- if("only directional sprites")
- prefs.ghost_accs = GHOST_ACCS_DIR
- if("default sprites")
- prefs.ghost_accs = GHOST_ACCS_NONE
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.update_icon()
-
-/client/verb/pick_ghost_customization()
- set name = "Ghost Customization"
- set category = "Preferences"
- set desc = "Customize your ghastly appearance."
- if(is_content_unlocked())
- switch(tgui_alert(usr,"Which setting do you want to change?",,list("Ghost Form","Ghost Orbit","Ghost Accessories")))
- if("Ghost Form")
- pick_form()
- if("Ghost Orbit")
- pick_ghost_orbit()
- if("Ghost Accessories")
- pick_ghost_accs()
- else
- pick_ghost_accs()
-
-/client/verb/pick_ghost_others()
- set name = "Ghosts of Others"
- set category = "Preferences"
- set desc = "Change display settings for the ghosts of other players."
- var/new_ghost_others = tgui_alert(usr,"Do you want the ghosts of others to show up as their own setting, as their default sprites or always as the default white ghost?",,list("Their Setting", "Default Sprites", "White Ghost"))
- if(new_ghost_others)
- switch(new_ghost_others)
- if("Their Setting")
- prefs.ghost_others = GHOST_OTHERS_THEIR_SETTING
- if("Default Sprites")
- prefs.ghost_others = GHOST_OTHERS_DEFAULT_SPRITE
- if("White Ghost")
- prefs.ghost_others = GHOST_OTHERS_SIMPLE
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.update_sight()
-
-/client/verb/toggle_intent_style()
- set name = "Toggle Intent Selection Style"
- set category = "Preferences"
- set desc = "Toggle between directly clicking the desired intent or clicking to rotate through."
- prefs.toggles ^= INTENT_STYLE
- to_chat(src, "[(prefs.toggles & INTENT_STYLE) ? "Clicking directly on intents selects them." : "Clicking on intents rotates selection clockwise."]")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Intent Selection", "[prefs.toggles & INTENT_STYLE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_hud_pref()
- set name = "Toggle Ghost HUD"
- set category = "Preferences"
- set desc = "Hide/Show Ghost HUD"
-
- prefs.ghost_hud = !prefs.ghost_hud
- to_chat(src, "Ghost HUD will now be [prefs.ghost_hud ? "visible" : "hidden"].")
- prefs.save_preferences()
- if(isobserver(mob))
- mob.hud_used.show_hud()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost HUD", "[prefs.ghost_hud ? "Enabled" : "Disabled"]"))
-
-/client/verb/toggle_show_credits()
- set name = "Toggle Credits"
- set category = "Preferences"
- set desc = "Hide/Show Credits"
-
- prefs.show_credits = !prefs.show_credits
- to_chat(src, "Credits will now be [prefs.show_credits ? "visible" : "hidden"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Credits", "[prefs.show_credits ? "Enabled" : "Disabled"]"))
-
-/client/verb/toggle_inquisition() // warning: unexpected inquisition
- set name = "Toggle Inquisitiveness"
- set desc = "Sets whether your ghost examines everything on click by default"
- set category = "Preferences"
-
- prefs.inquisitive_ghost = !prefs.inquisitive_ghost
- prefs.save_preferences()
- if(prefs.inquisitive_ghost)
- to_chat(src, span_notice("You will now examine everything you click on."))
- else
- to_chat(src, span_notice("You will no longer examine things you click on."))
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Inquisitiveness", "[prefs.inquisitive_ghost ? "Enabled" : "Disabled"]"))
-
-//Admin Preferences
-/client/proc/toggleadminhelpsound()
- set name = "Hear/Silence Adminhelps"
- set category = "Preferences.Admin"
- set desc = "Toggle hearing a notification when admin PMs are received"
- if(!holder)
- return
- prefs.toggles ^= SOUND_ADMINHELP
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & SOUND_ADMINHELP) ? "now" : "no longer"] hear a sound when adminhelps arrive.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Adminhelp Sound", "[prefs.toggles & SOUND_ADMINHELP ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggleannouncelogin()
- set name = "Do/Don't Announce Login"
- set category = "Preferences.Admin"
- set desc = "Toggle if you want an announcement to admins when you login during a round"
- if(!holder)
- return
- prefs.toggles ^= ANNOUNCE_LOGIN
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & ANNOUNCE_LOGIN) ? "now" : "no longer"] have an announcement to other admins when you login.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Login Announcement", "[prefs.toggles & ANNOUNCE_LOGIN ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggle_hear_radio()
- set name = "Show/Hide Radio Chatter"
- set category = "Preferences.Admin"
- set desc = "Toggle seeing radiochatter from nearby radios and speakers"
- if(!holder)
- return
- prefs.chat_toggles ^= CHAT_RADIO
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.chat_toggles & CHAT_RADIO) ? "now" : "no longer"] see radio chatter from nearby radios or speakers")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Radio Chatter", "[prefs.chat_toggles & CHAT_RADIO ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/deadchat()
- set name = "Show/Hide Deadchat"
- set category = "Preferences.Admin"
- set desc ="Toggles seeing deadchat"
- if(!holder)
- return
- prefs.chat_toggles ^= CHAT_DEAD
- prefs.save_preferences()
- to_chat(src, "You will [(prefs.chat_toggles & CHAT_DEAD) ? "now" : "no longer"] see deadchat.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Deadchat Visibility", "[prefs.chat_toggles & CHAT_DEAD ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggleprayers()
- set name = "Show/Hide Prayers"
- set category = "Preferences.Admin"
- set desc = "Toggles seeing prayers"
- if(!holder)
- return
- prefs.chat_toggles ^= CHAT_PRAYER
- prefs.save_preferences()
- to_chat(src, "You will [(prefs.chat_toggles & CHAT_PRAYER) ? "now" : "no longer"] see prayerchat.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Prayer Visibility", "[prefs.chat_toggles & CHAT_PRAYER ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggle_prayer_sound()
- set name = "Hear/Silence Prayer Sounds"
- set category = "Preferences.Admin"
- set desc = "Hear Prayer Sounds"
- if(!holder)
- return
- prefs.toggles ^= SOUND_PRAYERS
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & SOUND_PRAYERS) ? "now" : "no longer"] hear a sound when prayers arrive.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Prayer Sounds", "[usr.client.prefs.toggles & SOUND_PRAYERS ? "Enabled" : "Disabled"]"))
-
-/client/proc/colorasay()
- set name = "Set Admin Say Color"
- set category = "Preferences.Admin"
- set desc = "Set the color of your ASAY messages"
- if(!holder)
- return
- if(!CONFIG_GET(flag/allow_admin_asaycolor))
- to_chat(src, "Custom Asay color is currently disabled by the server.")
- return
- var/new_asaycolor = input(src, "Please select your ASAY color.", "ASAY color", prefs.asaycolor) as color|null
- if(new_asaycolor)
- prefs.asaycolor = sanitize_ooccolor(new_asaycolor)
- prefs.save_preferences()
- SSblackbox.record_feedback("tally", "admin_verb", 1, "Set ASAY Color")
- return
-
-/client/proc/resetasaycolor()
- set name = "Reset your Admin Say Color"
- set desc = "Returns your ASAY Color to default"
- set category = "Preferences.Admin"
- if(!holder)
- return
- if(!CONFIG_GET(flag/allow_admin_asaycolor))
- to_chat(src, "Custom Asay color is currently disabled by the server.")
- return
- prefs.asaycolor = initial(prefs.asaycolor)
- prefs.save_preferences()
-
-/client/proc/toggle_split_admin_tabs()
- set name = "Toggle Split Admin Tabs"
- set category = "Preferences.Admin"
- set desc = "Toggle the admin tab being split into separate tabs instead of being merged into one"
- if(!holder)
- return
- prefs.extra_toggles ^= SPLIT_ADMIN_TABS
- prefs.save_preferences()
- to_chat(src, "Admin tabs will now [(prefs.extra_toggles & SPLIT_ADMIN_TABS) ? "be" : "not be"] split.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Split Admin Tabs", "[prefs.extra_toggles & SPLIT_ADMIN_TABS ? "Enabled" : "Disabled"]"))
-
-/client/proc/toggle_fast_mc_refresh()
- set name = "Toggle Fast MC Refresh"
- set category = "Preferences.Admin"
- set desc = "Toggle the speed of which the MC refreshes."
- if(!holder)
- return
- prefs.extra_toggles ^= FAST_MC_REFRESH
- prefs.save_preferences()
- to_chat(src, "MC will now [(prefs.extra_toggles & FAST_MC_REFRESH) ? "not be" : "be"] fast updating.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Fast MC Refresh", "[prefs.extra_toggles & FAST_MC_REFRESH ? "Enabled" : "Disabled"]"))
diff --git a/code/modules/client/verbs/etips.dm b/code/modules/client/verbs/etips.dm
deleted file mode 100644
index 5c53deb9a3de..000000000000
--- a/code/modules/client/verbs/etips.dm
+++ /dev/null
@@ -1,20 +0,0 @@
-/client/verb/toggle_tips()
- set name = "Toggle Examine Tooltips"
- set desc = "Toggles examine hover-over tooltips"
- set category = "Preferences"
-
- prefs.enable_tips = !prefs.enable_tips
- prefs.save_preferences()
- to_chat(usr, span_danger("Examine tooltips [prefs.enable_tips ? "en" : "dis"]abled."))
-
-/client/verb/change_tip_delay()
- set name = "Set Examine Tooltip Delay"
- set desc = "Sets the delay in milliseconds before examine tooltips appear"
- set category = "Preferences"
-
- var/indelay = stripped_input(usr, "Enter the tooltip delay in milliseconds (default: 500)", "Enter tooltip delay", "", 10)
- indelay = text2num(indelay)
- if(usr)//is this what you mean?
- prefs.tip_delay = indelay
- prefs.save_preferences()
- to_chat(usr, span_danger("Tooltip delay set to [indelay] milliseconds."))
diff --git a/code/modules/client/verbs/ooc.dm b/code/modules/client/verbs/ooc.dm
index be70c608d03f..717ff0abb266 100644
--- a/code/modules/client/verbs/ooc.dm
+++ b/code/modules/client/verbs/ooc.dm
@@ -176,7 +176,7 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
set name = "Set Player OOC Color"
set desc = "Modifies player OOC Color"
set category = "Server"
- GLOB.OOC_COLOR = sanitize_ooccolor(newColor)
+ GLOB.OOC_COLOR = sanitize_color(newColor)
/client/proc/reset_ooc()
set name = "Reset Player OOC Color"
@@ -194,33 +194,6 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
log_admin("[key_name_admin(usr)] has reset player ooc color.")
GLOB.OOC_COLOR = null
-/client/verb/colorooc()
- set name = "Set Your OOC Color"
- set category = "Preferences"
-
- if(!holder || !check_rights_for(src, R_ADMIN))
- if(!is_content_unlocked())
- return
-
- var/new_ooccolor = input(src, "Please select your OOC color.", "OOC color", prefs.ooccolor) as color|null
- if(new_ooccolor)
- prefs.ooccolor = sanitize_ooccolor(new_ooccolor)
- prefs.save_preferences()
- SSblackbox.record_feedback("tally", "admin_verb", 1, "Set OOC Color") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
- return
-
-/client/verb/resetcolorooc()
- set name = "Reset Your OOC Color"
- set desc = "Returns your OOC Color to default"
- set category = "Preferences"
-
- if(!holder || !check_rights_for(src, R_ADMIN))
- if(!is_content_unlocked())
- return
-
- prefs.ooccolor = initial(prefs.ooccolor)
- prefs.save_preferences()
-
//Checks admin notice
/client/verb/admin_notice()
set name = "Adminnotice"
diff --git a/code/modules/jobs/job_types/_job.dm b/code/modules/jobs/job_types/_job.dm
index 938451ceed6e..37b9dbbc43ea 100644
--- a/code/modules/jobs/job_types/_job.dm
+++ b/code/modules/jobs/job_types/_job.dm
@@ -1,20 +1,37 @@
/datum/job
/// The name of the job used for preferences, bans, etc.
var/title = "NOPE"
+
/// The description of the job, used for preferences menu.
/// Keep it short and useful. Avoid in-jokes, these are for new players.
var/description
+
/// This job comes with these accesses by default
var/list/base_access = list()
+
/// Additional accesses for the job if config.jobs_have_minimal_access is set to false
var/list/added_access = list()
+
/// Who is responsible for demoting them
var/department_head = list()
+
/// Tells the given channels that the given mob is the new department head. See communications.dm for valid channels.
var/list/head_announce = null
+
// Used for something in preferences_savefile.dm
var/department_flag = NONE
+
+ /// If specified, this department will be used for the preferences menu.
+ var/datum/job_department/department_for_prefs = null
+
+ /// Lazy list with the departments this job belongs to.
+ /// Required to be set for playable jobs.
+ /// The first department will be used in the preferences menu,
+ /// unless department_for_prefs is set.
+ var/list/departments_list = null
+
var/flag = NONE //Deprecated
+
/// Automatic deadmin for a job. Usually head/security positions
var/auto_deadmin_role_flags = NONE
// Players will be allowed to spawn in as jobs that are set to "Station"
@@ -168,6 +185,13 @@
if(CONFIG_GET(flag/everyone_has_maint_access)) //Config has global maint access set
. |= list(ACCESS_MAINT_TUNNELS)
+/mob/living/proc/dress_up_as_job(datum/job/equipping, visual_only = FALSE)
+ return
+
+/mob/living/carbon/human/dress_up_as_job(datum/job/equipping, visual_only = FALSE)
+ //dna.species.pre_equip_species_outfit(equipping, src, visual_only)
+ equipOutfit(equipping.outfit, visual_only)
+
/datum/job/proc/announce_head(var/mob/living/carbon/human/H, var/channels) //tells the given channel that the given mob is the new department head. See communications.dm for valid channels.
if(H && GLOB.announcement_systems.len)
//timer because these should come after the captain announcement
diff --git a/code/modules/jobs/job_types/assistant.dm b/code/modules/jobs/job_types/assistant.dm
index c5b2d294cd0c..883347adc18e 100644
--- a/code/modules/jobs/job_types/assistant.dm
+++ b/code/modules/jobs/job_types/assistant.dm
@@ -49,3 +49,19 @@ Assistant
uniform = /obj/item/clothing/under/color/random
uniform_skirt = /obj/item/clothing/under/skirt/color/random
return ..()
+
+
+/datum/outfit/job/assistant/consistent
+ name = "Assistant - Consistent"
+
+/datum/outfit/job/assistant/consistent/pre_equip(mob/living/carbon/human/target)
+ ..()
+ uniform = /obj/item/clothing/under/color/grey
+
+/datum/outfit/job/assistant/consistent/post_equip(mob/living/carbon/human/H, visualsOnly)
+ ..()
+
+ // This outfit is used by the assets SS, which is ran before the atoms SS
+ if (SSatoms.initialized == INITIALIZATION_INSSATOMS)
+ // H.w_uniform?.update_greyscale()
+ H.update_inv_w_uniform()
diff --git a/code/modules/jobs/job_types/security_officer.dm b/code/modules/jobs/job_types/security_officer.dm
index 40f7d7705040..bf6bf35330da 100644
--- a/code/modules/jobs/job_types/security_officer.dm
+++ b/code/modules/jobs/job_types/security_officer.dm
@@ -67,7 +67,7 @@ GLOBAL_LIST_INIT(available_depts_sec, list(SEC_DEPT_ENGINEERING, SEC_DEPT_MEDICA
// Assign department security
var/department
if(M && M.client && M.client.prefs)
- department = M.client.prefs.prefered_security_department
+ department = M.client?.prefs?.read_preference(/datum/preference/choiced/security_department)
if(!LAZYLEN(GLOB.available_depts_sec) || department == "None")
return
else if(department in GLOB.available_depts_sec)
diff --git a/code/modules/keybindings/bindings_client.dm b/code/modules/keybindings/bindings_client.dm
index 3c44c4f112a2..037a48f61370 100644
--- a/code/modules/keybindings/bindings_client.dm
+++ b/code/modules/keybindings/bindings_client.dm
@@ -15,7 +15,7 @@
next_move_dir_add |= movement
//Focus Chat failsafe. Overrides movement checks to prevent WASD.
- if(!prefs.hotkeys && length(_key) == 1 && _key != "Alt" && _key != "Ctrl" && _key != "Shift")
+ if(!hotkeys && length(_key) == 1 && _key != "Alt" && _key != "Ctrl" && _key != "Shift")
winset(src, null, "input.focus=true ; input.text=[url_encode(_key)]")
return
@@ -31,7 +31,7 @@
else
full_key = "[AltMod][CtrlMod][ShiftMod][_key]"
var/keycount = 0
- for(var/kb_name in prefs.key_bindings[full_key])
+ for(var/kb_name in prefs.key_bindings_by_key[full_key])
keycount++
var/datum/keybinding/kb = GLOB.keybindings_by_name[kb_name]
if(kb.can_use(src) && kb.down(src) && keycount >= MAX_COMMANDS_PER_KEY)
@@ -55,7 +55,7 @@
// We don't do full key for release, because for mod keys you
// can hold different keys and releasing any should be handled by the key binding specifically
- for (var/kb_name in prefs.key_bindings[_key])
+ for (var/kb_name in prefs.key_bindings_by_key[_key])
var/datum/keybinding/kb = GLOB.keybindings_by_name[kb_name]
if(kb.can_use(src) && kb.up(src))
break
diff --git a/code/modules/keybindings/setup.dm b/code/modules/keybindings/setup.dm
index ce03506d3d6c..d7683d24d469 100644
--- a/code/modules/keybindings/setup.dm
+++ b/code/modules/keybindings/setup.dm
@@ -33,7 +33,7 @@
var/command = macro_set[key]
winset(src, "default-[REF(key)]", "parent=default;name=[key];command=[command]")
- if(prefs?.hotkeys)
+ if(hotkeys)
winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED]")
else
winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_DISABLED]")
diff --git a/code/modules/language/language_holder.dm b/code/modules/language/language_holder.dm
index 4e3945179672..663556c866d1 100644
--- a/code/modules/language/language_holder.dm
+++ b/code/modules/language/language_holder.dm
@@ -59,7 +59,10 @@ Key procs
var/datum/mind/M = owner
if(M.current)
update_atom_languages(M.current)
- get_selected_language()
+
+ // If we have an owner, we'll set a default selected language
+ if(owner)
+ get_selected_language()
/datum/language_holder/Destroy()
QDEL_NULL(language_menu)
diff --git a/code/modules/mob/dead/new_player/new_player.dm b/code/modules/mob/dead/new_player/new_player.dm
index 8d0e81b51aa2..62cead456234 100644
--- a/code/modules/mob/dead/new_player/new_player.dm
+++ b/code/modules/mob/dead/new_player/new_player.dm
@@ -117,7 +117,7 @@
relevant_cap = max(hpc, epc)
if(href_list["show_preferences"])
- client.prefs.ShowChoices(src)
+ //client.prefs.ShowChoices(src)
return 1
if(href_list["ready"])
diff --git a/code/modules/mob/dead/new_player/preferences_setup.dm b/code/modules/mob/dead/new_player/preferences_setup.dm
index 7703b6c0f551..b4c174dd6ccf 100644
--- a/code/modules/mob/dead/new_player/preferences_setup.dm
+++ b/code/modules/mob/dead/new_player/preferences_setup.dm
@@ -1,5 +1,14 @@
- //The mob should have a gender you want before running this proc. Will run fine without H
+/// Fully randomizes everything in the character.
+/datum/preferences/proc/randomise_appearance_prefs(randomize_flags = ALL)
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (!preference.included_in_randomization_flags(randomize_flags))
+ continue
+
+ if (preference.is_randomizable())
+ write_preference(preference, preference.create_random_value(src))
+
+//The mob should have a gender you want before running this proc. Will run fine without H
/datum/preferences/proc/random_character(gender_override)
if(gender_override)
gender = gender_override
@@ -33,33 +42,35 @@
features = temp_features
age = rand(AGE_MIN,AGE_MAX)
-/datum/preferences/proc/update_preview_icon()
- // Determine what job is marked as 'High' priority, and dress them up as such.
- var/datum/job/previewJob
+/// Returns what job is marked as highest
+/datum/preferences/proc/get_highest_priority_job()
+ var/datum/job/preview_job
var/highest_pref = 0
+
for(var/job in job_preferences)
if(job_preferences[job] > highest_pref)
- previewJob = SSjob.GetJob(job)
+ preview_job = SSjob.GetJob(job)
highest_pref = job_preferences[job]
- if(previewJob)
+ return preview_job
+
+/datum/preferences/proc/render_new_preview_appearance(mob/living/carbon/human/dummy/mannequin)
+ var/datum/job/preview_job = get_highest_priority_job()
+
+ if(preview_job)
// Silicons only need a very basic preview since there is no customization for them.
- if(istype(previewJob,/datum/job/ai))
- parent.show_character_previews(image('icons/mob/ai.dmi', icon_state = resolve_ai_icon(preferred_ai_core_display), dir = SOUTH))
- return
- if(istype(previewJob,/datum/job/cyborg))
- parent.show_character_previews(image('icons/mob/robots.dmi', icon_state = "robot", dir = SOUTH))
- return
+ if (istype(preview_job,/datum/job/ai))
+ return image('icons/mob/ai.dmi', icon_state = resolve_ai_icon(read_preference(/datum/preference/choiced/ai_core_display)), dir = SOUTH)
+ if (istype(preview_job,/datum/job/cyborg))
+ return image('icons/mob/robots.dmi', icon_state = "robot", dir = SOUTH)
// Set up the dummy for its photoshoot
- var/mob/living/carbon/human/dummy/mannequin = generate_or_wait_for_human_dummy(DUMMY_HUMAN_SLOT_PREFERENCES)
mannequin.add_overlay(mutable_appearance('icons/turf/floors.dmi', background, layer = SPACE_LAYER))
copy_to(mannequin)
- if(previewJob)
- mannequin.job = previewJob.title
- previewJob.equip(mannequin, TRUE, preference_source = parent)
+ if(preview_job)
+ mannequin.job = preview_job.title
+ mannequin.dress_up_as_job(preview_job, TRUE)
COMPILE_OVERLAYS(mannequin)
- parent.show_character_previews(new /mutable_appearance(mannequin))
- unset_busy_human_dummy(DUMMY_HUMAN_SLOT_PREFERENCES)
+ return mannequin.appearance
diff --git a/code/modules/mob/living/carbon/human/dummy.dm b/code/modules/mob/living/carbon/human/dummy.dm
index ce9b1e64d9af..8391acb58f06 100644
--- a/code/modules/mob/living/carbon/human/dummy.dm
+++ b/code/modules/mob/living/carbon/human/dummy.dm
@@ -69,10 +69,47 @@ INITIALIZE_IMMEDIATE(/mob/living/carbon/human/dummy)
cut_overlays(TRUE)
/mob/living/carbon/human/dummy/setup_human_dna()
- create_dna(src)
+ create_dna()
randomize_human(src)
dna.initialize_dna(skip_index = TRUE) //Skip stuff that requires full round init.
+/proc/create_consistent_human_dna(mob/living/carbon/human/target)
+ target.dna.initialize_dna(skip_index = TRUE)
+ target.dna.features["body_markings"] = "None"
+ target.dna.features["ears"] = "None"
+ target.dna.features["ethcolor"] = "EEEEEE" // white-ish
+ target.dna.features["frills"] = "None"
+ target.dna.features["horns"] = "None"
+ target.dna.features["mcolor"] = COLOR_VIBRANT_LIME
+ target.dna.features["moth_antennae"] = "Plain"
+ target.dna.features["moth_markings"] = "None"
+ target.dna.features["moth_wings"] = "Plain"
+ target.dna.features["snout"] = "Round"
+ target.dna.features["spines"] = "None"
+ target.dna.features["tail_cat"] = "None"
+ target.dna.features["tail_lizard"] = "Smooth"
+ target.dna.features["pod_hair"] = "Ivy"
+
+/// Provides a dummy that is consistently bald, white, naked, etc.
+/mob/living/carbon/human/dummy/consistent
+
+/mob/living/carbon/human/dummy/consistent/setup_human_dna()
+ create_dna()
+ create_consistent_human_dna(src)
+
+/// Provides a dummy for unit_tests that functions like a normal human, but with a standardized appearance
+/// Copies the stock dna setup from the dummy/consistent type
+/mob/living/carbon/human/consistent
+
+/mob/living/carbon/human/consistent/setup_human_dna()
+ create_dna()
+ create_consistent_human_dna(src)
+
+/mob/living/carbon/human/consistent/update_body(is_creating)
+ ..()
+ if(is_creating)
+ fully_replace_character_name(real_name, "John Doe")
+
//Inefficient pooling/caching way.
GLOBAL_LIST_EMPTY(human_dummy_list)
GLOBAL_LIST_EMPTY(dummy_mob_list)
diff --git a/code/modules/mob/living/carbon/human/species.dm b/code/modules/mob/living/carbon/human/species.dm
index 161b390d4fe8..ca3a8bd98bd3 100644
--- a/code/modules/mob/living/carbon/human/species.dm
+++ b/code/modules/mob/living/carbon/human/species.dm
@@ -3,6 +3,9 @@
GLOBAL_LIST_EMPTY(roundstart_races)
GLOBAL_LIST_EMPTY(mentor_races)
+/// An assoc list of species types to their features (from get_features())
+GLOBAL_LIST_EMPTY(features_by_species)
+
/datum/species
/// if the game needs to manually check your race to do something not included in a proc here, it will use this
var/id
@@ -10,6 +13,9 @@ GLOBAL_LIST_EMPTY(mentor_races)
var/limbs_id
/// this is the fluff name. these will be left generic (such as 'Lizardperson' for the lizard race) so servers can change them to whatever
var/name
+ /// The formatting of the name of the species in plural context. Defaults to "[name]\s" if unset.
+ /// Ex "[Plasmamen] are weak", "[Mothmen] are strong", "[Lizardpeople] don't like", "[Golems] hate"
+ var/plural_form
/// if alien colors are disabled, this is the color that will be used by that race
var/default_color = "#FFF"
/// whether or not the race has sexual characteristics. at the moment this is only FALSE for skeletons and shadows
@@ -31,7 +37,7 @@ GLOBAL_LIST_EMPTY(mentor_races)
var/use_skintones = FALSE
/// If your race wants to bleed something other than bog standard blood, change this to reagent id.
- var/exotic_blood = ""
+ var/datum/reagent/exotic_blood
///If your race uses a non standard bloodtype (A+, O-, AB-, etc)
var/exotic_bloodtype = ""
///What the species drops on gibbing
@@ -196,24 +202,49 @@ GLOBAL_LIST_EMPTY(mentor_races)
/datum/species/New()
-
if(!limbs_id) //if we havent set a limbs id to use, just use our own id
limbs_id = id
- ..()
+
+ if(!plural_form)
+ plural_form = "[name]\s"
+
+ return ..()
+
+/// Gets a list of all species available to choose in roundstart.
+/proc/get_selectable_species()
+ RETURN_TYPE(/list)
+
+ if (!GLOB.roundstart_races.len)
+ GLOB.roundstart_races = generate_selectable_species()
+ return GLOB.roundstart_races
+/**
+ * Generates species available to choose in character setup at roundstart
+ *
+ * This proc generates which species are available to pick from in character setup.
+ * If there are no available roundstart species, defaults to human.
+ */
/proc/generate_selectable_species()
- for(var/I in subtypesof(/datum/species))
- var/datum/species/S = new I
- if(S.check_roundstart_eligible())
- GLOB.roundstart_races += S.id
- qdel(S)
- else if(S.check_mentor())
- GLOB.mentor_races += S.id
- qdel(S)
- if(!GLOB.roundstart_races.len)
- GLOB.roundstart_races += "human"
+ var/list/selectable_species = list()
+
+ for(var/species_type in subtypesof(/datum/species))
+ var/datum/species/species = new species_type
+ if(species.check_roundstart_eligible())
+ selectable_species += species.id
+ qdel(species)
+
+ if(!selectable_species.len)
+ selectable_species += "human"
+
+ return selectable_species
+/**
+ * Checks if a species is eligible to be picked at roundstart.
+ *
+ * Checks the config to see if this species is allowed to be picked in the character setup menu.
+ * Used by [/proc/generate_selectable_species].
+ */
/datum/species/proc/check_roundstart_eligible()
if(id in (CONFIG_GET(keyed_list/roundstart_races)))
return TRUE
@@ -2324,3 +2355,402 @@ GLOBAL_LIST_EMPTY(mentor_races)
to_store += mutanttail
//We don't cache mutant hands because it's not constrained enough, too high a potential for failure
return to_store
+
+/// Returns a list of strings representing features this species has.
+/// Used by the preferences UI to know what buttons to show.
+/datum/species/proc/get_features()
+ var/cached_features = GLOB.features_by_species[type]
+ if (!isnull(cached_features))
+ return cached_features
+
+ var/list/features = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+
+ if ( \
+ (preference.relevant_mutant_bodypart in mutant_bodyparts) \
+ || (preference.relevant_species_trait in species_traits) \
+ )
+ features += preference.savefile_key
+
+ /*for (var/obj/item/organ/external/organ_type as anything in external_organs)
+ var/preference = initial(organ_type.preference)
+ if (!isnull(preference))
+ features += preference*/
+
+ GLOB.features_by_species[type] = features
+
+ return features
+
+/// Given a human, will adjust it before taking a picture for the preferences UI.
+/// This should create a CONSISTENT result, so the icons don't randomly change.
+/datum/species/proc/prepare_human_for_preview(mob/living/carbon/human/human)
+ return
+
+/**
+ * Gets a short description for the specices. Should be relatively succinct.
+ * Used in the preference menu.
+ *
+ * Returns a string.
+ */
+/datum/species/proc/get_species_description()
+ SHOULD_CALL_PARENT(FALSE)
+
+ stack_trace("Species [name] ([type]) did not have a description set, and is a selectable roundstart race! Override get_species_description.")
+ return "No species description set, file a bug report!"
+
+/**
+ * Gets the lore behind the type of species. Can be long.
+ * Used in the preference menu.
+ *
+ * Returns a list of strings.
+ * Between each entry in the list, a newline will be inserted, for formatting.
+ */
+/datum/species/proc/get_species_lore()
+ SHOULD_CALL_PARENT(FALSE)
+ RETURN_TYPE(/list)
+
+ stack_trace("Species [name] ([type]) did not have lore set, and is a selectable roundstart race! Override get_species_lore.")
+ return list("No species lore set, file a bug report!")
+
+/**
+ * Translate the species liked foods from bitfields into strings
+ * and returns it in the form of an associated list.
+ *
+ * Returns a list, or null if they have no diet.
+ */
+/datum/species/proc/get_species_diet()
+ if(TRAIT_NOHUNGER in inherent_traits)
+ return null
+
+ var/list/food_flags = FOOD_FLAGS
+
+ return list(
+ "liked_food" = bitfield_to_list(liked_food, food_flags),
+ "disliked_food" = bitfield_to_list(disliked_food, food_flags),
+ "toxic_food" = bitfield_to_list(toxic_food, food_flags),
+ )
+
+/**
+ * Generates a list of "perks" related to this species
+ * (Postives, neutrals, and negatives)
+ * in the format of a list of lists.
+ * Used in the preference menu.
+ *
+ * "Perk" format is as followed:
+ * list(
+ * SPECIES_PERK_TYPE = type of perk (postiive, negative, neutral - use the defines)
+ * SPECIES_PERK_ICON = icon shown within the UI
+ * SPECIES_PERK_NAME = name of the perk on hover
+ * SPECIES_PERK_DESC = description of the perk on hover
+ * )
+ *
+ * Returns a list of lists.
+ * The outer list is an assoc list of [perk type]s to a list of perks.
+ * The innter list is a list of perks. Can be empty, but won't be null.
+ */
+/datum/species/proc/get_species_perks()
+ var/list/species_perks = list()
+
+ // Let us get every perk we can concieve of in one big list.
+ // The order these are called (kind of) matters.
+ // Species unique perks first, as they're more important than genetic perks,
+ // and language perk last, as it comes at the end of the perks list
+ species_perks += create_pref_unique_perks()
+ species_perks += create_pref_blood_perks()
+ species_perks += create_pref_combat_perks()
+ species_perks += create_pref_damage_perks()
+ species_perks += create_pref_temperature_perks()
+ species_perks += create_pref_traits_perks()
+ species_perks += create_pref_biotypes_perks()
+ species_perks += create_pref_language_perk()
+
+ // Some overrides may return `null`, prevent those from jamming up the list.
+ listclearnulls(species_perks)
+
+ // Now let's sort them out for cleanliness and sanity
+ var/list/perks_to_return = list(
+ SPECIES_POSITIVE_PERK = list(),
+ SPECIES_NEUTRAL_PERK = list(),
+ SPECIES_NEGATIVE_PERK = list(),
+ )
+
+ for(var/list/perk as anything in species_perks)
+ var/perk_type = perk[SPECIES_PERK_TYPE]
+ // If we find a perk that isn't postiive, negative, or neutral,
+ // it's a bad entry - don't add it to our list. Throw a stack trace and skip it instead.
+ if(isnull(perks_to_return[perk_type]))
+ stack_trace("Invalid species perk ([perk[SPECIES_PERK_NAME]]) found for species [name]. \
+ The type should be positive, negative, or neutral. (Got: [perk_type])")
+ continue
+
+ perks_to_return[perk_type] += list(perk)
+
+ return perks_to_return
+
+/**
+ * Used to add any species specific perks to the perk list.
+ *
+ * Returns null by default. When overriding, return a list of perks.
+ */
+/datum/species/proc/create_pref_unique_perks()
+ return null
+
+/**
+ * Adds adds any perks related to combat.
+ * For example, the damage type of their punches.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_combat_perks()
+ var/list/to_add = list()
+
+ if(attack_type != BRUTE)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "fist-raised",
+ SPECIES_PERK_NAME = "Elemental Attacker",
+ SPECIES_PERK_DESC = "[plural_form] deal [attack_type] damage with their punches instead of brute.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to sustaining damage.
+ * For example, brute damage vulnerability, or fire damage resistance.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_damage_perks()
+ var/list/to_add = list()
+
+ // Brute related
+ if(brutemod > 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "band-aid",
+ SPECIES_PERK_NAME = "Brutal Weakness",
+ SPECIES_PERK_DESC = "[plural_form] are weak to brute damage.",
+ ))
+
+ if(brutemod < 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "shield-alt",
+ SPECIES_PERK_NAME = "Brutal Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to bruising and brute damage.",
+ ))
+
+ // Burn related
+ if(burnmod > 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "burn",
+ SPECIES_PERK_NAME = "Fire Weakness",
+ SPECIES_PERK_DESC = "[plural_form] are weak to fire and burn damage.",
+ ))
+
+ if(burnmod < 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "shield-alt",
+ SPECIES_PERK_NAME = "Fire Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to flames, and burn damage.",
+ ))
+
+ // Shock damage
+ if(siemens_coeff > 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "bolt",
+ SPECIES_PERK_NAME = "Shock Vulnerability",
+ SPECIES_PERK_DESC = "[plural_form] are vulnerable to being shocked.",
+ ))
+
+ if(siemens_coeff < 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "shield-alt",
+ SPECIES_PERK_NAME = "Shock Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to being shocked.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to how the species deals with temperature.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ // Hot temperature tolerance
+ if(heatmod > 1/* || bodytemp_heat_damage_limit < BODYTEMP_HEAT_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "temperature-high",
+ SPECIES_PERK_NAME = "Heat Vulnerability",
+ SPECIES_PERK_DESC = "[plural_form] are vulnerable to high temperatures.",
+ ))
+
+ if(heatmod < 1/* || bodytemp_heat_damage_limit > BODYTEMP_HEAT_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "thermometer-empty",
+ SPECIES_PERK_NAME = "Heat Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to hotter environments.",
+ ))
+
+ // Cold temperature tolerance
+ if(coldmod > 1/* || bodytemp_cold_damage_limit > BODYTEMP_COLD_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "temperature-low",
+ SPECIES_PERK_NAME = "Cold Vulnerability",
+ SPECIES_PERK_DESC = "[plural_form] are vulnerable to cold temperatures.",
+ ))
+
+ if(coldmod < 1/* || bodytemp_cold_damage_limit < BODYTEMP_COLD_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "thermometer-empty",
+ SPECIES_PERK_NAME = "Cold Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to colder environments.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to the species' blood (or lack thereof).
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_blood_perks()
+ var/list/to_add = list()
+
+ // NOBLOOD takes priority by default
+ if(NOBLOOD in species_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "tint-slash",
+ SPECIES_PERK_NAME = "Bloodletted",
+ SPECIES_PERK_DESC = "[plural_form] do not have blood.",
+ ))
+
+ // Otherwise, check if their exotic blood is a valid typepath
+ else if(ispath(exotic_blood))
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = initial(exotic_blood.name),
+ SPECIES_PERK_DESC = "[name] blood is [initial(exotic_blood.name)], which can make recieving medical treatment harder.",
+ ))
+
+ // Otherwise otherwise, see if they have an exotic bloodtype set
+ else if(exotic_bloodtype)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = "Exotic Blood",
+ SPECIES_PERK_DESC = "[plural_form] have \"[exotic_bloodtype]\" type blood, which can make recieving medical treatment harder.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to the species' inherent_traits list.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_traits_perks()
+ var/list/to_add = list()
+
+ if(TRAIT_LIMBATTACHMENT in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "user-plus",
+ SPECIES_PERK_NAME = "Limbs Easily Reattached",
+ SPECIES_PERK_DESC = "[plural_form] limbs are easily readded, and as such do not \
+ require surgery to restore. Simply pick it up and pop it back in, champ!",
+ ))
+
+ if(TRAIT_EASYDISMEMBER in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "user-times",
+ SPECIES_PERK_NAME = "Limbs Easily Dismembered",
+ SPECIES_PERK_DESC = "[plural_form] limbs are not secured well, and as such they are easily dismembered.",
+ ))
+
+ if(TRAIT_EASILY_WOUNDED in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "user-times",
+ SPECIES_PERK_NAME = "Easily Wounded",
+ SPECIES_PERK_DESC = "[plural_form] skin is very weak and fragile. They are much easier to apply serious wounds to.",
+ ))
+
+ if(TRAIT_TOXINLOVER in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "syringe",
+ SPECIES_PERK_NAME = "Toxins Lover",
+ SPECIES_PERK_DESC = "Toxins damage dealt to [plural_form] are reversed - healing toxins will instead cause harm, and \
+ causing toxins will instead cause healing. Be careful around purging chemicals!",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to the species' inherent_biotypes flags.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ if(MOB_UNDEAD in inherent_biotypes)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "skull",
+ SPECIES_PERK_NAME = "Undead",
+ SPECIES_PERK_DESC = "[plural_form] are of the undead! The undead do not have the need to eat or breathe, and \
+ most viruses will not be able to infect a walking corpse. Their worries mostly stop at remaining in one piece, really.",
+ ))
+
+ return to_add
+
+/**
+ * Adds in a language perk based on all the languages the species
+ * can speak by default (according to their language holder).
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_language_perk()
+ var/list/to_add = list()
+
+ // Grab galactic common as a path, for comparisons
+ var/datum/language/common_language = /datum/language/common
+
+ // Now let's find all the languages they can speak that aren't common
+ var/list/bonus_languages = list()
+ var/datum/language_holder/temp_holder = new species_language_holder()
+ for(var/datum/language/language_type as anything in temp_holder.spoken_languages)
+ if(ispath(language_type, common_language))
+ continue
+ bonus_languages += initial(language_type.name)
+
+ // If we have any languages we can speak: create a perk for them all
+ if(length(bonus_languages))
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "comment",
+ SPECIES_PERK_NAME = "Native Speaker",
+ SPECIES_PERK_DESC = "Alongside [initial(common_language.name)], [plural_form] gain the ability to speak [english_list(bonus_languages)].",
+ ))
+
+ qdel(temp_holder)
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/IPC.dm b/code/modules/mob/living/carbon/human/species_types/IPC.dm
index 213b3942ec19..0af85a0d352d 100644
--- a/code/modules/mob/living/carbon/human/species_types/IPC.dm
+++ b/code/modules/mob/living/carbon/human/species_types/IPC.dm
@@ -96,6 +96,30 @@ datum/species/ipc/on_species_loss(mob/living/carbon/C)
C.dna.features["ipc_screen"] = null //Turns off screen on death
C.update_body()
+/datum/species/ipc/get_species_description()
+ return "IPCs, or Integrated Posibrain Chassis, are a series of constructed bipedal humanoids which vaguely represent humans in their figure. \
+ IPCs were made by several human corporations after the second generation of cyborg units was created."
+
+/datum/species/ipc/get_species_lore()
+ return list(
+ "The development and creation of IPCs was a natural occurrence after Sol Interplanetary Coalition explorers, flying a Martian flag, uncovered MMI technology in 2419. \
+ It was massively hoped by scientists, explorers, and opportunists that this discovery would lead to a breakthrough in humanity’s ability to access and understand much of the derelict technology left behind."
+ )
+
+/datum/species/ipc/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
+/datum/species/ipc/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
/datum/action/innate/change_screen
name = "Change Display"
check_flags = AB_CHECK_CONSCIOUS
diff --git a/code/modules/mob/living/carbon/human/species_types/dullahan.dm b/code/modules/mob/living/carbon/human/species_types/dullahan.dm
index 4bc1a41367db..44debbb969c5 100644
--- a/code/modules/mob/living/carbon/human/species_types/dullahan.dm
+++ b/code/modules/mob/living/carbon/human/species_types/dullahan.dm
@@ -62,6 +62,49 @@
else
H.reset_perspective(myhead)
+/datum/species/dullahan/get_species_description()
+ return "An angry spirit, hanging onto the land of the living for \
+ unfinished business. Or that's what the books say. They're quite nice \
+ when you get to know them."
+
+/datum/species/dullahan/get_species_lore()
+ return list(
+ "\"No wonder they're all so grumpy! Their hands are always full! I used to think, \
+ \"Wouldn't this be cool?\" but after watching these creatures suffer from their head \
+ getting dunked down disposals for the nth time, I think I'm good.\" - Captain Larry Dodd"
+ )
+
+/datum/species/dullahan/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "horse-head",
+ SPECIES_PERK_NAME = "Headless and Horseless",
+ SPECIES_PERK_DESC = "Dullahans must lug their head around in their arms. While \
+ many creative uses can come out of your head being independent of your \
+ body, Dullahans will find it mostly a pain.",
+ ))
+
+ return to_add
+
+// There isn't a "Minor Undead" biotype, so we have to explain it in an override (see: vampires)
+/datum/species/dullahan/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "skull",
+ SPECIES_PERK_NAME = "Minor Undead",
+ SPECIES_PERK_DESC = "[name] are minor undead. \
+ Minor undead enjoy some of the perks of being dead, like \
+ not needing to breathe or eat, but do not get many of the \
+ environmental immunities involved with being fully undead.",
+ ))
+
+ return to_add
+
+
/obj/item/organ/brain/dullahan
decoy_override = TRUE
organ_flags = 0
diff --git a/code/modules/mob/living/carbon/human/species_types/ethereal.dm b/code/modules/mob/living/carbon/human/species_types/ethereal.dm
index 7aa228f49ccb..3b28a71057c5 100644
--- a/code/modules/mob/living/carbon/human/species_types/ethereal.dm
+++ b/code/modules/mob/living/carbon/human/species_types/ethereal.dm
@@ -34,6 +34,7 @@
hair_color = "fixedmutcolor"
hair_alpha = 140
swimming_component = /datum/component/swimming/ethereal
+
var/current_color
var/EMPeffect = FALSE
var/emageffect = FALSE
@@ -50,13 +51,15 @@
/datum/species/ethereal/on_species_gain(mob/living/carbon/C, datum/species/old_species, pref_load)
.=..()
- if(ishuman(C))
- var/mob/living/carbon/human/H = C
- default_color = "#" + H.dna.features["ethcolor"]
- r1 = GETREDPART(default_color)
- g1 = GETGREENPART(default_color)
- b1 = GETBLUEPART(default_color)
- spec_updatehealth(H)
+ if(!ishuman(C))
+ return
+
+ var/mob/living/carbon/human/H = C
+ default_color = "#[H.dna.features["ethcolor"]]"
+ r1 = GETREDPART(default_color)
+ g1 = GETGREENPART(default_color)
+ b1 = GETBLUEPART(default_color)
+ spec_updatehealth(H)
/datum/species/ethereal/on_species_loss(mob/living/carbon/human/C, datum/species/new_species, pref_load)
.=..()
@@ -183,3 +186,55 @@
if(istype(stomach))
return stomach.crystal_charge
return ETHEREAL_CHARGE_NONE
+
+/datum/species/ethereal/get_features()
+ var/list/features = ..()
+
+ features += "feature_ethcolor"
+
+ return features
+
+/datum/species/ethereal/get_species_description()
+ return "Coming from the planet of Sprout, the theocratic ethereals are \
+ separated socially by caste, and espouse a dogma of aiding the weak and \
+ downtrodden."
+
+/datum/species/ethereal/get_species_lore()
+ return list(
+ "Ethereals are a species native to the planet Sprout. \
+ When they were originally discovered, they were at a medieval level of technological progression, \
+ but due to their natural acclimation with electricity, they felt easy among the large NanoTrasen installations.",
+ )
+
+/datum/species/ethereal/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bolt",
+ SPECIES_PERK_NAME = "Shockingly Tasty",
+ SPECIES_PERK_DESC = "Ethereals can feed on electricity from APCs, and do not otherwise need to eat.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "lightbulb",
+ SPECIES_PERK_NAME = "Disco Ball",
+ SPECIES_PERK_DESC = "Ethereals passively generate their own light.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "gem",
+ SPECIES_PERK_NAME = "Crystal Core",
+ SPECIES_PERK_DESC = "The Ethereal's heart will encase them in crystal should they die, returning them to life after a time - \
+ at the cost of a permanent brain trauma.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "biohazard",
+ SPECIES_PERK_NAME = "Starving Artist",
+ SPECIES_PERK_DESC = "Ethereals take toxin damage while starving.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/felinid.dm b/code/modules/mob/living/carbon/human/species_types/felinid.dm
index 47de51ab9323..bdbbaaa3dd3c 100644
--- a/code/modules/mob/living/carbon/human/species_types/felinid.dm
+++ b/code/modules/mob/living/carbon/human/species_types/felinid.dm
@@ -192,3 +192,60 @@
H.emote("wag")
if(-1)
stop_wagging_tail(H)
+
+/datum/species/human/felinid/prepare_human_for_preview(mob/living/carbon/human/human)
+ human.hair_style = "Hime Cut"
+ human.hair_color = "fcc" // pink
+ human.update_hair()
+
+ var/obj/item/organ/ears/cat/cat_ears = human.getorgan(/obj/item/organ/ears/cat)
+ if (cat_ears)
+ cat_ears.color = human.hair_color
+ human.update_body()
+
+/datum/species/human/felinid/get_species_description()
+ return "Felinids are one of the many types of bespoke genetic \
+ modifications to come of humanity's mastery of genetic science, and are \
+ also one of the most common. Meow?"
+
+/datum/species/human/felinid/get_species_lore()
+ return list(
+ "Bio-engineering at its felinest, Felinids are the peak example of humanity's mastery of genetic code. \
+ One of many \"Animalid\" variants, Felinids are the most popular and common, as well as one of the \
+ biggest points of contention in genetic-modification.",
+
+ "Body modders were eager to splice human and feline DNA in search of the holy trifecta: ears, eyes, and tail. \
+ These traits were in high demand, with the corresponding side effects of vocal and neurochemical changes being seen as a minor inconvenience.",
+
+ "Sadly for the Felinids, they were not minor inconveniences. Shunned as subhuman and monstrous by many, Felinids (and other Animalids) \
+ sought their greener pastures out in the colonies, cloistering in communities of their own kind. \
+ As a result, outer Human space has a high Animalid population.",
+ )
+
+// Felinids are subtypes of humans.
+// This shouldn't call parent or we'll get a buncha human related perks (though it doesn't have a reason to).
+/datum/species/human/felinid/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "grin-tongue",
+ SPECIES_PERK_NAME = "Grooming",
+ SPECIES_PERK_DESC = "Felinids can lick wounds to reduce bleeding.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "assistive-listening-systems",
+ SPECIES_PERK_NAME = "Sensitive Hearing",
+ SPECIES_PERK_DESC = "Felinids are more sensitive to loud sounds, such as flashbangs.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "shower",
+ SPECIES_PERK_NAME = "Hydrophobia",
+ SPECIES_PERK_DESC = "Felinids don't like getting soaked with water.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/flypeople.dm b/code/modules/mob/living/carbon/human/species_types/flypeople.dm
index 4ccd49169019..a8de26aa0299 100644
--- a/code/modules/mob/living/carbon/human/species_types/flypeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/flypeople.dm
@@ -1,5 +1,6 @@
/datum/species/fly
name = "Flyperson"
+ plural_form = "Flypeople"
id = "fly"
say_mod = "buzzes"
species_traits = list(NOEYESPRITES, HAS_FLESH, HAS_BONE)
@@ -31,3 +32,57 @@
if(istype(weapon, /obj/item/melee/flyswatter))
return 29 //Flyswatters deal 30x damage to flypeople.
return 0
+
+/datum/species/fly/get_species_description()
+ return "With no official documentation or knowledge of the origin of \
+ this species, they remain a mystery to most. Any and all rumours among \
+ Nanotrasen staff regarding flypeople are often quickly silenced by high \
+ ranking staff or officials."
+
+/datum/species/fly/get_species_lore()
+ return list(
+ "Flypeople are a curious species with a striking resemblance to the insect order of Diptera, \
+ commonly known as flies. With no publically known origin, flypeople are rumored to be a side effect of bluespace travel, \
+ despite statements from Nanotrasen officials.",
+
+ "Little is known about the origins of this race, \
+ however they posess the ability to communicate with giant spiders, originally discovered in the Australicus sector \
+ and now a common occurence in black markets as a result of a breakthrough in syndicate bioweapon research.",
+
+ "Flypeople are often feared or avoided among other species, their appearance often described as unclean or frightening in some cases, \
+ and their eating habits even more so with an insufferable accent to top it off.",
+ )
+
+/datum/species/fly/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "grin-tongue",
+ SPECIES_PERK_NAME = "Uncanny Digestive System",
+ SPECIES_PERK_DESC = "Flypeople regurgitate their stomach contents and drink it \
+ off the floor to eat and drink with little care for taste, favoring gross foods.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fist-raised",
+ SPECIES_PERK_NAME = "Insectoid Biology",
+ SPECIES_PERK_DESC = "Fly swatters will deal significantly higher amounts of damage to a Flyperson.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "sun",
+ SPECIES_PERK_NAME = "Radial Eyesight",
+ SPECIES_PERK_DESC = "Flypeople can be flashed from all angles.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "briefcase-medical",
+ SPECIES_PERK_NAME = "Weird Organs",
+ SPECIES_PERK_DESC = "Flypeople take specialized medical knowledge to be \
+ treated. Their organs are disfigured and organ manipulation can be interesting...",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/golems.dm b/code/modules/mob/living/carbon/human/species_types/golems.dm
index ccf8569f0e58..18aa0f9e1b7f 100644
--- a/code/modules/mob/living/carbon/human/species_types/golems.dm
+++ b/code/modules/mob/living/carbon/human/species_types/golems.dm
@@ -44,6 +44,21 @@
var/golem_name = "[prefix] [golem_surname]"
return golem_name
+/datum/species/golem/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "gem",
+ SPECIES_PERK_NAME = "Lithoid",
+ SPECIES_PERK_DESC = "Lithoids are creatures made out of elements instead of \
+ blood and flesh. Because of this, they're generally stronger, slower, \
+ and mostly immune to environmental dangers and dangers to their health, \
+ such as viruses and dismemberment.",
+ ))
+
+ return to_add
+
/datum/species/golem/random
name = "Random Golem"
changesource_flags = MIRROR_BADMIN | WABBAJACK | MIRROR_PRIDE | MIRROR_MAGIC | RACE_SWAP | ERT_SPAWN
@@ -1548,3 +1563,46 @@
/obj/item/melee/supermatter_sword/hand/Initialize(mapload,silent,synthetic)
. = ..()
ADD_TRAIT(src, TRAIT_NODROP, INNATE_TRAIT)
+
+
+/datum/species/golem/cloth/get_species_description()
+ return "A wrapped up Mummy! They descend upon Space Station Thirteen every year to spook the crew! \"Return the slab!\""
+
+/datum/species/golem/cloth/get_species_lore()
+ return list(
+ "Mummies are very self conscious. They're shaped weird, they walk slow, and worst of all, \
+ they're considered the laziest halloween costume. But that's not even true, they say.",
+
+ "Making a mummy costume may be easy, but making a CONVINCING mummy costume requires \
+ things like proper fabric and purposeful staining to achieve the look. Which is FAR from easy. Gosh.",
+ )
+
+// Calls parent, as Golems have a species-wide perk we care about.
+/datum/species/golem/cloth/create_pref_unique_perks()
+ var/list/to_add = ..()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "recycle",
+ SPECIES_PERK_NAME = "Reformation",
+ SPECIES_PERK_DESC = "A boon quite similar to Ethereals, Mummies collapse into \
+ a pile of bandages after they die. If left alone, they will reform back \
+ into themselves. The bandages themselves are very vulnerable to fire.",
+ ))
+
+ return to_add
+
+// Override to add a perk elaborating on just how dangerous fire is.
+/datum/species/golem/cloth/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fire-alt",
+ SPECIES_PERK_NAME = "Incredibly Flammable",
+ SPECIES_PERK_DESC = "Mummies are made entirely of cloth, which makes them \
+ very vulnerable to fire. They will not reform if they die while on \
+ fire, and they will easily catch alight. If your bandages burn to ash, you're toast!",
+ ))
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/humans.dm b/code/modules/mob/living/carbon/human/species_types/humans.dm
index 069c9192552a..98c6139c47d2 100644
--- a/code/modules/mob/living/carbon/human/species_types/humans.dm
+++ b/code/modules/mob/living/carbon/human/species_types/humans.dm
@@ -29,3 +29,54 @@
if(prob(1))
return 'sound/voice/human/wilhelm_scream.ogg'
return pick(male_screams)
+
+/datum/species/human/prepare_human_for_preview(mob/living/carbon/human/human)
+ human.hair_style = "Business Hair"
+ human.hair_color = "b96" // brown
+ human.update_hair()
+
+/datum/species/human/get_species_description()
+ return "Humans are the dominant species in the known galaxy. \
+ Their kind extend from old Earth to the edges of known space."
+
+/datum/species/human/get_species_lore()
+ return list(
+ "These primate-descended creatures, originating from the mostly harmless Earth, \
+ have long-since outgrown their home and semi-benign designation. \
+ The space age has taken humans out of their solar system and into the galaxy-at-large.",
+
+ "In traditional human fashion, this near-record pace from terra firma to the final frontier spat \
+ in the face of other races they now shared a stage with. \
+ This included the lizards - if anyone was offended by these upstarts, it was certainly lizardkind.",
+
+ "Humanity never managed to find the kind of peace to fully unite under one banner like other species. \
+ The pencil and paper pushing of the UN bureaucrat lives on in the mosaic that is TerraGov; \
+ a composite of the nation-states that still live on in human society.",
+
+ "The human spirit of opportunity and enterprise continues on in its peak form: \
+ the hypercorporation. Acting outside of TerraGov's influence, literally and figuratively, \
+ hypercorporations buy the senate votes they need and establish territory far past the Earth Government's reach. \
+ In hypercorporation territory company policy is law, giving new meaning to \"employee termination\".",
+ )
+
+/datum/species/human/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ if(CONFIG_GET(number/default_laws) == 0) // Default lawset is set to Asimov
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "robot",
+ SPECIES_PERK_NAME = "Asimov Superiority",
+ SPECIES_PERK_DESC = "The AI and their cyborgs are, by default, subservient only \
+ to humans. As a human, silicons are required to both protect and obey you.",
+ ))
+
+ if(CONFIG_GET(flag/enforce_human_authority))
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bullhorn",
+ SPECIES_PERK_NAME = "Chain of Command",
+ SPECIES_PERK_DESC = "Nanotrasen only recognizes humans for command roles, such as Captain.",
+ ))
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/jellypeople.dm b/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
index 7690f3b666ea..635e3af30773 100644
--- a/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
@@ -1,6 +1,7 @@
/datum/species/jelly
// Entirely alien beings that seem to be made entirely out of gel. They have three eyes and a skeleton visible within them.
name = "Jellyperson"
+ plural_form = "Jellypeople"
id = "jelly"
default_color = "00FF90"
say_mod = "chirps"
@@ -67,6 +68,21 @@
qdel(consumed_limb)
H.blood_volume += 20
+// Slimes have both NOBLOOD and an exotic bloodtype set, so they need to be handled uniquely here.
+// They may not be roundstart but in the unlikely event they become one might as well not leave a glaring issue open.
+/datum/species/jelly/create_pref_blood_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = "Jelly Blood",
+ SPECIES_PERK_DESC = "[plural_form] don't have blood, but instead have toxic [initial(exotic_blood.name)]! \
+ Jelly is extremely important, as losing it will cause you to lose limbs. Having low jelly will make medical treatment very difficult.",
+ ))
+
+ return to_add
+
/datum/action/innate/regenerate_limbs
name = "Regenerate Limbs"
check_flags = AB_CHECK_CONSCIOUS
@@ -112,6 +128,7 @@
/datum/species/jelly/slime
name = "Slimeperson"
+ plural_form = "Slimepeople"
id = "slime"
default_color = "00FFFF"
species_traits = list(MUTCOLORS,EYECOLOR,HAIR,FACEHAIR,NOBLOOD)
@@ -384,6 +401,7 @@
/datum/species/jelly/luminescent
name = "Luminescent"
+ plural_form = null
id = "lum"
say_mod = "says"
var/glow_intensity = LUMINESCENT_DEFAULT_GLOW
@@ -551,6 +569,7 @@
/datum/species/jelly/stargazer
name = "Stargazer"
+ plural_form = null
id = "stargazer"
var/datum/action/innate/project_thought/project_thought
var/datum/action/innate/link_minds/link_minds
diff --git a/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm b/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm
index 2d6b23507ad5..e07efbb4cdc9 100644
--- a/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm
@@ -1,6 +1,7 @@
/datum/species/lizard
// Reptilian humanoids with scaled skin and tails.
name = "Lizardperson"
+ plural_form = "Lizardfolk"
id = "lizard"
say_mod = "hisses"
default_color = "00FF00"
@@ -89,6 +90,45 @@
if(-1)
stop_wagging_tail(H)
+/datum/species/lizard/get_species_description()
+ return "The militaristic Lizardpeople hail originally from Tizira, but have grown \
+ throughout their centuries in the stars to possess a large spacefaring \
+ empire: though now they must contend with their younger, more \
+ technologically advanced Human neighbours."
+
+/datum/species/lizard/get_species_lore()
+ return list(
+ "The face of conspiracy theory was changed forever the day mankind met the lizards.",
+
+ "Hailing from the arid world of Tizira, lizards were travelling the stars back when mankind was first discovering how neat trains could be. \
+ However, much like the space-fable of the space-tortoise and space-hare, lizards have rejected their kin's motto of \"slow and steady\" \
+ in favor of resting on their laurels and getting completely surpassed by 'bald apes', due in no small part to their lack of access to plasma.",
+
+ "The history between lizards and humans has resulted in many conflicts that lizards ended on the losing side of, \
+ with the finale being an explosive remodeling of their moon. Today's lizard-human relations are seeing the continuance of a record period of peace.",
+
+ "Lizard culture is inherently militaristic, though the influence the military has on lizard culture \
+ begins to lessen the further colonies lie from their homeworld - \
+ with some distanced colonies finding themselves subsumed by the cultural practices of other species nearby.",
+
+ "On their homeworld, lizards celebrate their 16th birthday by enrolling in a mandatory 5 year military tour of duty. \
+ Roles range from combat to civil service and everything in between. As the old slogan goes: \"Your place will be found!\"",
+ )
+
+// Override for the default temperature perks, so we can give our specific "cold blooded" perk.
+/datum/species/lizard/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "thermometer-empty",
+ SPECIES_PERK_NAME = "Cold-blooded",
+ SPECIES_PERK_DESC = "Lizardpeople have higher tolerance for hot temperatures, but lower \
+ tolerance for cold temperatures. Additionally, they cannot self-regulate their body temperature - \
+ they are as cold or as warm as the environment around them is. Stay warm!",
+ ))
+
+ return to_add
/*
Lizard subspecies: ASHWALKERS
diff --git a/code/modules/mob/living/carbon/human/species_types/mothmen.dm b/code/modules/mob/living/carbon/human/species_types/mothmen.dm
index f3efed0db1d6..6e3ff010adf2 100644
--- a/code/modules/mob/living/carbon/human/species_types/mothmen.dm
+++ b/code/modules/mob/living/carbon/human/species_types/mothmen.dm
@@ -1,5 +1,6 @@
/datum/species/moth
name = "Mothperson"
+ plural_form = "Mothpeople"
id = "moth"
say_mod = "flutters"
default_color = "00FF00"
@@ -71,3 +72,57 @@
var/datum/gas_mixture/current = H.loc.return_air()
if(current && (current.return_pressure() >= ONE_ATMOSPHERE*0.85)) //as long as there's reasonable pressure and no gravity, flight is possible
return TRUE
+
+/datum/species/moth/get_species_description()
+ return "Hailing from a planet that was lost long ago, the moths travel \
+ the galaxy as a nomadic people aboard a colossal fleet of ships, seeking a new homeland."
+
+/datum/species/moth/get_species_lore()
+ return list(
+ "Their homeworld lost to the ages, the moths live aboard the Grand Nomad Fleet. \
+ Made up of what could be found, bartered, repaired, or stolen the armada is a colossal patchwork \
+ built on a history of politely flagging travelers down and taking their things. Occasionally a moth \
+ will decide to leave the fleet, usually to strike out for fortunes to send back home.",
+
+ "Nomadic life produces a tight-knit culture, with moths valuing their friends, family, and vessels highly. \
+ Moths are gregarious by nature and do best in communal spaces. This has served them well on the galactic stage, \
+ maintaining a friendly and personable reputation even in the face of hostile encounters. \
+ It seems that the galaxy has come to accept these former pirates.",
+
+ "Surprisingly, living together in a giant fleet hasn't flattened variance in dialect and culture. \
+ These differences are welcomed and encouraged within the fleet for the variety that they bring.",
+ )
+
+/datum/species/moth/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "feather-alt",
+ SPECIES_PERK_NAME = "Precious Wings",
+ SPECIES_PERK_DESC = "Moths can fly in pressurized, zero-g environments and safely land short falls using their wings.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "tshirt",
+ SPECIES_PERK_NAME = "Meal Plan",
+ SPECIES_PERK_DESC = "Moths can eat clothes for nourishment.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fire",
+ SPECIES_PERK_NAME = "Ablazed Wings",
+ SPECIES_PERK_DESC = "Moth wings are fragile, and can be easily burnt off.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "sun",
+ SPECIES_PERK_NAME = "Bright Lights",
+ SPECIES_PERK_DESC = "Moths need an extra layer of flash protection to protect \
+ themselves, such as against security officers or when welding. Welding \
+ masks will work.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/mushpeople.dm b/code/modules/mob/living/carbon/human/species_types/mushpeople.dm
index 13761a2d60b5..3087a3c397e6 100644
--- a/code/modules/mob/living/carbon/human/species_types/mushpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/mushpeople.dm
@@ -1,5 +1,6 @@
/datum/species/mush //mush mush codecuck
name = "Mushroomperson"
+ plural_form = "Mushroompeople"
id = "mush"
mutant_bodyparts = list("caps")
default_features = list("caps" = "Round")
diff --git a/code/modules/mob/living/carbon/human/species_types/plasmamen.dm b/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
index b02e13af6d9b..888b0899118f 100644
--- a/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
+++ b/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
@@ -1,5 +1,6 @@
/datum/species/plasmaman
name = "Plasmaman"
+ plural_form = "Plasmamen"
id = "plasmaman"
say_mod = "rattles"
sexes = FALSE
@@ -22,7 +23,6 @@
payday_modifier = 0.8 //Useful to NT for plasma research
breathid = "tox"
damage_overlay_type = ""//let's not show bloody wounds or burns over bones.
- var/internal_fire = FALSE //If the bones themselves are burning clothes won't help you much
disliked_food = NONE
liked_food = DAIRY
changesource_flags = MIRROR_BADMIN | WABBAJACK | MIRROR_PRIDE | MIRROR_MAGIC
@@ -30,6 +30,9 @@
smells_like = "plasma-caked calcium"
+ /// If the bones themselves are burning clothes won't help you much
+ var/internal_fire = FALSE
+
/datum/species/plasmaman/spec_life(mob/living/carbon/human/H)
var/datum/gas_mixture/environment = H.loc.return_air()
var/atmos_sealed = FALSE
@@ -187,3 +190,79 @@
randname += " [lastname]"
return randname
+
+/datum/species/plasmaman/get_species_description()
+ return "Found on the Icemoon of Freyja, plasmamen consist of colonial \
+ fungal organisms which together form a sentient being. In human space, \
+ they're usually attached to skeletons to afford a human touch."
+
+/datum/species/plasmaman/get_species_lore()
+ return list(
+ "A confusing species, plasmamen are truly \"a fungus among us\". \
+ What appears to be a singular being is actually a colony of millions of organisms \
+ surrounding a found (or provided) skeletal structure.",
+
+ "Originally discovered by NT when a researcher \
+ fell into an open tank of liquid plasma, the previously unnoticed fungal colony overtook the body creating \
+ the first \"true\" plasmaman. The process has since been streamlined via generous donations of convict corpses and plasmamen \
+ have been deployed en masse throughout NT to bolster the workforce.",
+
+ "New to the galactic stage, plasmamen are a blank slate. \
+ Their appearance, generally regarded as \"ghoulish\", inspires a lot of apprehension in their crewmates. \
+ It might be the whole \"flammable purple skeleton\" thing.",
+
+ "The colonids that make up plasmamen require the plasma-rich atmosphere they evolved in. \
+ Their psuedo-nervous system runs with externalized electrical impulses that immediately ignite their plasma-based bodies when oxygen is present.",
+ )
+
+/datum/species/plasmaman/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "user-shield",
+ SPECIES_PERK_NAME = "Protected",
+ SPECIES_PERK_DESC = "Plasmamen are immune to radiation, poisons, and most diseases.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bone",
+ SPECIES_PERK_NAME = "Wound Resistance",
+ SPECIES_PERK_DESC = "Plasmamen have higher tolerance for damage that would wound others.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "wind",
+ SPECIES_PERK_NAME = "Plasma Healing",
+ SPECIES_PERK_DESC = "Plasmamen can heal wounds by consuming plasma.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "hard-hat",
+ SPECIES_PERK_NAME = "Protective Helmet",
+ SPECIES_PERK_DESC = "Plasmamen's helmets provide them shielding from the flashes of welding, as well as an inbuilt flashlight.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fire",
+ SPECIES_PERK_NAME = "Living Torch",
+ SPECIES_PERK_DESC = "Plasmamen instantly ignite when their body makes contact with oxygen.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "wind",
+ SPECIES_PERK_NAME = "Plasma Breathing",
+ SPECIES_PERK_DESC = "Plasmamen must breathe plasma to survive. You receive a tank when you arrive.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "briefcase-medical",
+ SPECIES_PERK_NAME = "Complex Biology",
+ SPECIES_PERK_DESC = "Plasmamen take specialized medical knowledge to be \
+ treated. Do not expect speedy revival, if you are lucky enough to get \
+ one at all.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/podpeople.dm b/code/modules/mob/living/carbon/human/species_types/podpeople.dm
index 8dd82d3d2d8e..4b24714e4d42 100644
--- a/code/modules/mob/living/carbon/human/species_types/podpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/podpeople.dm
@@ -7,6 +7,7 @@ DISREGUARD THIS FILE IF YOU'RE INTENDING TO CHANGE ASPECTS OF PLAYER CONTROLLED
/datum/species/pod
// A mutation caused by a human being ressurected in a revival pod. These regain health in light, and begin to wither in darkness.
name = "Podperson"
+ plural_form = "Podpeople"
id = "pod"
default_color = "59CE00"
species_traits = list(MUTCOLORS,EYECOLOR)
@@ -86,3 +87,25 @@ DISREGUARD THIS FILE IF YOU'RE INTENDING TO CHANGE ASPECTS OF PLAYER CONTROLLED
H.show_message(span_userdanger("The radiation beam singes you!"))
if(/obj/item/projectile/energy/florayield)
H.set_nutrition(min(H.nutrition+30, NUTRITION_LEVEL_FULL))
+
+/datum/species/pod/get_species_description()
+ return "TODO: This is pod description"
+
+/datum/species/pod/get_species_lore()
+ return list(
+ "TODO: This is pod lore"
+ )
+
+/datum/species/pod/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
+/datum/species/pod/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm b/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm
index 2ea0e1fc6620..2650315fc10b 100644
--- a/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm
+++ b/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm
@@ -52,3 +52,25 @@
.=..()
if(C.physiology)
C.physiology.armor.wound -= 10
+
+/datum/species/polysmorph/get_species_description()
+ return "TODO: This is polysmorph description"
+
+/datum/species/polysmorph/get_species_lore()
+ return list(
+ "TODO: This is polysmorph lore"
+ )
+
+/datum/species/polysmorph/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
+/datum/species/polysmorph/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm b/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
index da8f8c3aac82..2e0a0df00944 100644
--- a/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
@@ -4,6 +4,7 @@
/datum/species/shadow
// Humans cursed to stay in the darkness, lest their life forces drain. They regain health in shadow and die in light.
name = "???"
+ plural_form = "???"
id = "shadow"
sexes = FALSE
ignored_by = list(/mob/living/simple_animal/hostile/faithless)
@@ -30,8 +31,58 @@
return TRUE
return ..()
+/datum/species/shadow/get_species_description()
+ return "Victims of a long extinct space alien. Their flesh is a sickly \
+ seethrough filament, their tangled insides in clear view. Their form \
+ is a mockery of life, leaving them mostly unable to work with others under \
+ normal circumstances."
+
+/datum/species/shadow/get_species_lore()
+ return list(
+ "Long ago, the Spinward Sector used to be inhabited by terrifying aliens aptly named \"Shadowlings\" \
+ after their control over darkness, and tendancy to kidnap victims into the dark maintenance shafts. \
+ Around 2558, the long campaign Nanotrasen waged against the space terrors ended with the full extinction of the Shadowlings.",
+
+ "Victims of their kidnappings would become brainless thralls, and via surgery they could be freed from the Shadowling's control. \
+ Those more unlucky would have their entire body transformed by the Shadowlings to better serve in kidnappings. \
+ Unlike the brain tumors of lesser control, these greater thralls could not be reverted.",
+
+ "With Shadowlings long gone, their will is their own again. But their bodies have not reverted, burning in exposure to light. \
+ Nanotrasen has assured the victims that they are searching for a cure. No further information has been given, even years later. \
+ Most shadowpeople now assume Nanotrasen has long since shelfed the project.",
+ )
+
+/datum/species/shadow/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "moon",
+ SPECIES_PERK_NAME = "Shadowborn",
+ SPECIES_PERK_DESC = "Their skin blooms in the darkness. All kinds of damage, \
+ no matter how extreme, will heal over time as long as there is no light.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "eye",
+ SPECIES_PERK_NAME = "Nightvision",
+ SPECIES_PERK_DESC = "Their eyes are adapted to the night, and can see in the dark with no problems.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "sun",
+ SPECIES_PERK_NAME = "Lightburn",
+ SPECIES_PERK_DESC = "Their flesh withers in the light. Any exposure to light is \
+ incredibly painful for the shadowperson, charring their skin.",
+ ),
+ )
+
+ return to_add
+
/datum/species/shadow/nightmare
name = "Nightmare"
+ plural_form = null
id = "nightmare"
limbs_id = "shadow"
burnmod = 1.5
diff --git a/code/modules/mob/living/carbon/human/species_types/skeletons.dm b/code/modules/mob/living/carbon/human/species_types/skeletons.dm
index 85cd0cce1b33..6bf2eb266c27 100644
--- a/code/modules/mob/living/carbon/human/species_types/skeletons.dm
+++ b/code/modules/mob/living/carbon/human/species_types/skeletons.dm
@@ -28,3 +28,15 @@
if(SSevents.holidays && SSevents.holidays[HALLOWEEN])
return TRUE
return ..()
+
+/datum/species/skeleton/get_species_description()
+ return "A rattling skeleton! They descend upon Space Station 13 \
+ Every year to spook the crew! \"I've got a BONE to pick with you!\""
+
+/datum/species/skeleton/get_species_lore()
+ return list(
+ "Skeletons want to be feared again! Their presence in media has been destroyed, \
+ or at least that's what they firmly believe. They're always the first thing fought in an RPG, \
+ they're Flanderized into pun rolling JOKES, and it's really starting to get to them. \
+ You could say they're deeply RATTLED. Hah."
+ )
diff --git a/code/modules/mob/living/carbon/human/species_types/snail.dm b/code/modules/mob/living/carbon/human/species_types/snail.dm
index c53f045b9e7c..af55a09bcdb0 100644
--- a/code/modules/mob/living/carbon/human/species_types/snail.dm
+++ b/code/modules/mob/living/carbon/human/species_types/snail.dm
@@ -1,5 +1,6 @@
/datum/species/snail
name = "Snailperson"
+ plural_form = "Snailpeople"
id = "snail"
offset_features = list(OFFSET_UNIFORM = list(0,0), OFFSET_ID = list(0,0), OFFSET_GLOVES = list(0,0), OFFSET_GLASSES = list(0,4), OFFSET_EARS = list(0,0), OFFSET_SHOES = list(0,0), OFFSET_S_STORE = list(0,0), OFFSET_FACEMASK = list(0,0), OFFSET_HEAD = list(0,0), OFFSET_FACE = list(0,0), OFFSET_BELT = list(0,0), OFFSET_BACK = list(0,0), OFFSET_SUIT = list(0,0), OFFSET_NECK = list(0,0))
default_color = "336600" //vomit green
diff --git a/code/modules/mob/living/carbon/human/species_types/vampire.dm b/code/modules/mob/living/carbon/human/species_types/vampire.dm
index a16bad18939e..5a5f6c407ba5 100644
--- a/code/modules/mob/living/carbon/human/species_types/vampire.dm
+++ b/code/modules/mob/living/carbon/human/species_types/vampire.dm
@@ -60,8 +60,80 @@
/datum/species/vampire/check_species_weakness(obj/item/weapon, mob/living/attacker)
if(istype(weapon, /obj/item/nullrod/whip))
- return 1 //Whips deal 2x damage to vampires. Vampire killer.
- return 0
+ return TRUE //Whips deal 2x damage to vampires. Vampire killer.
+
+ return FALSE
+
+/datum/species/vampire/get_species_description()
+ return "A classy Vampire! They descend upon Space Station Thirteen Every year to spook the crew! \"Bleeg!!\""
+
+/datum/species/vampire/get_species_lore()
+ return list(
+ "Vampires are unholy beings blessed and cursed with The Thirst. \
+ The Thirst requires them to feast on blood to stay alive, and in return it gives them many bonuses. \
+ Because of this, Vampires have split into two clans, one that embraces their powers as a blessing and one that rejects it.",
+ )
+
+/datum/species/vampire/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bed",
+ SPECIES_PERK_NAME = "Coffin Brooding",
+ SPECIES_PERK_DESC = "Vampires can delay The Thirst and heal by resting in a coffin. So THAT'S why they do that!",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "book-dead",
+ SPECIES_PERK_NAME = "Vampire Clans",
+ SPECIES_PERK_DESC = "Vampires belong to one of two clans - the Inoculated, and the Outcast. The Outcast \
+ don't follow many vampiric traditions, while the Inoculated are given unique names and flavor.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "cross",
+ SPECIES_PERK_NAME = "Against God and Nature",
+ SPECIES_PERK_DESC = "Almost all higher powers are disgusted by the existence of \
+ Vampires, and entering the Chapel is essentially suicide. Do not do it!",
+ ),
+ )
+
+ return to_add
+
+// Vampire blood is special, so it needs to be handled with its own entry.
+/datum/species/vampire/create_pref_blood_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = "The Thirst",
+ SPECIES_PERK_DESC = "In place of eating, Vampires suffer from The Thirst. \
+ Thirst of what? Blood! Their tongue allows them to grab people and drink \
+ their blood, and they will die if they run out. As a note, it doesn't \
+ matter whose blood you drink, it will all be converted into your blood \
+ type when consumed.",
+ ))
+
+ return to_add
+
+// There isn't a "Minor Undead" biotype, so we have to explain it in an override (see: dullahans)
+/datum/species/vampire/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "skull",
+ SPECIES_PERK_NAME = "Minor Undead",
+ SPECIES_PERK_DESC = "[name] are minor undead. \
+ Minor undead enjoy some of the perks of being dead, like \
+ not needing to breathe or eat, but do not get many of the \
+ environmental immunities involved with being fully undead.",
+ ))
+
+ return to_add
/obj/item/organ/tongue/vampire
name = "vampire tongue"
diff --git a/code/modules/mob/living/carbon/human/species_types/zombies.dm b/code/modules/mob/living/carbon/human/species_types/zombies.dm
index ced9d2156f04..66adfbe2f8f0 100644
--- a/code/modules/mob/living/carbon/human/species_types/zombies.dm
+++ b/code/modules/mob/living/carbon/human/species_types/zombies.dm
@@ -21,6 +21,28 @@
return TRUE
return ..()
+/datum/species/zombie/get_species_description()
+ return "A rotting zombie! They descend upon Space Station Thirteen Every year to spook the crew! \"Sincerely, the Zombies!\""
+
+/datum/species/zombie/get_species_lore()
+ return list("Zombies have long lasting beef with Botanists. Their last incident involving a lawn with defensive plants has left them very unhinged.")
+
+// Override for the default temperature perks, so we can establish that they don't care about temperature very much
+/datum/species/zombie/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "thermometer-half",
+ SPECIES_PERK_NAME = "No Body Temperature",
+ SPECIES_PERK_DESC = "Having long since departed, Zombies do not have anything \
+ regulating their body temperature anymore. This means that \
+ the environment decides their body temperature - which they don't mind at \
+ all, until it gets a bit too hot.",
+ ))
+
+ return to_add
+
/datum/species/zombie/infectious
name = "Infectious Zombie"
id = "memezombies"
diff --git a/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm b/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm
index 9700d463b499..a38a47131fb6 100644
--- a/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm
+++ b/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm
@@ -34,7 +34,7 @@
/datum/portrait_picker/ui_data(mob/user)
var/list/data = list()
- data["public_paintings"] = SSpersistence.paintings["public"] ? SSpersistence.paintings["public"] : 0
+ data["public_paintings"] = SSpersistent_paintings.paintings["public"] ? SSpersistent_paintings.paintings["public"] : 0
return data
/datum/portrait_picker/ui_act(action, params)
@@ -45,7 +45,7 @@
if("select")
var/list/tab2key = list(TAB_PUBLIC = "public")
var/folder = tab2key[params["tab"]]
- var/list/current_list = SSpersistence.paintings[folder]
+ var/list/current_list = SSpersistent_paintings.paintings[folder]
var/list/chosen_portrait = current_list[params["selected"]]
var/png = "data/paintings/[folder]/[chosen_portrait["md5"]].png"
var/icon/portrait_icon = new(png)
diff --git a/code/modules/mob/living/silicon/silicon.dm b/code/modules/mob/living/silicon/silicon.dm
index 145259aa7c52..d6931bfc28a0 100644
--- a/code/modules/mob/living/silicon/silicon.dm
+++ b/code/modules/mob/living/silicon/silicon.dm
@@ -471,7 +471,7 @@
var/datum/mind/mega = usr.mind
if(!istype(mega))
return
- var/aksent = input(usr, "Choose your accent:","Available Accents") as null|anything in (assoc_list_strip_value(strings("accents.json", "accent_file_names", directory = "strings/accents")) + "None")
+ var/aksent = input(usr, "Choose your accent:","Available Accents") as null|anything in (assoc_to_keys(strings("accents.json", "accent_file_names", directory = "strings/accents")) + "None")
if(aksent) // Accents were an accidents why the fuck do I have to do mind.RegisterSignal(mob, COMSIG_MOB_SAY)
if(aksent == "None")
mega.accent_name = null
diff --git a/code/modules/modular_computers/file_system/programs/portrait_printer.dm b/code/modules/modular_computers/file_system/programs/portrait_printer.dm
index e898fffa7acc..912bf4030dcd 100644
--- a/code/modules/modular_computers/file_system/programs/portrait_printer.dm
+++ b/code/modules/modular_computers/file_system/programs/portrait_printer.dm
@@ -22,7 +22,7 @@
/datum/computer_file/program/portrait_printer/ui_data(mob/user)
var/list/data = list()
- data["public_paintings"] = SSpersistence.paintings["public"] ? SSpersistence.paintings["public"] : 0
+ data["public_paintings"] = SSpersistent_paintings.paintings["public"] ? SSpersistent_paintings.paintings["public"] : 0
return data
/datum/computer_file/program/portrait_printer/ui_assets(mob/user)
@@ -50,7 +50,7 @@
//canvas printing!
var/list/tab2key = list(TAB_PUBLIC = "public")
var/folder = tab2key[params["tab"]]
- var/list/current_list = SSpersistence.paintings[folder]
+ var/list/current_list = SSpersistent_paintings.paintings[folder]
var/list/chosen_portrait = current_list[params["selected"]]
var/author = chosen_portrait["author"]
var/title = chosen_portrait["title"]
diff --git a/code/modules/reagents/chemistry/machinery/chem_master.dm b/code/modules/reagents/chemistry/machinery/chem_master.dm
index 117b4ae50b2b..abf36adeb536 100644
--- a/code/modules/reagents/chemistry/machinery/chem_master.dm
+++ b/code/modules/reagents/chemistry/machinery/chem_master.dm
@@ -25,15 +25,6 @@
/obj/machinery/chem_master/Initialize()
create_reagents(100)
- //Calculate the span tags and ids fo all the available pill icons
- var/datum/asset/spritesheet/simple/assets = get_asset_datum(/datum/asset/spritesheet/simple/pills)
- pillStyles = list()
- for (var/x in 1 to PILL_STYLE_COUNT)
- var/list/SL = list()
- SL["id"] = x
- SL["className"] = assets.icon_class_name("pill[x]")
- pillStyles += list(SL)
-
. = ..()
/obj/machinery/chem_master/Destroy()
@@ -151,6 +142,16 @@
bottle = null
return ..()
+/obj/machinery/chem_master/proc/load_styles()
+ //Calculate the span tags and ids fo all the available pill icons
+ var/datum/asset/spritesheet/simple/assets = get_asset_datum(/datum/asset/spritesheet/simple/pills)
+ pillStyles = list()
+ for (var/x in 1 to PILL_STYLE_COUNT)
+ var/list/SL = list()
+ SL["id"] = x
+ SL["className"] = assets.icon_class_name("pill[x]")
+ pillStyles += list(SL)
+
/obj/machinery/chem_master/ui_assets(mob/user)
return list(
get_asset_datum(/datum/asset/spritesheet/simple/pills),
@@ -190,7 +191,9 @@
bufferContents.Add(list(list("name" = N.name, "id" = ckey(N.name), "volume" = N.volume))) // ^
data["bufferContents"] = bufferContents
- //Calculated at init time as it never changes
+ //Calculated once since it'll never change
+ if(!pillStyles)
+ load_styles()
data["pillStyles"] = pillStyles
return data
diff --git a/code/modules/research/techweb/_techweb_node.dm b/code/modules/research/techweb/_techweb_node.dm
index 7945eacf8843..5bf1cff4a57c 100644
--- a/code/modules/research/techweb/_techweb_node.dm
+++ b/code/modules/research/techweb/_techweb_node.dm
@@ -42,9 +42,9 @@
VARSET_TO_LIST(., display_name)
VARSET_TO_LIST(., hidden)
VARSET_TO_LIST(., starting_node)
- VARSET_TO_LIST(., assoc_list_strip_value(prereq_ids))
- VARSET_TO_LIST(., assoc_list_strip_value(design_ids))
- VARSET_TO_LIST(., assoc_list_strip_value(unlock_ids))
+ VARSET_TO_LIST(., assoc_to_keys(prereq_ids))
+ VARSET_TO_LIST(., assoc_to_keys(design_ids))
+ VARSET_TO_LIST(., assoc_to_keys(unlock_ids))
VARSET_TO_LIST(., boost_item_paths)
VARSET_TO_LIST(., autounlock_by_boost)
VARSET_TO_LIST(., research_costs)
diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm
index cfc788768c97..ee0300b87ee3 100644
--- a/code/modules/tgui/tgui_window.dm
+++ b/code/modules/tgui/tgui_window.dm
@@ -258,7 +258,7 @@
if(istype(asset, /datum/asset/spritesheet))
var/datum/asset/spritesheet/spritesheet = asset
send_message("asset/stylesheet", spritesheet.css_filename())
- send_message("asset/mappings", asset.get_url_mappings())
+ send_raw_message(asset.get_serialized_url_mappings())
/**
* private
diff --git a/config/config.txt b/config/config.txt
index f5da2d48e75f..1cf215bc47e5 100644
--- a/config/config.txt
+++ b/config/config.txt
@@ -451,3 +451,10 @@ MAX_SHUTTLE_SIZE 300
## Enable/disable roundstart station traits
#STATION_TRAITS
+
+## Assets can opt-in to caching their results into `tmp`.
+## This is important, as preferences assets take upwards of 30 seconds (without sleeps) to collect.
+## The cache is assumed to be cleared by TGS recompiling, which deletes `tmp`.
+## This should be disabled (through `CACHE_ASSETS 0`) on development,
+## but enabled on production (the default).
+CACHE_ASSETS 0
diff --git a/icons/blanks/32x32.dmi b/icons/blanks/32x32.dmi
new file mode 100644
index 000000000000..6c4f2b33e0fe
Binary files /dev/null and b/icons/blanks/32x32.dmi differ
diff --git a/icons/blanks/96x96.dmi b/icons/blanks/96x96.dmi
new file mode 100644
index 000000000000..d79e60c111ab
Binary files /dev/null and b/icons/blanks/96x96.dmi differ
diff --git a/icons/blanks/blank_title.png b/icons/blanks/blank_title.png
new file mode 100644
index 000000000000..387a21f03a01
Binary files /dev/null and b/icons/blanks/blank_title.png differ
diff --git a/tgui/package.json b/tgui/package.json
index 0f65150c4381..a203dc4a3a25 100644
--- a/tgui/package.json
+++ b/tgui/package.json
@@ -6,17 +6,16 @@
"packages/*"
],
"scripts": {
+ "tgui:build": "webpack",
"tgui:analyze": "webpack --analyze",
- "tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js",
- "tgui:build": "BROWSERSLIST_IGNORE_OLD_DATA=true webpack",
"tgui:dev": "node --experimental-modules packages/tgui-dev-server/index.js",
"tgui:lint": "eslint packages --ext .js,.cjs,.ts,.tsx",
- "tgui:prettier": "",
"tgui:sonar": "eslint packages --ext .js,.cjs,.ts,.tsx -c .eslintrc-sonar.yml",
+ "tgui:tsc": "tsc",
"tgui:test": "jest --watch",
"tgui:test-simple": "CI=true jest --color",
"tgui:test-ci": "CI=true jest --color --collect-coverage",
- "tgui:tsc": "tsc"
+ "tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js"
},
"dependencies": {
"@babel/core": "^7.15.0",
@@ -28,6 +27,8 @@
"@types/jest": "^27.0.1",
"@types/jsdom": "^16.2.13",
"@types/node": "^14.17.9",
+ "@types/webpack": "^5.28.0",
+ "@types/webpack-env": "^1.16.2",
"@typescript-eslint/parser": "^4.29.1",
"babel-jest": "^27.0.6",
"babel-loader": "^8.2.2",
@@ -38,6 +39,7 @@
"eslint": "^7.32.0",
"eslint-plugin-radar": "^0.2.1",
"eslint-plugin-react": "^7.24.0",
+ "eslint-plugin-unused-imports": "^1.1.4",
"file-loader": "^6.2.0",
"inferno": "^7.4.8",
"jest": "^27.0.6",
diff --git a/tgui/packages/common/collections.js b/tgui/packages/common/collections.js
deleted file mode 100644
index 7b4540e730ab..000000000000
--- a/tgui/packages/common/collections.js
+++ /dev/null
@@ -1,271 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-/**
- * Converts a given collection to an array.
- *
- * - Arrays are returned unmodified;
- * - If object was provided, keys will be discarded;
- * - Everything else will result in an empty array.
- *
- * @returns {any[]}
- */
-export const toArray = collection => {
- if (Array.isArray(collection)) {
- return collection;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(collection[i]);
- }
- }
- return result;
- }
- return [];
-};
-
-/**
- * Converts a given object to an array, and appends a key to every
- * object inside of that array.
- *
- * Example input (object):
- * ```
- * {
- * 'Foo': { info: 'Hello world!' },
- * 'Bar': { info: 'Hello world!' },
- * }
- * ```
- *
- * Example output (array):
- * ```
- * [
- * { key: 'Foo', info: 'Hello world!' },
- * { key: 'Bar', info: 'Hello world!' },
- * ]
- * ```
- *
- * @template T
- * @param {{ [key: string]: T }} obj Object, or in DM terms, an assoc array
- * @param {string} keyProp Property, to which key will be assigned
- * @returns {T[]} Array of keyed objects
- */
-export const toKeyedArray = (obj, keyProp = 'key') => {
- return map((item, key) => ({
- [keyProp]: key,
- ...item,
- }))(obj);
-};
-
-/**
- * Iterates over elements of collection, returning an array of all elements
- * iteratee returns truthy for. The predicate is invoked with three
- * arguments: (value, index|key, collection).
- *
- * If collection is 'null' or 'undefined', it will be returned "as is"
- * without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
- */
-export const filter = iterateeFn => collection => {
- if (collection === null || collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- const item = collection[i];
- if (iterateeFn(item, i, collection)) {
- result.push(item);
- }
- }
- return result;
- }
- throw new Error(`filter() can't iterate on type ${typeof collection}`);
-};
-
-/**
- * Creates an array of values by running each element in collection
- * thru an iteratee function. The iteratee is invoked with three
- * arguments: (value, index|key, collection).
- *
- * If collection is 'null' or 'undefined', it will be returned "as is"
- * without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
- */
-export const map = iterateeFn => collection => {
- if (collection === null || collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- result.push(iterateeFn(collection[i], i, collection));
- }
- return result;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(iterateeFn(collection[i], i, collection));
- }
- }
- return result;
- }
- throw new Error(`map() can't iterate on type ${typeof collection}`);
-};
-
-const COMPARATOR = (objA, objB) => {
- const criteriaA = objA.criteria;
- const criteriaB = objB.criteria;
- const length = criteriaA.length;
- for (let i = 0; i < length; i++) {
- const a = criteriaA[i];
- const b = criteriaB[i];
- if (a < b) {
- return -1;
- }
- if (a > b) {
- return 1;
- }
- }
- return 0;
-};
-
-/**
- * Creates an array of elements, sorted in ascending order by the results
- * of running each element in a collection thru each iteratee.
- *
- * Iteratees are called with one argument (value).
- *
- * @returns {any[]}
- */
-export const sortBy = (...iterateeFns) => array => {
- if (!Array.isArray(array)) {
- return array;
- }
- let length = array.length;
- // Iterate over the array to collect criteria to sort it by
- let mappedArray = [];
- for (let i = 0; i < length; i++) {
- const value = array[i];
- mappedArray.push({
- criteria: iterateeFns.map(fn => fn(value)),
- value,
- });
- }
- // Sort criteria using the base comparator
- mappedArray.sort(COMPARATOR);
- // Unwrap values
- while (length--) {
- mappedArray[length] = mappedArray[length].value;
- }
- return mappedArray;
-};
-
-/**
- * A fast implementation of reduce.
- */
-export const reduce = (reducerFn, initialValue) => array => {
- const length = array.length;
- let i;
- let result;
- if (initialValue === undefined) {
- i = 1;
- result = array[0];
- }
- else {
- i = 0;
- result = initialValue;
- }
- for (; i < length; i++) {
- result = reducerFn(result, array[i], i, array);
- }
- return result;
-};
-
-/**
- * Creates a duplicate-free version of an array, using SameValueZero for
- * equality comparisons, in which only the first occurrence of each element
- * is kept. The order of result values is determined by the order they occur
- * in the array.
- *
- * It accepts iteratee which is invoked for each element in array to generate
- * the criterion by which uniqueness is computed. The order of result values
- * is determined by the order they occur in the array. The iteratee is
- * invoked with one argument: value.
- */
-export const uniqBy = iterateeFn => array => {
- const { length } = array;
- const result = [];
- const seen = iterateeFn ? [] : result;
- let index = -1;
- outer:
- while (++index < length) {
- let value = array[index];
- const computed = iterateeFn ? iterateeFn(value) : value;
- value = value !== 0 ? value : 0;
- if (computed === computed) {
- let seenIndex = seen.length;
- while (seenIndex--) {
- if (seen[seenIndex] === computed) {
- continue outer;
- }
- }
- if (iterateeFn) {
- seen.push(computed);
- }
- result.push(value);
- }
- else if (!seen.includes(computed)) {
- if (seen !== result) {
- seen.push(computed);
- }
- result.push(value);
- }
- }
- return result;
-};
-
-/**
- * Creates an array of grouped elements, the first of which contains
- * the first elements of the given arrays, the second of which contains
- * the second elements of the given arrays, and so on.
- *
- * @returns {any[]}
- */
-export const zip = (...arrays) => {
- if (arrays.length === 0) {
- return;
- }
- const numArrays = arrays.length;
- const numValues = arrays[0].length;
- const result = [];
- for (let valueIndex = 0; valueIndex < numValues; valueIndex++) {
- const entry = [];
- for (let arrayIndex = 0; arrayIndex < numArrays; arrayIndex++) {
- entry.push(arrays[arrayIndex][valueIndex]);
- }
- result.push(entry);
- }
- return result;
-};
-
-/**
- * This method is like "zip" except that it accepts iteratee to
- * specify how grouped values should be combined. The iteratee is
- * invoked with the elements of each group.
- *
- * @returns {any[]}
- */
-export const zipWith = iterateeFn => (...arrays) => {
- return map(values => iterateeFn(...values))(zip(...arrays));
-};
diff --git a/tgui/packages/common/collections.ts b/tgui/packages/common/collections.ts
index 40f610b36d6f..baf02242372e 100644
--- a/tgui/packages/common/collections.ts
+++ b/tgui/packages/common/collections.ts
@@ -4,64 +4,6 @@
* @license MIT
*/
-/**
- * Converts a given collection to an array.
- *
- * - Arrays are returned unmodified;
- * - If object was provided, keys will be discarded;
- * - Everything else will result in an empty array.
- *
- * @returns {any[]}
- */
-export const toArray = collection => {
- if (Array.isArray(collection)) {
- return collection;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(collection[i]);
- }
- }
- return result;
- }
- return [];
-};
-
-/**
- * Converts a given object to an array, and appends a key to every
- * object inside of that array.
- *
- * Example input (object):
- * ```
- * {
- * 'Foo': { info: 'Hello world!' },
- * 'Bar': { info: 'Hello world!' },
- * }
- * ```
- *
- * Example output (array):
- * ```
- * [
- * { key: 'Foo', info: 'Hello world!' },
- * { key: 'Bar', info: 'Hello world!' },
- * ]
- * ```
- *
- * @template T
- * @param {{ [key: string]: T }} obj Object, or in DM terms, an assoc array
- * @param {string} keyProp Property, to which key will be assigned
- * @returns {T[]} Array of keyed objects
- */
-export const toKeyedArray = (obj, keyProp = 'key') => {
- return map((item, key) => ({
- [keyProp]: key,
- ...item,
- }))(obj);
-};
-
/**
* Iterates over elements of collection, returning an array of all elements
* iteratee returns truthy for. The predicate is invoked with three
@@ -69,24 +11,34 @@ export const toKeyedArray = (obj, keyProp = 'key') => {
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
*/
-export const filter = iterateeFn => collection => {
- if (collection === null && collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- const item = collection[i];
- if (iterateeFn(item, i, collection)) {
- result.push(item);
+export const filter =
+ (iterateeFn: (input: T, index: number, collection: T[]) => boolean) =>
+ (collection: T[]): T[] => {
+ if (collection === null || collection === undefined) {
+ return collection;
+ }
+ if (Array.isArray(collection)) {
+ const result: T[] = [];
+ for (let i = 0; i < collection.length; i++) {
+ const item = collection[i];
+ if (iterateeFn(item, i, collection)) {
+ result.push(item);
+ }
}
+ return result;
}
- return result;
- }
- throw new Error(`filter() can't iterate on type ${typeof collection}`);
+ throw new Error(`filter() can't iterate on type ${typeof collection}`);
+ };
+
+type MapFunction = {
+ (iterateeFn: (value: T, index: number, collection: T[]) => U): (
+ collection: T[]
+ ) => U[];
+
+ (
+ iterateeFn: (value: T, index: K, collection: Record) => U
+ ): (collection: Record) => U[];
};
/**
@@ -96,31 +48,45 @@ export const filter = iterateeFn => collection => {
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
*/
-export const map = iterateeFn => collection => {
- if (collection === null && collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- result.push(iterateeFn(collection[i], i, collection));
+export const map: MapFunction =
+ (iterateeFn) =>
+ (collection: T[]): U[] => {
+ if (collection === null || collection === undefined) {
+ return collection;
}
- return result;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(iterateeFn(collection[i], i, collection));
- }
+
+ if (Array.isArray(collection)) {
+ return collection.map(iterateeFn);
+ }
+
+ if (typeof collection === 'object') {
+ return Object.entries(collection).map(([key, value]) => {
+ return iterateeFn(value, key, collection);
+ });
+ }
+
+ throw new Error(`map() can't iterate on type ${typeof collection}`);
+ };
+
+/**
+ * Given a collection, will run each element through an iteratee function.
+ * Will then filter out undefined values.
+ */
+export const filterMap = (
+ collection: T[],
+ iterateeFn: (value: T) => U | undefined
+): U[] => {
+ const finalCollection: U[] = [];
+
+ for (const value of collection) {
+ const output = iterateeFn(value);
+ if (output !== undefined) {
+ finalCollection.push(output);
}
- return result;
}
- throw new Error(`map() can't iterate on type ${typeof collection}`);
+
+ return finalCollection;
};
const COMPARATOR = (objA, objB) => {
@@ -145,44 +111,59 @@ const COMPARATOR = (objA, objB) => {
* of running each element in a collection thru each iteratee.
*
* Iteratees are called with one argument (value).
- *
- * @returns {any[]}
*/
-export const sortBy = (...iterateeFns) => array => {
- if (!Array.isArray(array)) {
- return array;
- }
- let length = array.length;
- // Iterate over the array to collect criteria to sort it by
- let mappedArray = [];
- for (let i = 0; i < length; i++) {
- const value = array[i];
- mappedArray.push({
- criteria: iterateeFns.map(fn => fn(value)),
- value,
- });
- }
- // Sort criteria using the base comparator
- mappedArray.sort(COMPARATOR);
- // Unwrap values
- while (length--) {
- mappedArray[length] = mappedArray[length].value;
- }
- return mappedArray;
-};
+export const sortBy =
+ (...iterateeFns: ((input: T) => unknown)[]) =>
+ (array: T[]): T[] => {
+ if (!Array.isArray(array)) {
+ return array;
+ }
+ let length = array.length;
+ // Iterate over the array to collect criteria to sort it by
+ let mappedArray: {
+ criteria: unknown[];
+ value: T;
+ }[] = [];
+ for (let i = 0; i < length; i++) {
+ const value = array[i];
+ mappedArray.push({
+ criteria: iterateeFns.map((fn) => fn(value)),
+ value,
+ });
+ }
+ // Sort criteria using the base comparator
+ mappedArray.sort(COMPARATOR);
+
+ // Unwrap values
+ const values: T[] = [];
+ while (length--) {
+ values[length] = mappedArray[length].value;
+ }
+ return values;
+ };
+
+export const sort = sortBy();
+
+export const sortStrings = sortBy();
+
+/**
+ * Returns a range of numbers from start to end, exclusively.
+ * For example, range(0, 5) will return [0, 1, 2, 3, 4].
+ */
+export const range = (start: number, end: number): number[] =>
+ new Array(end - start).fill(null).map((_, index) => index + start);
/**
* A fast implementation of reduce.
*/
-export const reduce = (reducerFn, initialValue) => array => {
+export const reduce = (reducerFn, initialValue) => (array) => {
const length = array.length;
let i;
let result;
if (initialValue === undefined) {
i = 1;
result = array[0];
- }
- else {
+ } else {
i = 0;
result = initialValue;
}
@@ -203,58 +184,65 @@ export const reduce = (reducerFn, initialValue) => array => {
* is determined by the order they occur in the array. The iteratee is
* invoked with one argument: value.
*/
-export const uniqBy = iterateeFn => array => {
- const { length } = array;
- const result = [];
- const seen = iterateeFn ? [] : result;
- let index = -1;
- outer:
- while (++index < length) {
- let value = array[index];
- const computed = iterateeFn ? iterateeFn(value) : value;
- value = value !== 0 ? value : 0;
- if (computed === computed) {
- let seenIndex = seen.length;
- while (seenIndex--) {
- if (seen[seenIndex] === computed) {
- continue outer;
+export const uniqBy =
+ (iterateeFn?: (value: T) => unknown) =>
+ (array: T[]): T[] => {
+ const { length } = array;
+ const result: T[] = [];
+ const seen: unknown[] = iterateeFn ? [] : result;
+ let index = -1;
+ // prettier-ignore
+ outer:
+ while (++index < length) {
+ let value: T | 0 = array[index];
+ const computed = iterateeFn ? iterateeFn(value) : value;
+ if (computed === computed) {
+ let seenIndex = seen.length;
+ while (seenIndex--) {
+ if (seen[seenIndex] === computed) {
+ continue outer;
+ }
}
+ if (iterateeFn) {
+ seen.push(computed);
+ }
+ result.push(value);
+ } else if (!seen.includes(computed)) {
+ if (seen !== result) {
+ seen.push(computed);
+ }
+ result.push(value);
}
- if (iterateeFn) {
- seen.push(computed);
- }
- result.push(value);
- }
- else if (!seen.includes(computed)) {
- if (seen !== result) {
- seen.push(computed);
- }
- result.push(value);
}
- }
- return result;
-};
+ return result;
+ };
+
+export const uniq = uniqBy();
+
+type Zip = {
+ [I in keyof T]: T[I] extends (infer U)[] ? U : never;
+}[];
/**
* Creates an array of grouped elements, the first of which contains
* the first elements of the given arrays, the second of which contains
* the second elements of the given arrays, and so on.
- *
- * @returns {any[]}
*/
-export const zip = (...arrays) => {
+export const zip = (...arrays: T): Zip => {
if (arrays.length === 0) {
- return;
+ return [];
}
const numArrays = arrays.length;
const numValues = arrays[0].length;
- const result = [];
+ const result: Zip = [];
for (let valueIndex = 0; valueIndex < numValues; valueIndex++) {
- const entry = [];
+ const entry: unknown[] = [];
for (let arrayIndex = 0; arrayIndex < numArrays; arrayIndex++) {
entry.push(arrays[arrayIndex][valueIndex]);
}
- result.push(entry);
+
+ // I tried everything to remove this any, and have no idea how to do it.
+ result.push(entry as any);
}
return result;
};
@@ -263,13 +251,76 @@ export const zip = (...arrays) => {
* This method is like "zip" except that it accepts iteratee to
* specify how grouped values should be combined. The iteratee is
* invoked with the elements of each group.
- *
- * @returns {any[]}
*/
-export const zipWith = iterateeFn => (...arrays) => {
- return map(values => iterateeFn(...values))(zip(...arrays));
+export const zipWith =
+ (iterateeFn: (...values: T[]) => U) =>
+ (...arrays: T[][]): U[] => {
+ return map((values: T[]) => iterateeFn(...values))(zip(...arrays));
+ };
+
+const binarySearch = (
+ getKey: (value: T) => U,
+ collection: readonly T[],
+ inserting: T
+): number => {
+ if (collection.length === 0) {
+ return 0;
+ }
+
+ const insertingKey = getKey(inserting);
+
+ let [low, high] = [0, collection.length];
+
+ // Because we have checked if the collection is empty, it's impossible
+ // for this to be used before assignment.
+ let compare: U = undefined as unknown as U;
+ let middle = 0;
+
+ while (low < high) {
+ middle = (low + high) >> 1;
+
+ compare = getKey(collection[middle]);
+
+ if (compare < insertingKey) {
+ low = middle + 1;
+ } else if (compare === insertingKey) {
+ return middle;
+ } else {
+ high = middle;
+ }
+ }
+
+ return compare > insertingKey ? middle : middle + 1;
};
+export const binaryInsertWith =
+ (getKey: (value: T) => U) =>
+ (collection: readonly T[], value: T) => {
+ const copy = [...collection];
+ copy.splice(binarySearch(getKey, collection, value), 0, value);
+ return copy;
+ };
+
+/**
+ * This method takes a collection of items and a number, returning a collection
+ * of collections, where the maximum amount of items in each is that second arg
+ */
+export const paginate = (collection: T[], maxPerPage: number): T[][] => {
+ const pages: T[][] = [];
+ let page: T[] = [];
+ let itemsToAdd = maxPerPage;
+
+ for (const item of collection) {
+ page.push(item);
+ itemsToAdd--;
+ if (!itemsToAdd) {
+ itemsToAdd = maxPerPage;
+ pages.push(page);
+ page = [];
+ }
+ }
+ return pages;
+};
const isObject = (obj: unknown) => typeof obj === 'object' && obj !== null;
diff --git a/tgui/packages/common/exhaustive.ts b/tgui/packages/common/exhaustive.ts
new file mode 100644
index 000000000000..794e3866d248
--- /dev/null
+++ b/tgui/packages/common/exhaustive.ts
@@ -0,0 +1,19 @@
+/**
+ * Throws an error such that a non-exhaustive check will error at compile time
+ * when using TypeScript, rather than at runtime.
+ *
+ * For example:
+ * enum Color { Red, Green, Blue }
+ * switch (color) {
+ * case Color.Red:
+ * return "red";
+ * case Color.Green:
+ * return "green";
+ * default:
+ * // This will error at compile time that we forgot blue.
+ * exhaustiveCheck(color);
+ * }
+ */
+ export const exhaustiveCheck = (input: never) => {
+ throw new Error(`Unhandled case: ${input}`);
+};
diff --git a/tgui/packages/tgfont/icons/ATTRIBUTIONS.md b/tgui/packages/tgfont/icons/ATTRIBUTIONS.md
new file mode 100644
index 000000000000..2f218388d364
--- /dev/null
+++ b/tgui/packages/tgfont/icons/ATTRIBUTIONS.md
@@ -0,0 +1,6 @@
+bad-touch.svg contains:
+- hug by Phạm Thanh Lộc from the Noun Project
+- Fight by Rudez Studio from the Noun Project
+
+prosthetic-leg.svg contains:
+- prosthetic leg by Gan Khoon Lay from the Noun Project
diff --git a/tgui/packages/tgfont/icons/air-tank-slash.svg b/tgui/packages/tgfont/icons/air-tank-slash.svg
new file mode 100644
index 000000000000..37ffcb5b8809
--- /dev/null
+++ b/tgui/packages/tgfont/icons/air-tank-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/air-tank.svg b/tgui/packages/tgfont/icons/air-tank.svg
new file mode 100644
index 000000000000..7d1e07747197
--- /dev/null
+++ b/tgui/packages/tgfont/icons/air-tank.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/bad-touch.svg b/tgui/packages/tgfont/icons/bad-touch.svg
new file mode 100644
index 000000000000..795f4c2d840a
--- /dev/null
+++ b/tgui/packages/tgfont/icons/bad-touch.svg
@@ -0,0 +1,46 @@
+
+
diff --git a/tgui/packages/tgfont/icons/image-minus.svg b/tgui/packages/tgfont/icons/image-minus.svg
new file mode 100644
index 000000000000..8c3231917ff9
--- /dev/null
+++ b/tgui/packages/tgfont/icons/image-minus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/image-plus.svg b/tgui/packages/tgfont/icons/image-plus.svg
new file mode 100644
index 000000000000..1658509429e3
--- /dev/null
+++ b/tgui/packages/tgfont/icons/image-plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/nanotrasen-logo.svg b/tgui/packages/tgfont/icons/nanotrasen-logo.svg
new file mode 100644
index 000000000000..b74a415d4d2b
--- /dev/null
+++ b/tgui/packages/tgfont/icons/nanotrasen-logo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/tgui/packages/tgfont/icons/nanotrasen_logo.svg b/tgui/packages/tgfont/icons/nanotrasen_logo.svg
deleted file mode 100644
index d21b9f0a2a0f..000000000000
--- a/tgui/packages/tgfont/icons/nanotrasen_logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
diff --git a/tgui/packages/tgfont/icons/non-binary.svg b/tgui/packages/tgfont/icons/non-binary.svg
new file mode 100644
index 000000000000..c708c26d9855
--- /dev/null
+++ b/tgui/packages/tgfont/icons/non-binary.svg
@@ -0,0 +1,44 @@
+
+
diff --git a/tgui/packages/tgfont/icons/prosthetic-full.svg b/tgui/packages/tgfont/icons/prosthetic-full.svg
new file mode 100644
index 000000000000..7d221244edcc
--- /dev/null
+++ b/tgui/packages/tgfont/icons/prosthetic-full.svg
@@ -0,0 +1,27 @@
+
+
diff --git a/tgui/packages/tgfont/icons/prosthetic-leg.svg b/tgui/packages/tgfont/icons/prosthetic-leg.svg
new file mode 100644
index 000000000000..f4e16dccfb65
--- /dev/null
+++ b/tgui/packages/tgfont/icons/prosthetic-leg.svg
@@ -0,0 +1,35 @@
+
+
diff --git a/tgui/packages/tgfont/icons/sound-minus.svg b/tgui/packages/tgfont/icons/sound-minus.svg
new file mode 100644
index 000000000000..df51179d4b53
--- /dev/null
+++ b/tgui/packages/tgfont/icons/sound-minus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/sound-plus.svg b/tgui/packages/tgfont/icons/sound-plus.svg
new file mode 100644
index 000000000000..c5f40d53b560
--- /dev/null
+++ b/tgui/packages/tgfont/icons/sound-plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/syndicate-logo.svg b/tgui/packages/tgfont/icons/syndicate-logo.svg
new file mode 100644
index 000000000000..423dd7c62754
--- /dev/null
+++ b/tgui/packages/tgfont/icons/syndicate-logo.svg
@@ -0,0 +1,13 @@
+
+
diff --git a/tgui/packages/tgfont/icons/syndicate_logo.svg b/tgui/packages/tgfont/icons/syndicate_logo.svg
deleted file mode 100644
index c2863b790df3..000000000000
--- a/tgui/packages/tgfont/icons/syndicate_logo.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
diff --git a/tgui/packages/tgui-polyfill/index.js b/tgui/packages/tgui-polyfill/index.js
index c7a1e57ad4f9..ab6951036668 100644
--- a/tgui/packages/tgui-polyfill/index.js
+++ b/tgui/packages/tgui-polyfill/index.js
@@ -14,8 +14,4 @@ import './ie8';
import './dom4';
import './css-om';
import './inferno';
-
-// Fetch is required for Webpack HMR
-if (module.hot) {
- require('whatwg-fetch');
-}
+import 'unfetch/polyfill';
diff --git a/tgui/packages/tgui-polyfill/package.json b/tgui/packages/tgui-polyfill/package.json
index 9c351877b7d3..8a441f8321cd 100644
--- a/tgui/packages/tgui-polyfill/package.json
+++ b/tgui/packages/tgui-polyfill/package.json
@@ -2,9 +2,15 @@
"private": true,
"name": "tgui-polyfill",
"version": "4.3.0",
+ "scripts": {
+ "tgui-polyfill:build": "terser 00-html5shiv.js 01-ie8.js 02-dom4.js 03-css-om.js 10-misc.js --ie8 -f ascii_only,comments=false -o ../../public/tgui-polyfill.min.js"
+ },
"dependencies": {
"core-js": "^3.16.1",
"regenerator-runtime": "^0.13.9",
- "whatwg-fetch": "^3.6.2"
+ "unfetch": "^4.2.0"
+ },
+ "devDependencies": {
+ "terser": "^5.12.1"
}
}
diff --git a/tgui/packages/tgui/components/AnimatedNumber.js b/tgui/packages/tgui/components/AnimatedNumber.js
deleted file mode 100644
index 05b89c96d022..000000000000
--- a/tgui/packages/tgui/components/AnimatedNumber.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-import { clamp, toFixed } from 'common/math';
-import { Component } from 'inferno';
-
-const FPS = 20;
-const Q = 0.5;
-
-const isSafeNumber = value => {
- return typeof value === 'number'
- && Number.isFinite(value)
- && !Number.isNaN(value);
-};
-
-export class AnimatedNumber extends Component {
- constructor(props) {
- super(props);
- this.timer = null;
- this.state = {
- value: 0,
- };
- // Use provided initial state
- if (isSafeNumber(props.initial)) {
- this.state.value = props.initial;
- }
- // Set initial state with value provided in props
- else if (isSafeNumber(props.value)) {
- this.state.value = Number(props.value);
- }
- }
-
- tick() {
- const { props, state } = this;
- const currentValue = Number(state.value);
- const targetValue = Number(props.value);
- // Avoid poisoning our state with infinities and NaN
- if (!isSafeNumber(targetValue)) {
- return;
- }
- // Smooth the value using an exponential moving average
- const value = currentValue * Q + targetValue * (1 - Q);
- this.setState({ value });
- }
-
- componentDidMount() {
- this.timer = setInterval(() => this.tick(), 1000 / FPS);
- }
-
- componentWillUnmount() {
- clearTimeout(this.timer);
- }
-
- render() {
- const { props, state } = this;
- const { format, children } = props;
- const currentValue = state.value;
- const targetValue = props.value;
- // Directly display values which can't be animated
- if (!isSafeNumber(targetValue)) {
- return targetValue || null;
- }
- let formattedValue;
- // Use custom formatter
- if (format) {
- formattedValue = format(currentValue);
- }
- // Fix our animated precision at target value's precision.
- else {
- const fraction = String(targetValue).split('.')[1];
- const precision = fraction ? fraction.length : 0;
- formattedValue = toFixed(currentValue, clamp(precision, 0, 8));
- }
- // Use a custom render function
- if (typeof children === 'function') {
- return children(formattedValue, currentValue);
- }
- return formattedValue;
- }
-}
diff --git a/tgui/packages/tgui/components/ByondUi.js b/tgui/packages/tgui/components/ByondUi.js
index a1f7e9104093..4623fe577015 100644
--- a/tgui/packages/tgui/components/ByondUi.js
+++ b/tgui/packages/tgui/components/ByondUi.js
@@ -15,7 +15,7 @@ const logger = createLogger('ByondUi');
// Stack of currently allocated BYOND UI element ids.
const byondUiStack = [];
-const createByondUiElement = elementId => {
+const createByondUiElement = (elementId) => {
// Reserve an index in the stack
const index = byondUiStack.length;
byondUiStack.push(null);
@@ -24,7 +24,7 @@ const createByondUiElement = elementId => {
logger.log(`allocated '${id}'`);
// Return a control structure
return {
- render: params => {
+ render: (params) => {
logger.log(`rendering '${id}'`);
byondUiStack[index] = id;
Byond.winset(id, params);
@@ -54,18 +54,20 @@ window.addEventListener('beforeunload', () => {
});
/**
- * Get the bounding box of the DOM element.
+ * Get the bounding box of the DOM element in display-pixels.
*/
-const getBoundingBox = element => {
+const getBoundingBox = (element) => {
+ const pixelRatio = window.devicePixelRatio ?? 1;
const rect = element.getBoundingClientRect();
+ // prettier-ignore
return {
pos: [
- rect.left,
- rect.top,
+ rect.left * pixelRatio,
+ rect.top * pixelRatio,
],
size: [
- rect.right - rect.left,
- rect.bottom - rect.top,
+ (rect.right - rect.left) * pixelRatio,
+ (rect.bottom - rect.top) * pixelRatio,
],
};
};
@@ -81,16 +83,12 @@ export class ByondUi extends Component {
}
shouldComponentUpdate(nextProps) {
- const {
- params: prevParams = {},
- ...prevRest
- } = this.props;
- const {
- params: nextParams = {},
- ...nextRest
- } = nextProps;
- return shallowDiffers(prevParams, nextParams)
- || shallowDiffers(prevRest, nextRest);
+ const { params: prevParams = {}, ...prevRest } = this.props;
+ const { params: nextParams = {}, ...nextRest } = nextProps;
+ return (
+ shallowDiffers(prevParams, nextParams) ||
+ shallowDiffers(prevRest, nextRest)
+ );
}
componentDidMount() {
@@ -108,13 +106,11 @@ export class ByondUi extends Component {
if (Byond.IS_LTE_IE10) {
return;
}
- const {
- params = {},
- } = this.props;
+ const { params = {} } = this.props;
const box = getBoundingBox(this.containerRef.current);
logger.debug('bounding box', box);
this.byondUiElement.render({
- parent: window.__windowId__,
+ parent: Byond.windowId,
...params,
pos: box.pos[0] + ',' + box.pos[1],
size: box.size[0] + 'x' + box.size[1],
@@ -133,9 +129,7 @@ export class ByondUi extends Component {
render() {
const { params, ...rest } = this.props;
return (
-
+
{/* Filler */}
diff --git a/tgui/packages/tgui/components/KeyListener.tsx b/tgui/packages/tgui/components/KeyListener.tsx
new file mode 100644
index 000000000000..62509cae96d6
--- /dev/null
+++ b/tgui/packages/tgui/components/KeyListener.tsx
@@ -0,0 +1,39 @@
+import { Component } from 'inferno';
+import { KeyEvent } from '../events';
+import { listenForKeyEvents } from '../hotkeys';
+
+type KeyListenerProps = Partial<{
+ onKey: (key: KeyEvent) => void;
+ onKeyDown: (key: KeyEvent) => void;
+ onKeyUp: (key: KeyEvent) => void;
+}>;
+
+export class KeyListener extends Component {
+ dispose: () => void;
+
+ constructor() {
+ super();
+
+ this.dispose = listenForKeyEvents((key) => {
+ if (this.props.onKey) {
+ this.props.onKey(key);
+ }
+
+ if (key.isDown() && this.props.onKeyDown) {
+ this.props.onKeyDown(key);
+ }
+
+ if (key.isUp() && this.props.onKeyUp) {
+ this.props.onKeyUp(key);
+ }
+ });
+ }
+
+ componentWillUnmount() {
+ this.dispose();
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/tgui/packages/tgui/components/Tooltip.js b/tgui/packages/tgui/components/Tooltip.js
deleted file mode 100644
index 5c0aad61a914..000000000000
--- a/tgui/packages/tgui/components/Tooltip.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-import { classes } from 'common/react';
-
-export const Tooltip = props => {
- const {
- content,
- overrideLong = false,
- position = 'bottom',
- } = props;
- // Empirically calculated length of the string,
- // at which tooltip text starts to overflow.
- const long = typeof content === 'string'
- && (content.length > 35 && !overrideLong);
- return (
-
- );
-};
diff --git a/tgui/packages/tgui/components/Tooltip.tsx b/tgui/packages/tgui/components/Tooltip.tsx
index cb35425e7401..62885c3b6eda 100644
--- a/tgui/packages/tgui/components/Tooltip.tsx
+++ b/tgui/packages/tgui/components/Tooltip.tsx
@@ -20,16 +20,13 @@ const DEFAULT_OPTIONS = {
],
};
-const NULL_RECT: DOMRect = {
+const NULL_RECT = {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
- x: 0,
- y: 0,
- toJSON: () => null,
};
export class Tooltip extends Component {
diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js
index cce830012250..fa613161ea1c 100644
--- a/tgui/packages/tgui/components/index.js
+++ b/tgui/packages/tgui/components/index.js
@@ -24,6 +24,7 @@ export { Grid } from './Grid';
export { Icon } from './Icon';
export { InfinitePlane } from './InfinitePlane';
export { Input } from './Input';
+export { KeyListener } from './KeyListener';
export { Knob } from './Knob';
export { LabeledControls } from './LabeledControls';
export { LabeledList } from './LabeledList';
@@ -37,8 +38,8 @@ export { RestrictedInput } from './RestrictedInput';
export { RoundGauge } from './RoundGauge';
export { Section } from './Section';
export { Slider } from './Slider';
-export { Stack } from './Stack';
export { StyleableSection } from './StyleableSection';
+export { Stack } from './Stack';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { TextArea } from './TextArea';
diff --git a/tgui/packages/tgui/events.js b/tgui/packages/tgui/events.js
index a27f1e192549..a0bc2ab2da32 100644
--- a/tgui/packages/tgui/events.js
+++ b/tgui/packages/tgui/events.js
@@ -16,7 +16,6 @@ export const setupGlobalEvents = (options = {}) => {
ignoreWindowFocus = !!options.ignoreWindowFocus;
};
-
// Window focus
// --------------------------------------------------------
@@ -44,18 +43,17 @@ const setWindowFocus = (value, delayed) => {
}
};
-
// Focus stealing
// --------------------------------------------------------
let focusStolenBy = null;
-export const canStealFocus = node => {
+export const canStealFocus = (node) => {
const tag = String(node.tagName).toLowerCase();
return tag === 'input' || tag === 'textarea';
};
-const stealFocus = node => {
+const stealFocus = (node) => {
releaseStolenFocus();
focusStolenBy = node;
focusStolenBy.addEventListener('blur', releaseStolenFocus);
@@ -68,7 +66,6 @@ const releaseStolenFocus = () => {
}
};
-
// Focus follows the mouse
// --------------------------------------------------------
@@ -76,18 +73,18 @@ let focusedNode = null;
let lastVisitedNode = null;
const trackedNodes = [];
-export const addScrollableNode = node => {
+export const addScrollableNode = (node) => {
trackedNodes.push(node);
};
-export const removeScrollableNode = node => {
+export const removeScrollableNode = (node) => {
const index = trackedNodes.indexOf(node);
if (index >= 0) {
trackedNodes.splice(index, 1);
}
};
-const focusNearestTrackedParent = node => {
+const focusNearestTrackedParent = (node) => {
if (focusStolenBy || !windowFocused) {
return;
}
@@ -106,7 +103,7 @@ const focusNearestTrackedParent = node => {
}
};
-window.addEventListener('mousemove', e => {
+window.addEventListener('mousemove', (e) => {
const node = e.target;
if (node !== lastVisitedNode) {
lastVisitedNode = node;
@@ -114,11 +111,10 @@ window.addEventListener('mousemove', e => {
}
});
-
// Focus event hooks
// --------------------------------------------------------
-window.addEventListener('focusin', e => {
+window.addEventListener('focusin', (e) => {
lastVisitedNode = null;
focusedNode = e.target;
setWindowFocus(true);
@@ -128,27 +124,26 @@ window.addEventListener('focusin', e => {
}
});
-window.addEventListener('focusout', e => {
+window.addEventListener('focusout', (e) => {
lastVisitedNode = null;
setWindowFocus(false, true);
});
-window.addEventListener('blur', e => {
+window.addEventListener('blur', (e) => {
lastVisitedNode = null;
setWindowFocus(false, true);
});
-window.addEventListener('beforeunload', e => {
+window.addEventListener('beforeunload', (e) => {
setWindowFocus(false);
});
-
// Key events
// --------------------------------------------------------
const keyHeldByCode = {};
-class KeyEvent {
+export class KeyEvent {
constructor(e, type, repeat) {
this.event = e;
this.type = type;
@@ -164,6 +159,7 @@ class KeyEvent {
}
isModifierKey() {
+ // prettier-ignore
return this.code === KEY_CTRL
|| this.code === KEY_SHIFT
|| this.code === KEY_ALT;
@@ -193,11 +189,9 @@ class KeyEvent {
}
if (this.code >= 48 && this.code <= 90) {
this._str += String.fromCharCode(this.code);
- }
- else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
+ } else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
this._str += 'F' + (this.code - 111);
- }
- else {
+ } else {
this._str += '[' + this.code + ']';
}
return this._str;
@@ -205,7 +199,7 @@ class KeyEvent {
}
// IE8: Keydown event is only available on document.
-document.addEventListener('keydown', e => {
+document.addEventListener('keydown', (e) => {
if (canStealFocus(e.target)) {
return;
}
@@ -216,7 +210,7 @@ document.addEventListener('keydown', e => {
keyHeldByCode[code] = true;
});
-document.addEventListener('keyup', e => {
+document.addEventListener('keyup', (e) => {
if (canStealFocus(e.target)) {
return;
}
diff --git a/tgui/packages/tgui/hotkeys.js b/tgui/packages/tgui/hotkeys.ts
similarity index 60%
rename from tgui/packages/tgui/hotkeys.js
rename to tgui/packages/tgui/hotkeys.ts
index 4e5412c7f7a4..f7176bd00300 100644
--- a/tgui/packages/tgui/hotkeys.js
+++ b/tgui/packages/tgui/hotkeys.ts
@@ -4,34 +4,40 @@
* @license MIT
*/
-import { KEY_CTRL, KEY_ENTER, KEY_ESCAPE, KEY_F, KEY_F5, KEY_R, KEY_SHIFT, KEY_SPACE, KEY_TAB } from 'common/keycodes';
-import { globalEvents } from './events';
+import * as keycodes from 'common/keycodes';
+import { globalEvents, KeyEvent } from './events';
import { createLogger } from './logging';
const logger = createLogger('hotkeys');
// BYOND macros, in `key: command` format.
-const byondMacros = {};
+const byondMacros: Record = {};
-// Array of acquired keys, which will not be sent to BYOND.
+// Default set of acquired keys, which will not be sent to BYOND.
const hotKeysAcquired = [
- // Default set of acquired keys
- KEY_ESCAPE,
- KEY_ENTER,
- KEY_SPACE,
- KEY_TAB,
- KEY_CTRL,
- KEY_SHIFT,
- KEY_F5,
+ keycodes.KEY_ESCAPE,
+ keycodes.KEY_ENTER,
+ keycodes.KEY_SPACE,
+ keycodes.KEY_TAB,
+ keycodes.KEY_CTRL,
+ keycodes.KEY_SHIFT,
+ keycodes.KEY_UP,
+ keycodes.KEY_DOWN,
+ keycodes.KEY_LEFT,
+ keycodes.KEY_RIGHT,
+ keycodes.KEY_F5,
];
// State of passed-through keys.
-const keyState = {};
+const keyState: Record = {};
+
+// Custom listeners for key events
+const keyListeners: ((key: KeyEvent) => void)[] = [];
/**
* Converts a browser keycode to BYOND keycode.
*/
-const keyCodeToByond = keyCode => {
+const keyCodeToByond = (keyCode: number) => {
if (keyCode === 16) return 'Shift';
if (keyCode === 17) return 'Ctrl';
if (keyCode === 18) return 'Alt';
@@ -45,6 +51,7 @@ const keyCodeToByond = keyCode => {
if (keyCode === 40) return 'South';
if (keyCode === 45) return 'Insert';
if (keyCode === 46) return 'Delete';
+ // prettier-ignore
if (keyCode >= 48 && keyCode <= 57 || keyCode >= 65 && keyCode <= 90) {
return String.fromCharCode(keyCode);
}
@@ -63,20 +70,24 @@ const keyCodeToByond = keyCode => {
* Keyboard passthrough logic. This allows you to keep doing things
* in game while the browser window is focused.
*/
-const handlePassthrough = key => {
+const handlePassthrough = (key: KeyEvent) => {
+ const keyString = String(key);
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
- if (key.ctrl && (key.code === KEY_F5 || key.code === KEY_R)) {
+ if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') {
location.reload();
return;
}
// Prevent passthrough on Ctrl+F
- if (key.ctrl && key.code === KEY_F) {
+ if (keyString === 'Ctrl+F') {
return;
}
// NOTE: Alt modifier is pretty bad and sticky in IE11.
- if (key.event.defaultPrevented
- || key.isModifierKey()
- || hotKeysAcquired.includes(key.code)) {
+ // prettier-ignore
+ if (
+ key.event.defaultPrevented
+ || key.isModifierKey()
+ || hotKeysAcquired.includes(key.code)
+ ) {
return;
}
const byondKeyCode = keyCodeToByond(key.code);
@@ -109,14 +120,14 @@ const handlePassthrough = key => {
* Acquires a lock on the hotkey, which prevents it from being
* passed through to BYOND.
*/
-export const acquireHotKey = keyCode => {
+export const acquireHotKey = (keyCode: number) => {
hotKeysAcquired.push(keyCode);
};
/**
* Makes the hotkey available to BYOND again.
*/
-export const releaseHotKey = keyCode => {
+export const releaseHotKey = (keyCode: number) => {
const index = hotKeysAcquired.indexOf(keyCode);
if (index >= 0) {
hotKeysAcquired.splice(index, 1);
@@ -133,25 +144,34 @@ export const releaseHeldKeys = () => {
}
};
+type ByondSkinMacro = {
+ command: string;
+ name: string;
+};
+
export const setupHotKeys = () => {
// Read macros
- Byond.winget('default.*').then(data => {
+ Byond.winget('default.*').then((data: Record) => {
// Group each macro by ref
- const groupedByRef = {};
+ const groupedByRef: Record = {};
for (let key of Object.keys(data)) {
const keyPath = key.split('.');
const ref = keyPath[1];
const prop = keyPath[2];
if (ref && prop) {
+ // This piece of code imperatively adds each property to a
+ // ByondSkinMacro object in the order we meet it, which is hard
+ // to express safely in typescript.
if (!groupedByRef[ref]) {
- groupedByRef[ref] = {};
+ groupedByRef[ref] = {} as any;
}
groupedByRef[ref][prop] = data[key];
}
}
// Insert macros
const escapedQuotRegex = /\\"/g;
- const unescape = str => str
+ // prettier-ignore
+ const unescape = (str: string) => str
.substring(1, str.length - 1)
.replace(escapedQuotRegex, '"');
for (let ref of Object.keys(groupedByRef)) {
@@ -165,7 +185,36 @@ export const setupHotKeys = () => {
globalEvents.on('window-blur', () => {
releaseHeldKeys();
});
- globalEvents.on('key', key => {
+ globalEvents.on('key', (key: KeyEvent) => {
+ for (const keyListener of keyListeners) {
+ keyListener(key);
+ }
handlePassthrough(key);
});
};
+
+/**
+ * Registers for any key events, such as key down or key up.
+ * This should be preferred over directly connecting to keydown/keyup
+ * as it lets tgui prevent the key from reaching BYOND.
+ *
+ * If using in a component, prefer KeyListener, which automatically handles
+ * stopping listening when unmounting.
+ *
+ * @param callback The function to call whenever a key event occurs
+ * @returns A callback to stop listening
+ */
+export const listenForKeyEvents = (callback: (key: KeyEvent) => void) => {
+ keyListeners.push(callback);
+
+ let removed = false;
+
+ return () => {
+ if (removed) {
+ return;
+ }
+
+ removed = true;
+ keyListeners.splice(keyListeners.indexOf(callback), 1);
+ };
+};
diff --git a/tgui/packages/tgui/http.ts b/tgui/packages/tgui/http.ts
new file mode 100644
index 000000000000..17ba6678a6ec
--- /dev/null
+++ b/tgui/packages/tgui/http.ts
@@ -0,0 +1,17 @@
+/**
+ * An equivalent to `fetch`, except will automatically retry.
+ */
+ export const fetchRetry
+ = (
+ url: string,
+ options?: RequestInit,
+ retryTimer: number = 1000,
+ ): Promise => {
+ return fetch(url, options).catch(() => {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ fetchRetry(url, options, retryTimer).then(resolve);
+ }, retryTimer);
+ });
+ });
+ };
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx
new file mode 100644
index 000000000000..5307c25a9fd2
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx
@@ -0,0 +1,207 @@
+import { binaryInsertWith } from "common/collections";
+import { classes } from "common/react";
+import { useBackend, useLocalState } from "../../backend";
+import { Box, Button, Divider, Flex, Section, Stack, Tooltip } from "../../components";
+import { Antagonist, Category } from "./antagonists/base";
+import { PreferencesMenuData } from "./data";
+
+const requireAntag = require.context("./antagonists/antagonists", false, /.ts$/);
+
+const antagsByCategory = new Map();
+
+// This will break at priorities higher than 10, but that almost definitely
+// will not happen.
+const binaryInsertAntag = binaryInsertWith((antag: Antagonist) => {
+ return `${antag.priority}_${antag.name}`;
+});
+
+for (const antagKey of requireAntag.keys()) {
+ const antag = requireAntag<{
+ default?: Antagonist,
+ }>(antagKey).default;
+
+ if (!antag) {
+ continue;
+ }
+
+ antagsByCategory.set(
+ antag.category,
+ binaryInsertAntag(
+ antagsByCategory.get(antag.category) || [],
+ antag,
+ )
+ );
+}
+
+const AntagSelection = (props: {
+ antagonists: Antagonist[],
+ name: string,
+}, context) => {
+ const { act, data } = useBackend(context);
+ const className = "PreferencesMenu__Antags__antagSelection";
+
+ const [predictedState, setPredictedState]
+ = useLocalState(
+ context,
+ "AntagSelection_predictedState",
+ new Set(data.selected_antags),
+ );
+
+ const enableAntags = (antags: string[]) => {
+ const newState = new Set(predictedState);
+
+ for (const antag of antags) {
+ newState.add(antag);
+ }
+
+ setPredictedState(newState);
+
+ act("set_antags", {
+ antags,
+ toggled: true,
+ });
+ };
+
+ const disableAntags = (antags: string[]) => {
+ const newState = new Set(predictedState);
+
+ for (const antag of antags) {
+ newState.delete(antag);
+ }
+
+ setPredictedState(newState);
+
+ act("set_antags", {
+ antags,
+ toggled: false,
+ });
+ };
+
+ const antagonistKeys = props.antagonists.map(antagonist => antagonist.key);
+
+ return (
+
+
+
+
+ >
+ )}>
+
+ {props.antagonists.map(antagonist => {
+ const isBanned = data.antag_bans
+ && data.antag_bans.indexOf(antagonist.key) !== -1;
+
+ const daysLeft
+ = (data.antag_days_left && data.antag_days_left[antagonist.key])
+ || 0;
+
+ return (
+ 0)
+ ? "banned"
+ : predictedState.has(antagonist.key) ? "on" : "off"
+ }`,
+ ])}
+ key={antagonist.key}
+ >
+
+
+ {antagonist.name}
+
+
+
+ {
+ return (
+