From 588db2e83b44013c1feed2d3c907fa2789a30f5d Mon Sep 17 00:00:00 2001 From: Ling Date: Fri, 6 Jan 2023 23:29:16 +0100 Subject: [PATCH 001/216] TGUI Preferences Menu + total rewrite of the preferences backend (#17368) * It compiles * It opens * Sync 1 * Add asset caching * Sync 2 * It opens without dev now * Update a few packages * Sync 3 * Sync 4 keybind fix * start of dehardcoded species * Small fixes * Add more individual preferences * ASS sync --- _maps/_basemap.dm | 2 +- code/__DEFINES/DNA.dm | 16 + code/__DEFINES/antagonists.dm | 3 + code/__DEFINES/colors.dm | 5 + code/__DEFINES/food.dm | 19 + code/__DEFINES/jobs.dm | 21 + code/__DEFINES/preferences.dm | 51 + code/__DEFINES/subsystems.dm | 9 +- code/__HELPERS/_lists.dm | 9 +- code/__HELPERS/colors.dm | 18 + code/__HELPERS/mobs.dm | 19 +- code/__HELPERS/sanitize_values.dm | 9 +- code/__HELPERS/text.dm | 5 + code/_globalvars/_regexes.dm | 3 + code/_globalvars/lists/client.dm | 21 +- code/_globalvars/lists/flavor_misc.dm | 14 +- code/_globalvars/lists/keybindings.dm | 10 +- .../configuration/entries/game_options.dm | 2 - .../configuration/entries/general.dm | 3 + code/controllers/subsystem/asset_loading.dm | 19 + code/controllers/subsystem/assets.dm | 4 +- code/controllers/subsystem/atoms.dm | 17 + code/controllers/subsystem/early_assets.dm | 24 + code/controllers/subsystem/job.dm | 41 +- code/controllers/subsystem/mapping.dm | 2 +- code/controllers/subsystem/persistence.dm | 20 - .../subsystem/persistent_paintings.dm | 29 + .../subsystem/processing/quirks.dm | 61 + code/controllers/subsystem/vote.dm | 2 +- code/datums/traits/neutral.dm | 2 +- code/game/objects/structures/artstuff.dm | 22 +- code/modules/admin/admin_verbs.dm | 9 - .../antagonists/_common/antag_datum.dm | 38 + .../antagonists/traitor/datum_traitor.dm | 18 +- code/modules/asset_cache/asset_list.dm | 220 +- code/modules/asset_cache/asset_list_items.dm | 74 +- .../asset_cache/transports/asset_transport.dm | 2 +- code/modules/client/client_defines.dm | 3 + code/modules/client/client_procs.dm | 42 +- code/modules/client/preferences.dm | 2353 +++-------------- code/modules/client/preferences/README.md | 419 +++ .../modules/client/preferences/_preference.dm | 532 ++++ code/modules/client/preferences/admin.dm | 41 + code/modules/client/preferences/age.dm | 10 + .../client/preferences/ai_core_display.dm | 25 + .../client/preferences/ambient_occlusion.dm | 12 + code/modules/client/preferences/assets.dm | 65 + .../client/preferences/auto_fit_viewport.dm | 7 + .../client/preferences/buttons_locked.dm | 5 + code/modules/client/preferences/clothing.dm | 151 ++ .../preferences/engineering_department.dm | 22 + code/modules/client/preferences/fps.dm | 20 + code/modules/client/preferences/gender.dm | 13 + code/modules/client/preferences/ghost.dm | 178 ++ code/modules/client/preferences/hotkeys.dm | 7 + .../client/preferences/jobless_role.dm | 12 + .../preferences/middleware/_middleware.dm | 52 + .../client/preferences/middleware/antags.dm | 164 ++ .../client/preferences/middleware/jobs.dm | 119 + .../preferences/middleware/keybindings.dm | 97 + .../preferences/middleware/legacy_toggles.dm | 127 + .../client/preferences/middleware/names.dm | 56 + .../client/preferences/middleware/quirks.dm | 89 + .../client/preferences/middleware/random.dm | 84 + .../client/preferences/middleware/species.dm | 35 + code/modules/client/preferences/names.dm | 144 + code/modules/client/preferences/ooc.dm | 14 + code/modules/client/preferences/parallax.dm | 38 + code/modules/client/preferences/pda.dm | 17 + code/modules/client/preferences/pixel_size.dm | 15 + .../client/preferences/preferred_map.dm | 52 + code/modules/client/preferences/random.dm | 53 + code/modules/client/preferences/runechat.dm | 25 + .../client/preferences/scaling_method.dm | 14 + .../client/preferences/security_department.dm | 22 + code/modules/client/preferences/skillcape.dm | 40 + code/modules/client/preferences/skin_tone.dm | 36 + code/modules/client/preferences/species.dm | 52 + .../preferences/species_features/basic.dm | 101 + .../preferences/species_features/ethereal.dm | 33 + .../preferences/species_features/felinid.dm | 33 + .../preferences/species_features/lizard.dm | 141 + .../preferences/species_features/moth.dm | 22 + .../preferences/species_features/mutants.dm | 20 + .../preferences/tgui_prefs_migration.dm | 114 + code/modules/client/preferences/ui_style.dm | 26 + .../client/preferences/uplink_location.dm | 26 + code/modules/client/preferences/widescreen.dm | 7 + .../client/preferences/window_flashing.dm | 5 + code/modules/client/preferences_menu.dm | 25 + code/modules/client/preferences_savefile.dm | 440 +-- code/modules/client/preferences_toggles.dm | 521 ---- code/modules/client/verbs/etips.dm | 20 - code/modules/client/verbs/ooc.dm | 29 +- code/modules/jobs/job_types/_job.dm | 24 + code/modules/jobs/job_types/assistant.dm | 16 + .../jobs/job_types/security_officer.dm | 2 +- code/modules/keybindings/bindings_client.dm | 6 +- code/modules/keybindings/setup.dm | 2 +- code/modules/language/language_holder.dm | 5 +- .../modules/mob/dead/new_player/new_player.dm | 2 +- .../mob/dead/new_player/preferences_setup.dm | 47 +- code/modules/mob/living/carbon/human/dummy.dm | 39 +- .../mob/living/carbon/human/species.dm | 456 +++- .../living/carbon/human/species_types/IPC.dm | 24 + .../carbon/human/species_types/dullahan.dm | 43 + .../carbon/human/species_types/ethereal.dm | 69 +- .../carbon/human/species_types/felinid.dm | 57 + .../carbon/human/species_types/flypeople.dm | 55 + .../carbon/human/species_types/golems.dm | 58 + .../carbon/human/species_types/humans.dm | 51 + .../carbon/human/species_types/jellypeople.dm | 19 + .../human/species_types/lizardpeople.dm | 40 + .../carbon/human/species_types/mothmen.dm | 55 + .../carbon/human/species_types/mushpeople.dm | 1 + .../carbon/human/species_types/plasmamen.dm | 81 +- .../carbon/human/species_types/podpeople.dm | 23 + .../carbon/human/species_types/polysmorphs.dm | 22 + .../human/species_types/shadowpeople.dm | 51 + .../carbon/human/species_types/skeletons.dm | 12 + .../carbon/human/species_types/snail.dm | 1 + .../carbon/human/species_types/vampire.dm | 76 +- .../carbon/human/species_types/zombies.dm | 22 + .../living/silicon/ai/ai_portrait_picker.dm | 4 +- code/modules/mob/living/silicon/silicon.dm | 2 +- .../file_system/programs/portrait_printer.dm | 4 +- .../chemistry/machinery/chem_master.dm | 23 +- .../modules/research/techweb/_techweb_node.dm | 6 +- code/modules/tgui/tgui_window.dm | 2 +- config/config.txt | 7 + icons/blanks/32x32.dmi | Bin 0 -> 216 bytes icons/blanks/96x96.dmi | Bin 0 -> 222 bytes icons/blanks/blank_title.png | Bin 0 -> 181 bytes tgui/package.json | 10 +- tgui/packages/common/collections.js | 271 -- tgui/packages/common/collections.ts | 371 +-- tgui/packages/common/exhaustive.ts | 19 + tgui/packages/tgfont/icons/ATTRIBUTIONS.md | 6 + tgui/packages/tgfont/icons/air-tank-slash.svg | 1 + tgui/packages/tgfont/icons/air-tank.svg | 1 + tgui/packages/tgfont/icons/bad-touch.svg | 46 + tgui/packages/tgfont/icons/image-minus.svg | 1 + tgui/packages/tgfont/icons/image-plus.svg | 1 + .../packages/tgfont/icons/nanotrasen-logo.svg | 3 + .../packages/tgfont/icons/nanotrasen_logo.svg | 8 - tgui/packages/tgfont/icons/non-binary.svg | 44 + .../packages/tgfont/icons/prosthetic-full.svg | 27 + tgui/packages/tgfont/icons/prosthetic-leg.svg | 35 + tgui/packages/tgfont/icons/sound-minus.svg | 1 + tgui/packages/tgfont/icons/sound-plus.svg | 1 + tgui/packages/tgfont/icons/syndicate-logo.svg | 13 + tgui/packages/tgfont/icons/syndicate_logo.svg | 6 - tgui/packages/tgui-polyfill/index.js | 6 +- tgui/packages/tgui-polyfill/package.json | 8 +- .../tgui/components/AnimatedNumber.js | 83 - tgui/packages/tgui/components/ByondUi.js | 44 +- tgui/packages/tgui/components/KeyListener.tsx | 39 + tgui/packages/tgui/components/Tooltip.js | 28 - tgui/packages/tgui/components/Tooltip.tsx | 5 +- tgui/packages/tgui/components/index.js | 3 +- tgui/packages/tgui/events.js | 38 +- tgui/packages/tgui/{hotkeys.js => hotkeys.ts} | 103 +- tgui/packages/tgui/http.ts | 17 + .../interfaces/PreferencesMenu/AntagsPage.tsx | 207 ++ .../CharacterPreferenceWindow.tsx | 164 ++ .../PreferencesMenu/CharacterPreview.tsx | 14 + .../PreferencesMenu/GamePreferenceWindow.tsx | 77 + .../PreferencesMenu/GamePreferencesPage.tsx | 102 + .../interfaces/PreferencesMenu/JobsPage.tsx | 413 +++ .../PreferencesMenu/KeybindingsPage.tsx | 465 ++++ .../interfaces/PreferencesMenu/MainPage.tsx | 596 +++++ .../interfaces/PreferencesMenu/PageButton.tsx | 30 + .../interfaces/PreferencesMenu/QuirksPage.tsx | 327 +++ .../PreferencesMenu/RandomizationButton.tsx | 64 + .../ServerPreferencesFetcher.tsx | 38 + .../PreferencesMenu/SpeciesPage.tsx | 345 +++ .../interfaces/PreferencesMenu/TabbedMenu.tsx | 94 + .../antagonists/antagonists/traitor.ts | 25 + .../PreferencesMenu/antagonists/base.ts | 35 + .../tgui/interfaces/PreferencesMenu/data.ts | 188 ++ .../tgui/interfaces/PreferencesMenu/index.tsx | 24 + .../tgui/interfaces/PreferencesMenu/names.tsx | 235 ++ .../preferences/features/base.tsx | 333 +++ .../features/character_preferences/age.tsx | 6 + .../character_preferences/ai_core_display.tsx | 6 + .../engineering_department.tsx | 6 + .../features/character_preferences/pda.tsx | 15 + .../security_department.tsx | 6 + .../character_preferences/skillcape.tsx | 6 + .../character_preferences/skin_tone.tsx | 65 + .../character_preferences/uplink_loc.tsx | 6 + .../features/game_preferences/admin.tsx | 15 + .../game_preferences/ambient_occlusion.tsx | 8 + .../game_preferences/auto_fit_viewport.tsx | 7 + .../game_preferences/buttons_locked.tsx | 8 + .../features/game_preferences/fps.tsx | 56 + .../features/game_preferences/ghost.tsx | 139 + .../features/game_preferences/hotkeys.tsx | 8 + .../game_preferences/legacy_chat_toggles.tsx | 89 + .../game_preferences/legacy_toggles.tsx | 153 ++ .../features/game_preferences/ooc.tsx | 8 + .../features/game_preferences/parallax.tsx | 7 + .../features/game_preferences/pixel_size.tsx | 13 + .../game_preferences/preferred_map.tsx | 13 + .../features/game_preferences/runechat.tsx | 29 + .../game_preferences/scaling_method.tsx | 11 + .../features/game_preferences/tgui.tsx | 15 + .../features/game_preferences/ui_style.tsx | 63 + .../features/game_preferences/widescreen.tsx | 7 + .../game_preferences/window_flashing.tsx | 12 + .../preferences/features/index.ts | 23 + .../preferences/features/randomization.tsx | 90 + .../preferences/features/species_features.tsx | 51 + .../PreferencesMenu/preferences/gender.ts | 22 + .../PreferencesMenu/useRandomToggleState.ts | 4 + tgui/packages/tgui/logging.js | 7 +- tgui/packages/tgui/package.json | 2 + .../tgui/styles/atomic/centered-image.scss | 7 + .../packages/tgui/styles/atomic/fit-text.scss | 10 + tgui/packages/tgui/styles/colors.scss | 2 + .../tgui/styles/components/Section.scss | 4 - .../styles/interfaces/PreferencesMenu.scss | 240 ++ tgui/packages/tgui/styles/main.scss | 3 + tgui/yarn.lock | 84 +- yogstation.dme | 57 +- yogstation/code/controllers/subsystem/yogs.dm | 2 +- .../modules/client/preferences_toggles.dm | 26 - .../code/modules/jobs/job_types/tourist.dm | 1 + .../carbon/human/species_types/plantpeople.dm | 22 + .../species_types/preternis/preternis.dm | 23 + 230 files changed, 11630 insertions(+), 3917 deletions(-) create mode 100644 code/__HELPERS/colors.dm create mode 100644 code/_globalvars/_regexes.dm create mode 100644 code/controllers/subsystem/asset_loading.dm create mode 100644 code/controllers/subsystem/early_assets.dm create mode 100644 code/controllers/subsystem/persistent_paintings.dm create mode 100644 code/modules/client/preferences/README.md create mode 100644 code/modules/client/preferences/_preference.dm create mode 100644 code/modules/client/preferences/admin.dm create mode 100644 code/modules/client/preferences/age.dm create mode 100644 code/modules/client/preferences/ai_core_display.dm create mode 100644 code/modules/client/preferences/ambient_occlusion.dm create mode 100644 code/modules/client/preferences/assets.dm create mode 100644 code/modules/client/preferences/auto_fit_viewport.dm create mode 100644 code/modules/client/preferences/buttons_locked.dm create mode 100644 code/modules/client/preferences/clothing.dm create mode 100644 code/modules/client/preferences/engineering_department.dm create mode 100644 code/modules/client/preferences/fps.dm create mode 100644 code/modules/client/preferences/gender.dm create mode 100644 code/modules/client/preferences/ghost.dm create mode 100644 code/modules/client/preferences/hotkeys.dm create mode 100644 code/modules/client/preferences/jobless_role.dm create mode 100644 code/modules/client/preferences/middleware/_middleware.dm create mode 100644 code/modules/client/preferences/middleware/antags.dm create mode 100644 code/modules/client/preferences/middleware/jobs.dm create mode 100644 code/modules/client/preferences/middleware/keybindings.dm create mode 100644 code/modules/client/preferences/middleware/legacy_toggles.dm create mode 100644 code/modules/client/preferences/middleware/names.dm create mode 100644 code/modules/client/preferences/middleware/quirks.dm create mode 100644 code/modules/client/preferences/middleware/random.dm create mode 100644 code/modules/client/preferences/middleware/species.dm create mode 100644 code/modules/client/preferences/names.dm create mode 100644 code/modules/client/preferences/ooc.dm create mode 100644 code/modules/client/preferences/parallax.dm create mode 100644 code/modules/client/preferences/pda.dm create mode 100644 code/modules/client/preferences/pixel_size.dm create mode 100644 code/modules/client/preferences/preferred_map.dm create mode 100644 code/modules/client/preferences/random.dm create mode 100644 code/modules/client/preferences/runechat.dm create mode 100644 code/modules/client/preferences/scaling_method.dm create mode 100644 code/modules/client/preferences/security_department.dm create mode 100644 code/modules/client/preferences/skillcape.dm create mode 100644 code/modules/client/preferences/skin_tone.dm create mode 100644 code/modules/client/preferences/species.dm create mode 100644 code/modules/client/preferences/species_features/basic.dm create mode 100644 code/modules/client/preferences/species_features/ethereal.dm create mode 100644 code/modules/client/preferences/species_features/felinid.dm create mode 100644 code/modules/client/preferences/species_features/lizard.dm create mode 100644 code/modules/client/preferences/species_features/moth.dm create mode 100644 code/modules/client/preferences/species_features/mutants.dm create mode 100644 code/modules/client/preferences/tgui_prefs_migration.dm create mode 100644 code/modules/client/preferences/ui_style.dm create mode 100644 code/modules/client/preferences/uplink_location.dm create mode 100644 code/modules/client/preferences/widescreen.dm create mode 100644 code/modules/client/preferences/window_flashing.dm create mode 100644 code/modules/client/preferences_menu.dm delete mode 100644 code/modules/client/preferences_toggles.dm delete mode 100644 code/modules/client/verbs/etips.dm create mode 100644 icons/blanks/32x32.dmi create mode 100644 icons/blanks/96x96.dmi create mode 100644 icons/blanks/blank_title.png delete mode 100644 tgui/packages/common/collections.js create mode 100644 tgui/packages/common/exhaustive.ts create mode 100644 tgui/packages/tgfont/icons/ATTRIBUTIONS.md create mode 100644 tgui/packages/tgfont/icons/air-tank-slash.svg create mode 100644 tgui/packages/tgfont/icons/air-tank.svg create mode 100644 tgui/packages/tgfont/icons/bad-touch.svg create mode 100644 tgui/packages/tgfont/icons/image-minus.svg create mode 100644 tgui/packages/tgfont/icons/image-plus.svg create mode 100644 tgui/packages/tgfont/icons/nanotrasen-logo.svg delete mode 100644 tgui/packages/tgfont/icons/nanotrasen_logo.svg create mode 100644 tgui/packages/tgfont/icons/non-binary.svg create mode 100644 tgui/packages/tgfont/icons/prosthetic-full.svg create mode 100644 tgui/packages/tgfont/icons/prosthetic-leg.svg create mode 100644 tgui/packages/tgfont/icons/sound-minus.svg create mode 100644 tgui/packages/tgfont/icons/sound-plus.svg create mode 100644 tgui/packages/tgfont/icons/syndicate-logo.svg delete mode 100644 tgui/packages/tgfont/icons/syndicate_logo.svg delete mode 100644 tgui/packages/tgui/components/AnimatedNumber.js create mode 100644 tgui/packages/tgui/components/KeyListener.tsx delete mode 100644 tgui/packages/tgui/components/Tooltip.js rename tgui/packages/tgui/{hotkeys.js => hotkeys.ts} (60%) create mode 100644 tgui/packages/tgui/http.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/KeybindingsPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/data.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skillcape.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/species_features.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts create mode 100644 tgui/packages/tgui/styles/atomic/centered-image.scss create mode 100644 tgui/packages/tgui/styles/atomic/fit-text.scss create mode 100644 tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss delete mode 100644 yogstation/code/modules/client/preferences_toggles.dm diff --git a/_maps/_basemap.dm b/_maps/_basemap.dm index faf2c1a18c8b..150f974d7a53 100644 --- a/_maps/_basemap.dm +++ b/_maps/_basemap.dm @@ -1,4 +1,4 @@ -//#define LOWMEMORYMODE //uncomment this to load centcom and runtime station and thats it. +#define LOWMEMORYMODE //uncomment this to load centcom and runtime station and thats it. #include "map_files\generic\CentCom.dmm" diff --git a/code/__DEFINES/DNA.dm b/code/__DEFINES/DNA.dm index 1b41f155696d..8d82f64f85bc 100644 --- a/code/__DEFINES/DNA.dm +++ b/code/__DEFINES/DNA.dm @@ -195,3 +195,19 @@ #define G_MALE 1 #define G_FEMALE 2 #define G_PLURAL 3 + +// Defines for used in creating "perks" for the species preference pages. +/// A key that designates UI icon displayed on the perk. +#define SPECIES_PERK_ICON "ui_icon" +/// A key that designates the name of the perk. +#define SPECIES_PERK_NAME "name" +/// A key that designates the description of the perk. +#define SPECIES_PERK_DESC "description" +/// A key that designates what type of perk it is (see below). +#define SPECIES_PERK_TYPE "perk_type" + +// The possible types each perk can be. +// Positive perks are shown in green, negative in red, and neutral in grey. +#define SPECIES_POSITIVE_PERK "positive" +#define SPECIES_NEGATIVE_PERK "negative" +#define SPECIES_NEUTRAL_PERK "neutral" diff --git a/code/__DEFINES/antagonists.dm b/code/__DEFINES/antagonists.dm index a66c4eba91af..6400d4231af6 100644 --- a/code/__DEFINES/antagonists.dm +++ b/code/__DEFINES/antagonists.dm @@ -45,6 +45,9 @@ #define BLOB_RANDOM_PLACEMENT 1 +/// The dimensions of the antagonist preview icon. Will be scaled to this size. +#define ANTAGONIST_PREVIEW_ICON_SIZE 96 + /// How many telecrystals a normal traitor starts with #define TELECRYSTALS_DEFAULT 20 /// How many telecrystals mapper/admin only "precharged" uplink implant diff --git a/code/__DEFINES/colors.dm b/code/__DEFINES/colors.dm index 7beb454a22da..fa7b455257e2 100644 --- a/code/__DEFINES/colors.dm +++ b/code/__DEFINES/colors.dm @@ -14,6 +14,7 @@ #define COLOR_ALMOST_BLACK "#333333" #define COLOR_BLACK "#000000" #define COLOR_RED "#FF0000" +#define COLOR_MOSTLY_PURE_RED "#FF3300" #define COLOR_RED_LIGHT "#FF3333" #define COLOR_MAROON "#800000" #define COLOR_YELLOW "#FFFF00" @@ -43,6 +44,7 @@ #define COLOR_PALE_RED_GRAY "#D59998" #define COLOR_PALE_PURPLE_GRAY "#CBB1CA" #define COLOR_PURPLE_GRAY "#AE8CA8" +#define COLOR_VIBRANT_LIME "#00FF00" //Color defines used by the assembly detailer. #define COLOR_ASSEMBLY_BLACK "#545454" @@ -60,3 +62,6 @@ #define COLOR_ASSEMBLY_LBLUE "#5D99BE" #define COLOR_ASSEMBLY_BLUE "#38559E" #define COLOR_ASSEMBLY_PURPLE "#6F6192" + +/// The default color for admin say, used as a fallback when the preference is not enabled +#define DEFAULT_ASAY_COLOR COLOR_MOSTLY_PURE_RED diff --git a/code/__DEFINES/food.dm b/code/__DEFINES/food.dm index ee5e0a9ce458..ed893bd712ca 100644 --- a/code/__DEFINES/food.dm +++ b/code/__DEFINES/food.dm @@ -20,6 +20,25 @@ #define MICE (1<<19) //disliked/liked by anything that dislikes/likes any of RAW, MEAT, or GROSS, except felinids #define NUTS (1<<20) +/// A list of food type names, in order of their flags +#define FOOD_FLAGS list( \ + "MEAT", \ + "VEGETABLES", \ + "RAW", \ + "JUNKFOOD", \ + "GRAIN", \ + "FRUIT", \ + "DAIRY", \ + "FRIED", \ + "ALCOHOL", \ + "SUGAR", \ + "GROSS", \ + "TOXIC", \ + "PINEAPPLE", \ + "BREAKFAST", \ + "CLOTH", \ +) + #define DRINK_NICE 1 #define DRINK_GOOD 2 #define DRINK_VERYGOOD 3 diff --git a/code/__DEFINES/jobs.dm b/code/__DEFINES/jobs.dm index 14f023b4693f..0fddfd9b06f0 100644 --- a/code/__DEFINES/jobs.dm +++ b/code/__DEFINES/jobs.dm @@ -100,6 +100,27 @@ #define JOB_DISPLAY_ORDER_CLERK 39 #define JOB_DISPLAY_ORDER_CHAPLAIN 40 +#define DEPARTMENT_UNASSIGNED "No department assigned" +#define DEPARTMENT_BITFLAG_SECURITY (1<<0) +#define DEPARTMENT_SECURITY "Security" +#define DEPARTMENT_BITFLAG_COMMAND (1<<1) +#define DEPARTMENT_COMMAND "Command" +#define DEPARTMENT_BITFLAG_SERVICE (1<<2) +#define DEPARTMENT_SERVICE "Service" +#define DEPARTMENT_BITFLAG_CARGO (1<<3) +#define DEPARTMENT_CARGO "Cargo" +#define DEPARTMENT_BITFLAG_ENGINEERING (1<<4) +#define DEPARTMENT_ENGINEERING "Engineering" +#define DEPARTMENT_BITFLAG_SCIENCE (1<<5) +#define DEPARTMENT_SCIENCE "Science" +#define DEPARTMENT_BITFLAG_MEDICAL (1<<6) +#define DEPARTMENT_MEDICAL "Medical" +#define DEPARTMENT_BITFLAG_SILICON (1<<7) +#define DEPARTMENT_SILICON "Silicon" +#define DEPARTMENT_BITFLAG_ASSISTANT (1<<8) +#define DEPARTMENT_ASSISTANT "Assistant" +#define DEPARTMENT_BITFLAG_CAPTAIN (1<<9) +#define DEPARTMENT_CAPTAIN "Captain" /proc/find_job(target) //Get the job from the mind diff --git a/code/__DEFINES/preferences.dm b/code/__DEFINES/preferences.dm index a217d9cbd90a..673f0d36604d 100644 --- a/code/__DEFINES/preferences.dm +++ b/code/__DEFINES/preferences.dm @@ -112,3 +112,54 @@ #define JP_LOW 1 #define JP_MEDIUM 2 #define JP_HIGH 3 + + +//recommened client FPS +#define RECOMMENDED_FPS 100 + + +// randomise_appearance_prefs() and randomize_human_appearance() proc flags +#define RANDOMIZE_SPECIES (1<<0) +#define RANDOMIZE_NAME (1<<1) + + +//randomised elements +#define RANDOM_ANTAG_ONLY 1 +#define RANDOM_DISABLED 2 +#define RANDOM_ENABLED 3 + + +// Values for /datum/preference/savefile_identifier +/// This preference is character specific. +#define PREFERENCE_CHARACTER "character" +/// This preference is account specific. +#define PREFERENCE_PLAYER "player" + +// Values for /datum/preferences/current_tab +/// Open the character preference window +#define PREFERENCE_TAB_CHARACTER_PREFERENCES 0 + +/// Open the game preferences window +#define PREFERENCE_TAB_GAME_PREFERENCES 1 + +/// Open the keybindings window +#define PREFERENCE_TAB_KEYBINDINGS 2 + +/// 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" diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 7f1be58a1e64..4990d0b8958a 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -133,11 +133,12 @@ #define INIT_ORDER_MATERIALS 76 #define INIT_ORDER_RESEARCH 75 #define INIT_ORDER_STATION 74 +#define INIT_ORDER_QUIRKS 73 #define INIT_ORDER_EVENTS 70 #define INIT_ORDER_MAPPING 65 #define INIT_ORDER_JOBS 60 -#define INIT_ORDER_QUIRKS 55 #define INIT_ORDER_TICKER 50 +#define INIT_ORDER_EARLY_ASSETS 48 #define INIT_ORDER_NETWORKS 45 #define INIT_ORDER_ECONOMY 40 #define INIT_ORDER_OUTPUTS 35 @@ -148,7 +149,8 @@ #define INIT_ORDER_TIMER 1 #define INIT_ORDER_DEFAULT 0 #define INIT_ORDER_AIR -1 -#define INIT_ORDER_PERSISTENCE -2 //before assets because some assets take data from SSPersistence +#define INIT_ORDER_PERSISTENCE -2 +#define INIT_ORDER_PERSISTENT_PAINTINGS -3 // Assets relies on this #define INIT_ORDER_ASSETS -4 #define INIT_ORDER_ICON_SMOOTHING -5 #define INIT_ORDER_OVERLAY -6 @@ -160,7 +162,7 @@ #define INIT_ORDER_PATH -50 #define INIT_ORDER_DISCORD -60 #define INIT_ORDER_EXPLOSIONS -69 -#define INIT_ORDER_STATPANELS -98 +#define INIT_ORDER_STATPANELS -98 #define INIT_ORDER_DEMO -99 // To avoid a bunch of changes related to initialization being written, do this last #define INIT_ORDER_CHAT -100 //Should be last to ensure chat remains smooth during init. @@ -188,6 +190,7 @@ #define FIRE_PRIORITY_PARALLAX 65 #define FIRE_PRIORITY_INSTRUMENTS 80 #define FIRE_PRIORITY_MOBS 100 +#define FIRE_PRIORITY_ASSETS 105 #define FIRE_PRIORITY_TGUI 110 #define FIRE_PRIORITY_TICKER 200 #define FIRE_PRIORITY_ATMOS_ADJACENCY 300 diff --git a/code/__HELPERS/_lists.dm b/code/__HELPERS/_lists.dm index d4ec01d8833f..722e68d051e6 100644 --- a/code/__HELPERS/_lists.dm +++ b/code/__HELPERS/_lists.dm @@ -698,11 +698,12 @@ else L1[key] = other_value -/proc/assoc_list_strip_value(list/input) - var/list/ret = list() +/// Turns an associative list into a flat list of keys +/proc/assoc_to_keys(list/input) + var/list/keys = list() for(var/key in input) - ret += key - return ret + keys += key + return keys /proc/compare_list(list/l,list/d) if(!islist(l) || !islist(d)) diff --git a/code/__HELPERS/colors.dm b/code/__HELPERS/colors.dm new file mode 100644 index 000000000000..4a15f36ef6ff --- /dev/null +++ b/code/__HELPERS/colors.dm @@ -0,0 +1,18 @@ +/// Given a color in the format of "#RRGGBB", will return if the color +/// is dark. +/proc/is_color_dark(color, threshold = 25) + var/hsl = rgb2num(color, COLORSPACE_HSL) + return hsl[3] < threshold + +/// Given a 3 character color (no hash), converts it into #RRGGBB (with hash) +/proc/expand_three_digit_color(color) + if (length_char(color) != 3) + CRASH("Invalid 3 digit color: [color]") + + var/final_color = "#" + + for (var/digit = 1 to 3) + final_color += copytext(color, digit, digit + 1) + final_color += copytext(color, digit, digit + 1) + + return final_color diff --git a/code/__HELPERS/mobs.dm b/code/__HELPERS/mobs.dm index 834cf6dc80a9..e51181eb1d7d 100644 --- a/code/__HELPERS/mobs.dm +++ b/code/__HELPERS/mobs.dm @@ -200,7 +200,7 @@ /proc/random_skin_tone() return pick(GLOB.skin_tones) -GLOBAL_LIST_INIT(skin_tones, list( +GLOBAL_LIST_INIT(skin_tones, sortList(list( "albino", "caucasian1", "caucasian2", @@ -213,7 +213,22 @@ GLOBAL_LIST_INIT(skin_tones, list( "indian", "african1", "african2" - )) + ))) + +GLOBAL_LIST_INIT(skin_tone_names, list( + "african1" = "Medium brown", + "african2" = "Dark brown", + "albino" = "Albino", + "arab" = "Light brown", + "asian1" = "Ivory", + "asian2" = "Beige", + "caucasian1" = "Porcelain", + "caucasian2" = "Light peach", + "caucasian3" = "Peach", + "indian" = "Brown", + "latino" = "Light beige", + "mediterranean" = "Olive", +)) GLOBAL_LIST_EMPTY(species_list) diff --git a/code/__HELPERS/sanitize_values.dm b/code/__HELPERS/sanitize_values.dm index b91f99c142b3..e3403250baa5 100644 --- a/code/__HELPERS/sanitize_values.dm +++ b/code/__HELPERS/sanitize_values.dm @@ -6,6 +6,13 @@ return number return default +/proc/sanitize_float(number, min=0, max=1, accuracy=1, default=0) + if(isnum(number)) + number = round(number, accuracy) + if(min <= number && number <= max) + return number + return default + /proc/sanitize_text(text, default="") if(istext(text)) return text @@ -79,7 +86,7 @@ return crunch + . -/proc/sanitize_ooccolor(color) +/proc/sanitize_color(color) if(length(color) != length_char(color)) CRASH("Invalid characters in color '[color]'") var/list/HSL = rgb2hsl(hex2num(copytext(color, 2, 4)), hex2num(copytext(color, 4, 6)), hex2num(copytext(color, 6, 8))) diff --git a/code/__HELPERS/text.dm b/code/__HELPERS/text.dm index 91dc36234382..8df388f21658 100644 --- a/code/__HELPERS/text.dm +++ b/code/__HELPERS/text.dm @@ -841,3 +841,8 @@ GLOBAL_LIST_INIT(binary, list("0","1")) #define is_alpha(X) ((text2ascii(X) <= 122) && (text2ascii(X) >= 97)) #define is_digit(X) ((length(X) == 1) && (length(text2num(X)) == 1)) + +/// Removes all non-alphanumerics from the text, keep in mind this can lead to id conflicts +/proc/sanitize_css_class_name(name) + var/static/regex/regex = new(@"[^a-zA-Z0-9]","g") + return replacetext(name, regex, "") diff --git a/code/_globalvars/_regexes.dm b/code/_globalvars/_regexes.dm new file mode 100644 index 000000000000..59f468dcf022 --- /dev/null +++ b/code/_globalvars/_regexes.dm @@ -0,0 +1,3 @@ +//These are a bunch of regex datums for use /((any|every|no|some|head|foot)where(wolf)?\sand\s)+(\.[\.\s]+\s?where\?)?/i + +GLOBAL_DATUM_INIT(is_color, /regex, regex("^#\[0-9a-fA-F]{6}$")) diff --git a/code/_globalvars/lists/client.dm b/code/_globalvars/lists/client.dm index 5181d870c4f9..0e1ed245c2be 100644 --- a/code/_globalvars/lists/client.dm +++ b/code/_globalvars/lists/client.dm @@ -1,21 +1,2 @@ -GLOBAL_LIST_EMPTY(classic_keybinding_list_by_key) -GLOBAL_LIST_EMPTY(hotkey_keybinding_list_by_key) +GLOBAL_LIST_EMPTY(default_hotkeys) GLOBAL_LIST_EMPTY(keybindings_by_name) - -// This is a mapping from JS keys to Byond - ref: https://keycode.info/ -GLOBAL_LIST_INIT(_kbMap, list( - "UP" = "North", - "RIGHT" = "East", - "DOWN" = "South", - "LEFT" = "West", - "INSERT" = "Insert", - "HOME" = "Northwest", - "PAGEUP" = "Northeast", - "DEL" = "Delete", - "END" = "Southwest", - "PAGEDOWN" = "Southeast", - "SPACEBAR" = "Space", - "ALT" = "Alt", - "SHIFT" = "Shift", - "CONTROL" = "Ctrl" - )) diff --git a/code/_globalvars/lists/flavor_misc.dm b/code/_globalvars/lists/flavor_misc.dm index 403c183156a1..bb754efa8068 100644 --- a/code/_globalvars/lists/flavor_misc.dm +++ b/code/_globalvars/lists/flavor_misc.dm @@ -100,7 +100,11 @@ GLOBAL_LIST_INIT(ai_core_display_screens, list( "Triumvirate-M", "Weird")) -/proc/resolve_ai_icon(input) +/// A form of resolve_ai_icon that is guaranteed to never sleep. +/// Not always accurate, but always synchronous. +/proc/resolve_ai_icon_sync(input) + SHOULD_NOT_SLEEP(TRUE) + if(!input || !(input in GLOB.ai_core_display_screens)) return "ai" else @@ -112,6 +116,14 @@ GLOBAL_LIST_INIT(ai_core_display_screens, list( return "ai-portrait" //just take this until they decide return "ai-[lowertext(input)]" +/proc/resolve_ai_icon(input) + if (input == "Portrait") + var/datum/portrait_picker/tgui = new(usr)//create the datum + tgui.ui_interact(usr)//datum has a tgui component, here we open the window + return "ai-portrait" //just take this until they decide + + return resolve_ai_icon_sync(input) + GLOBAL_LIST_INIT(security_depts_prefs, list(SEC_DEPT_RANDOM, SEC_DEPT_NONE, SEC_DEPT_ENGINEERING, SEC_DEPT_MEDICAL, SEC_DEPT_SCIENCE, SEC_DEPT_SUPPLY, SEC_DEPT_SERVICE)) GLOBAL_LIST_INIT(engineering_depts_prefs, list(ENG_DEPT_RANDOM, ENG_DEPT_NONE, ENG_DEPT_MEDICAL, ENG_DEPT_SCIENCE, ENG_DEPT_SUPPLY, ENG_DEPT_SERVICE)) diff --git a/code/_globalvars/lists/keybindings.dm b/code/_globalvars/lists/keybindings.dm index a3826af38dad..8e4ff0ab949d 100644 --- a/code/_globalvars/lists/keybindings.dm +++ b/code/_globalvars/lists/keybindings.dm @@ -11,15 +11,13 @@ /proc/add_keybinding(datum/keybinding/instance) GLOB.keybindings_by_name[instance.name] = instance - // Classic - if(LAZYLEN(instance.classic_keys)) - for(var/bound_key in instance.classic_keys) - LAZYADD(GLOB.classic_keybinding_list_by_key[bound_key], list(instance.name)) - // Hotkey if(LAZYLEN(instance.hotkey_keys)) for(var/bound_key in instance.hotkey_keys) - LAZYADD(GLOB.hotkey_keybinding_list_by_key[bound_key], list(instance.name)) + if (bound_key == "Unbound") + LAZYADD(GLOB.default_hotkeys[instance.name], list()) + else + LAZYADD(GLOB.default_hotkeys[instance.name], list(bound_key)) /proc/init_emote_keybinds() for(var/i in subtypesof(/datum/emote)) diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm index e11ac7ec7622..f8058ef4f874 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -171,8 +171,6 @@ key_mode = KEY_MODE_TEXT value_mode = VALUE_MODE_FLAG -/datum/config_entry/flag/join_with_mutant_humans //players can pick mutant bodyparts for humans before joining the game - /datum/config_entry/flag/no_intercept_report //Whether or not to send a communications intercept report roundstart. This may be overridden by gamemodes. /datum/config_entry/number/arrivals_shuttle_dock_window //Time from when a player late joins on the arrivals shuttle to when the shuttle docks on the station diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 7aa0006c13d1..5bfca7831a29 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -531,3 +531,6 @@ /// logs all timers in buckets on automatic bucket reset (Useful for timer debugging) /datum/config_entry/flag/log_timers_on_bucket_reset + +/datum/config_entry/flag/cache_assets + default = TRUE diff --git a/code/controllers/subsystem/asset_loading.dm b/code/controllers/subsystem/asset_loading.dm new file mode 100644 index 000000000000..1525b7366eed --- /dev/null +++ b/code/controllers/subsystem/asset_loading.dm @@ -0,0 +1,19 @@ +/// Allows us to lazyload asset datums +/// Anything inserted here will fully load if directly gotten +/// So this just serves to remove the requirement to load assets fully during init +SUBSYSTEM_DEF(asset_loading) + name = "Asset Loading" + priority = FIRE_PRIORITY_ASSETS + flags = SS_NO_INIT + runlevels = RUNLEVEL_LOBBY|RUNLEVELS_DEFAULT + var/list/datum/asset/generate_queue = list() + +/datum/controller/subsystem/asset_loading/fire(resumed) + while(length(generate_queue)) + var/datum/asset/to_load = generate_queue[generate_queue.len] + + to_load.queued_generation() + + if(MC_TICK_CHECK) + return + generate_queue.len-- diff --git a/code/controllers/subsystem/assets.dm b/code/controllers/subsystem/assets.dm index 9626c9eda0f8..485433048e4e 100644 --- a/code/controllers/subsystem/assets.dm +++ b/code/controllers/subsystem/assets.dm @@ -5,7 +5,7 @@ SUBSYSTEM_DEF(assets) loading_points = 3 SECONDS // Yogs -- loading times - var/list/cache = list() + var/list/datum/asset_cache_item/cache = list() var/list/preload = list() var/datum/asset_transport/transport = new() @@ -29,7 +29,7 @@ SUBSYSTEM_DEF(assets) for(var/type in typesof(/datum/asset)) var/datum/asset/A = type if (type != initial(A._abstract)) - get_asset_datum(type) + load_asset_datum(type) transport.Initialize(cache) diff --git a/code/controllers/subsystem/atoms.dm b/code/controllers/subsystem/atoms.dm index 276554112cf7..625317c23513 100644 --- a/code/controllers/subsystem/atoms.dm +++ b/code/controllers/subsystem/atoms.dm @@ -19,6 +19,9 @@ SUBSYSTEM_DEF(atoms) var/init_start_time + /// Atoms that will be deleted once the subsystem is initialized + var/list/queued_deletions = list() + initialized = INITIALIZATION_INSSATOMS /datum/controller/subsystem/atoms/Initialize(timeofday) @@ -51,6 +54,12 @@ SUBSYSTEM_DEF(atoms) A.LateInitialize() testing("Late initialized [late_loaders.len] atoms") late_loaders.Cut() + + for (var/queued_deletion in queued_deletions) + qdel(queued_deletion) + + testing("[queued_deletions.len] atoms were queued for deletion.") + queued_deletions.Cut() /// Actually creates the list of atoms. Exists soley so a runtime in the creation logic doesn't cause initalized to totally break /datum/controller/subsystem/atoms/proc/CreateAtoms(list/atoms) @@ -191,6 +200,14 @@ SUBSYSTEM_DEF(atoms) if(fails & BAD_INIT_SLEPT) . += "- Slept during Initialize()\n" +/// Prepares an atom to be deleted once the atoms SS is initialized. +/datum/controller/subsystem/atoms/proc/prepare_deletion(atom/target) + if (initialized == INITIALIZATION_INNEW_REGULAR) + // Atoms SS has already completed, just kill it now. + qdel(target) + else + queued_deletions += WEAKREF(target) + /datum/controller/subsystem/atoms/Shutdown() var/initlog = InitLog() if(initlog) diff --git a/code/controllers/subsystem/early_assets.dm b/code/controllers/subsystem/early_assets.dm new file mode 100644 index 000000000000..5ce669bec83b --- /dev/null +++ b/code/controllers/subsystem/early_assets.dm @@ -0,0 +1,24 @@ +/// Initializes any assets that need to be loaded ASAP. +/// This houses preference menu assets, since they can be loaded at any time, +/// most dangerously before the atoms SS initializes. +/// Thus, we want it to fail consistently in CI as if it would've if a player +/// opened it up early. +SUBSYSTEM_DEF(early_assets) + name = "Early Assets" + init_order = INIT_ORDER_EARLY_ASSETS + flags = SS_NO_FIRE + +/datum/controller/subsystem/early_assets/Initialize() + for (var/datum/asset/asset_type as anything in subtypesof(/datum/asset)) + if (initial(asset_type._abstract) == asset_type) + continue + + if (!initial(asset_type.early)) + continue + + if (!load_asset_datum(asset_type)) + stack_trace("Could not initialize early asset [asset_type]!") + + CHECK_TICK + + return SS_INIT_SUCCESS diff --git a/code/controllers/subsystem/job.dm b/code/controllers/subsystem/job.dm index f1cd88fa4210..d8edaa8f6927 100644 --- a/code/controllers/subsystem/job.dm +++ b/code/controllers/subsystem/job.dm @@ -22,7 +22,6 @@ SUBSYSTEM_DEF(job) SetupOccupations() if(CONFIG_GET(flag/load_jobs_from_txt)) LoadJobs() - generate_selectable_species() set_overflow_role(CONFIG_GET(string/overflow_job)) return SS_INIT_SUCCESS @@ -74,6 +73,7 @@ SUBSYSTEM_DEF(job) return name_occupations[rank] /datum/controller/subsystem/job/proc/GetJobType(jobtype) + RETURN_TYPE(/datum/job) if(!occupations.len) SetupOccupations() return type_occupations[jobtype] @@ -436,26 +436,33 @@ SUBSYSTEM_DEF(job) //We couldn't find a job from prefs for this guy. /datum/controller/subsystem/job/proc/HandleUnassigned(mob/dead/new_player/player) + var/jobless_role = player.client.prefs.read_preference(/datum/preference/choiced/jobless_role) + if(PopcapReached()) RejectPlayer(player) - else if(player.client.prefs.joblessrole == BEOVERFLOW) - var/allowed_to_be_a_loser = !is_banned_from(player.ckey, SSjob.overflow_role) - if(QDELETED(player) || !allowed_to_be_a_loser) - RejectPlayer(player) - else - if(!AssignRole(player, SSjob.overflow_role)) + return + + switch (jobless_role) + if (BEOVERFLOW) + var/datum/job/overflow_role_datum = GetJobType(overflow_role) + var/allowed_to_be_a_loser = !is_banned_from(player.ckey, overflow_role_datum.title) + if(QDELETED(player) || !allowed_to_be_a_loser) RejectPlayer(player) - else if(player.client.prefs.joblessrole == BERANDOMJOB) - if(!GiveRandomJob(player)) + else + if(!AssignRole(player, overflow_role_datum)) + RejectPlayer(player) + if (BERANDOMJOB) + if(!GiveRandomJob(player)) + RejectPlayer(player) + if (RETURNTOLOBBY) RejectPlayer(player) - else if(player.client.prefs.joblessrole == RETURNTOLOBBY) - RejectPlayer(player) - else //Something gone wrong if we got here. - var/message = "DO: [player] fell through handling unassigned" - JobDebug(message) - log_game(message) - message_admins(message) - RejectPlayer(player) + else //Something gone wrong if we got here. + var/message = "DO: [player] fell through handling unassigned" + JobDebug(message) + log_game(message) + message_admins(message) + RejectPlayer(player) + //Gives the player the stuff he should have with his rank /datum/controller/subsystem/job/proc/EquipRank(mob/M, rank, joined_late = FALSE) var/mob/dead/new_player/newplayer diff --git a/code/controllers/subsystem/mapping.dm b/code/controllers/subsystem/mapping.dm index db7d236fc32c..c1c67956476a 100644 --- a/code/controllers/subsystem/mapping.dm +++ b/code/controllers/subsystem/mapping.dm @@ -368,7 +368,7 @@ GLOBAL_LIST_EMPTY(the_station_areas) var/pmv = CONFIG_GET(flag/preference_map_voting) if(pmv) for (var/client/c in GLOB.clients) - var/vote = c.prefs.preferred_map + var/vote = c.prefs.read_preference(/datum/preference/choiced/preferred_map) if (!vote) if (global.config.defaultmap) mapvotes[global.config.defaultmap.map_name] += 1 diff --git a/code/controllers/subsystem/persistence.dm b/code/controllers/subsystem/persistence.dm index 98724529cf94..a717069492b5 100644 --- a/code/controllers/subsystem/persistence.dm +++ b/code/controllers/subsystem/persistence.dm @@ -14,8 +14,6 @@ SUBSYSTEM_DEF(persistence) var/list/picture_logging_information = list() var/list/obj/structure/sign/picture_frame/photo_frames = list() var/list/obj/item/storage/photo_album/photo_albums = list() - var/list/obj/structure/sign/painting/painting_frames = list() - var/list/paintings = list() /datum/controller/subsystem/persistence/Initialize() LoadPoly() @@ -26,7 +24,6 @@ SUBSYSTEM_DEF(persistence) if(CONFIG_GET(flag/use_antag_rep)) LoadAntagReputation() LoadRandomizedRecipes() - LoadPaintings() return SS_INIT_SUCCESS /datum/controller/subsystem/persistence/proc/LoadPoly() @@ -151,7 +148,6 @@ SUBSYSTEM_DEF(persistence) if(CONFIG_GET(flag/use_antag_rep)) CollectAntagReputation() SaveRandomizedRecipes() - SavePaintings() SaveScars() /datum/controller/subsystem/persistence/proc/GetPhotoAlbums() @@ -332,22 +328,6 @@ SUBSYSTEM_DEF(persistence) fdel(json_file) WRITE_FILE(json_file, json_encode(file_data)) -/datum/controller/subsystem/persistence/proc/LoadPaintings() - var/json_file = file("data/paintings.json") - if(fexists(json_file)) - paintings = json_decode(file2text(json_file)) - - for(var/obj/structure/sign/painting/P in painting_frames) - P.load_persistent() - -/datum/controller/subsystem/persistence/proc/SavePaintings() - for(var/obj/structure/sign/painting/P in painting_frames) - P.save_persistent() - - var/json_file = file("data/paintings.json") - fdel(json_file) - WRITE_FILE(json_file, json_encode(paintings)) - /datum/controller/subsystem/persistence/proc/SaveScars() for(var/i in GLOB.joined_player_list) var/mob/living/carbon/human/ending_human = get_mob_by_ckey(i) diff --git a/code/controllers/subsystem/persistent_paintings.dm b/code/controllers/subsystem/persistent_paintings.dm new file mode 100644 index 000000000000..6e6c2b1c6279 --- /dev/null +++ b/code/controllers/subsystem/persistent_paintings.dm @@ -0,0 +1,29 @@ +SUBSYSTEM_DEF(persistent_paintings) + name = "Persistent Paintings" + init_order = INIT_ORDER_PERSISTENT_PAINTINGS + flags = SS_NO_FIRE + + /// A list of painting frames that this controls + var/list/obj/structure/sign/painting/painting_frames = list() + + /// A map of identifiers (such as library) to paintings from paintings.json + var/list/paintings = list() + +/datum/controller/subsystem/persistent_paintings/Initialize(start_timeofday) + var/json_file = file("data/paintings.json") + if(fexists(json_file)) + paintings = json_decode(file2text(json_file)) + + for(var/obj/structure/sign/painting/painting_frame as anything in painting_frames) + painting_frame.load_persistent() + + return SS_INIT_SUCCESS + +/// Saves all persistent paintings +/datum/controller/subsystem/persistent_paintings/proc/save_paintings() + for(var/obj/structure/sign/painting/painting_frame as anything in painting_frames) + painting_frame.save_persistent() + + var/json_file = file("data/paintings.json") + fdel(json_file) + WRITE_FILE(json_file, json_encode(paintings)) diff --git a/code/controllers/subsystem/processing/quirks.dm b/code/controllers/subsystem/processing/quirks.dm index 793554bd2432..cf440e076753 100644 --- a/code/controllers/subsystem/processing/quirks.dm +++ b/code/controllers/subsystem/processing/quirks.dm @@ -57,3 +57,64 @@ PROCESSING_SUBSYSTEM_DEF(quirks) badquirk = TRUE if(badquirk) cli.prefs.save_character() + +/// Takes a list of quirk names and returns a new list of quirks that would +/// be valid. +/// If no changes need to be made, will return the same list. +/// Expects all quirk names to be unique, but makes no other expectations. +/datum/controller/subsystem/processing/quirks/proc/filter_invalid_quirks(list/quirks) + var/list/new_quirks = list() + var/list/positive_quirks = list() + var/balance = 0 + + for (var/quirk_name in quirks) + var/datum/quirk/quirk = SSquirks.quirks[quirk_name] + if (isnull(quirk)) + continue + + if (initial(quirk.mood_quirk) && CONFIG_GET(flag/disable_human_mood)) + continue + + var/blacklisted = FALSE + + for (var/list/blacklist as anything in quirk_blacklist) + if (!(quirk in blacklist)) + continue + + for (var/other_quirk in blacklist) + if (other_quirk in new_quirks) + blacklisted = TRUE + break + + if (blacklisted) + break + + if (blacklisted) + continue + + var/value = initial(quirk.value) + if (value > 0) + if (positive_quirks.len == MAX_QUIRKS) + continue + + positive_quirks[quirk_name] = value + + balance += value + new_quirks += quirk_name + + if (balance > 0) + var/balance_left_to_remove = balance + + for (var/positive_quirk in positive_quirks) + var/value = positive_quirks[positive_quirk] + balance_left_to_remove -= value + new_quirks -= positive_quirk + + if (balance_left_to_remove <= 0) + break + + // It is guaranteed that if no quirks are invalid, you can simply check through `==` + if (new_quirks.len == quirks.len) + return quirks + + return new_quirks diff --git a/code/controllers/subsystem/vote.dm b/code/controllers/subsystem/vote.dm index 35ab0c4671eb..d129066cb14d 100644 --- a/code/controllers/subsystem/vote.dm +++ b/code/controllers/subsystem/vote.dm @@ -69,7 +69,7 @@ SUBSYSTEM_DEF(vote) else if(mode == "map") for (var/non_voter_ckey in non_voters) var/client/C = non_voters[non_voter_ckey] - var/preferred_map = C.prefs.preferred_map + var/preferred_map = C.prefs.read_preference(/datum/preference/choiced/preferred_map) if(isnull(global.config.defaultmap)) continue if(!preferred_map) diff --git a/code/datums/traits/neutral.dm b/code/datums/traits/neutral.dm index aa64d7baf7bf..dde52c1891e1 100644 --- a/code/datums/traits/neutral.dm +++ b/code/datums/traits/neutral.dm @@ -124,7 +124,7 @@ var/mob/living/carbon/human/H = quirk_holder if(!H.mind.accent_name) H.mind.RegisterSignal(H, COMSIG_MOB_SAY, /datum/mind/.proc/handle_speech) - H.mind.accent_name = pick(assoc_list_strip_value(GLOB.accents_name2file))// Right now this pick just picks a straight random one from all implemented. + H.mind.accent_name = pick(assoc_to_keys(GLOB.accents_name2file))// Right now this pick just picks a straight random one from all implemented. /datum/quirk/colorist name = "Colorist" diff --git a/code/game/objects/structures/artstuff.dm b/code/game/objects/structures/artstuff.dm index 02bb9607c5b2..32a0f55ded1d 100644 --- a/code/game/objects/structures/artstuff.dm +++ b/code/game/objects/structures/artstuff.dm @@ -247,7 +247,7 @@ /obj/structure/sign/painting/Initialize(mapload, dir, building) . = ..() - SSpersistence.painting_frames += src + SSpersistent_paintings.painting_frames += src AddComponent(/datum/component/art, 20) if(dir) setDir(dir) @@ -257,7 +257,7 @@ /obj/structure/sign/painting/Destroy() . = ..() - SSpersistence.painting_frames -= src + SSpersistent_paintings.painting_frames -= src /obj/structure/sign/painting/attackby(obj/item/I, mob/user, params) if(!C && istype(I, /obj/item/canvas)) @@ -321,12 +321,12 @@ * Deleting paintings leaves their json, so this proc will remove the json and try again if it finds one of those. */ /obj/structure/sign/painting/proc/load_persistent() - if(!persistence_id || !SSpersistence.paintings || !SSpersistence.paintings[persistence_id]) + if(!persistence_id || !SSpersistent_paintings.paintings[persistence_id]) return - var/list/painting_category = SSpersistence.paintings[persistence_id] + var/list/painting_category = SSpersistent_paintings.paintings[persistence_id] var/list/painting while(!painting) - if(!length(SSpersistence.paintings[persistence_id])) + if(!length(SSpersistent_paintings.paintings[persistence_id])) return //aborts loading anything this category has no usable paintings var/list/chosen = pick(painting_category) if(!fexists("data/paintings/[persistence_id]/[chosen["md5"]].png")) //shitmin deleted this art, lets remove json entry to avoid errors @@ -368,7 +368,7 @@ C.painting_name = "Untitled Artwork" var/data = C.get_data_string() var/md5 = md5(lowertext(data)) - var/list/current = SSpersistence.paintings[persistence_id] + var/list/current = SSpersistent_paintings.paintings[persistence_id] if(!current) current = list() for(var/list/entry in current) @@ -380,7 +380,7 @@ if(result) CRASH("Error saving persistent painting: [result]") current += list(list("title" = C.painting_name , "md5" = md5, "ckey" = C.author_ckey)) - SSpersistence.paintings[persistence_id] = current + SSpersistent_paintings.paintings[persistence_id] = current /obj/item/canvas/proc/fill_grid_from_icon(icon/I) var/h = I.Height() + 1 @@ -410,15 +410,15 @@ return var/md5 = md5(C.get_data_string()) var/author = C.author_ckey - var/list/current = SSpersistence.paintings[persistence_id] + var/list/current = SSpersistent_paintings.paintings[persistence_id] if(current) for(var/list/entry in current) if(entry["md5"] == md5) current -= entry var/png = "data/paintings/[persistence_id]/[md5].png" fdel(png) - for(var/obj/structure/sign/painting/PA in SSpersistence.painting_frames) - if(PA.C && md5(PA.C.get_data_string()) == md5) - QDEL_NULL(PA.C) + for(var/obj/structure/sign/painting/painting in SSpersistent_paintings.painting_frames) + if(painting.C && md5(painting.C.get_data_string()) == md5) + QDEL_NULL(painting.C) log_admin("[key_name(user)] has deleted a persistent painting made by [author].") message_admins(span_notice("[key_name_admin(user)] has deleted persistent painting made by [author].")) diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index be65edc8a07c..accce5184121 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -12,9 +12,6 @@ GLOBAL_PROTECT(admin_verbs_default) /client/verb/dsay, /*talk in deadchat using our ckey/fakekey*/ /client/proc/investigate_show, /*various admintools for investigation. Such as a singulo grief-log*/ /client/proc/secrets, - /client/proc/toggle_split_admin_tabs, - /client/proc/toggle_fast_mc_refresh, - /client/proc/toggle_hear_radio, /*allows admins to hide all radio output*/ /client/proc/reload_admins, /client/proc/reload_mentors, /client/proc/reestablish_db_connection, /*reattempt a connection to the database*/ @@ -78,12 +75,6 @@ GLOBAL_PROTECT(admin_verbs_admin) /client/proc/toggle_combo_hud, // toggle display of the combination pizza antag and taco sci/med/eng hud /client/proc/toggle_AI_interact, /*toggle admin ability to interact with machines as an AI*/ /datum/admins/proc/open_shuttlepanel, /* Opens shuttle manipulator UI */ - /client/proc/deadchat, - /client/proc/toggleprayers, - /client/proc/toggle_prayer_sound, - /client/proc/colorasay, - /client/proc/resetasaycolor, - /client/proc/toggleadminhelpsound, /client/proc/respawn_character, /client/proc/discord_id_manipulation, /datum/admins/proc/open_borgopanel, diff --git a/code/modules/antagonists/_common/antag_datum.dm b/code/modules/antagonists/_common/antag_datum.dm index 1d10ee85a68a..4cbb1811b0a8 100644 --- a/code/modules/antagonists/_common/antag_datum.dm +++ b/code/modules/antagonists/_common/antag_datum.dm @@ -25,6 +25,8 @@ GLOBAL_LIST_EMPTY(antagonists) var/show_to_ghosts = FALSE // Should this antagonist be shown as antag to ghosts? Shouldn't be used for stealthy antagonists like traitors /// The corporation employing us var/datum/corporation/company + /// The typepath for the outfit to show in the preview for the preferences menu. + var/preview_outfit /datum/antagonist/New() @@ -220,6 +222,42 @@ GLOBAL_LIST_EMPTY(antagonists) return FALSE return TRUE +/// Creates an icon from the preview outfit. +/// Custom implementors of `get_preview_icon` should use this, as the +/// result of `get_preview_icon` is expected to be the completed version. +/datum/antagonist/proc/render_preview_outfit(datum/outfit/outfit, mob/living/carbon/human/dummy) + dummy = dummy || new /mob/living/carbon/human/dummy/consistent + dummy.equipOutfit(outfit, visualsOnly = TRUE) + COMPILE_OVERLAYS(dummy) + var/icon = getFlatIcon(dummy) + + // We don't want to qdel the dummy right away, since its items haven't initialized yet. + SSatoms.prepare_deletion(dummy) + + return icon + +/// Given an icon, will crop it to be consistent of those in the preferences menu. +/// Not necessary, and in fact will look bad if it's anything other than a human. +/datum/antagonist/proc/finish_preview_icon(icon/icon) + // Zoom in on the top of the head and the chest + // I have no idea how to do this dynamically. + icon.Scale(115, 115) + + // This is probably better as a Crop, but I cannot figure it out. + icon.Shift(WEST, 8) + icon.Shift(SOUTH, 30) + + icon.Crop(1, 1, ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE) + + return icon + +/// Returns the icon to show on the preferences menu. +/datum/antagonist/proc/get_preview_icon() + if (isnull(preview_outfit)) + return null + + return finish_preview_icon(render_preview_outfit(preview_outfit)) + // List if ["Command"] = CALLBACK(), user will be appeneded to callback arguments on execution /datum/antagonist/proc/get_admin_commands() . = list() diff --git a/code/modules/antagonists/traitor/datum_traitor.dm b/code/modules/antagonists/traitor/datum_traitor.dm index d29de4a36f35..0797133f1411 100644 --- a/code/modules/antagonists/traitor/datum_traitor.dm +++ b/code/modules/antagonists/traitor/datum_traitor.dm @@ -7,6 +7,7 @@ antagpanel_category = "Traitor" job_rank = ROLE_TRAITOR antag_moodlet = /datum/mood_event/focused + preview_outfit = /datum/outfit/traitor var/special_role = ROLE_TRAITOR var/employer = "The Syndicate" var/give_objectives = TRUE @@ -452,6 +453,21 @@ return message - /datum/antagonist/traitor/is_gamemode_hero() return SSticker.mode.name == "traitor" + +/datum/outfit/traitor + name = "Traitor (Preview only)" + + uniform = /obj/item/clothing/under/color/grey + suit = /obj/item/clothing/suit/hooded + gloves = /obj/item/clothing/gloves/color/yellow + mask = /obj/item/clothing/mask/gas + l_hand = /obj/item/melee/transforming/energy/sword + r_hand = /obj/item/gun/energy/kinetic_accelerator/crossbow + +/datum/outfit/traitor/post_equip(mob/living/carbon/human/H, visualsOnly) + var/obj/item/melee/transforming/energy/sword/sword = locate() in H.held_items + sword.icon_state = "e_sword_on_red" + + H.update_inv_hands() diff --git a/code/modules/asset_cache/asset_list.dm b/code/modules/asset_cache/asset_list.dm index 221febbe14d4..e316d07cdf6a 100644 --- a/code/modules/asset_cache/asset_list.dm +++ b/code/modules/asset_cache/asset_list.dm @@ -1,3 +1,4 @@ +#define ASSET_CROSS_ROUND_CACHE_DIRECTORY "tmp/assets" //These datums are used to populate the asset cache, the proc "register()" does this. //Place any asset datums you create in asset_list_items.dm @@ -6,25 +7,60 @@ GLOBAL_LIST_EMPTY(asset_datums) //get an assetdatum or make a new one -/proc/get_asset_datum(type) +//does NOT ensure it's filled, if you want that use get_asset_datum() +/proc/load_asset_datum(type) return GLOB.asset_datums[type] || new type() +/proc/get_asset_datum(type) + var/datum/asset/loaded_asset = GLOB.asset_datums[type] || new type() + return loaded_asset.ensure_ready() + /datum/asset var/_abstract = /datum/asset + var/cached_serialized_url_mappings + var/cached_serialized_url_mappings_transport_type + + /// Whether or not this asset should be loaded in the "early assets" SS + var/early = FALSE + + /// Whether or not this asset can be cached across rounds of the same commit under the `CACHE_ASSETS` config. + /// This is not a *guarantee* the asset will be cached. Not all asset subtypes respect this field, and the + /// config can, of course, be disabled. + var/cross_round_cachable = FALSE /datum/asset/New() GLOB.asset_datums[type] = src register() +/// Stub that allows us to react to something trying to get us +/// Not useful here, more handy for sprite sheets +/datum/asset/proc/ensure_ready() + return src + +/// Stub to hook into if your asset is having its generation queued by SSasset_loading +/datum/asset/proc/queued_generation() + CRASH("[type] inserted into SSasset_loading despite not implementing /proc/queued_generation") + /datum/asset/proc/get_url_mappings() return list() +/// Returns a cached tgui message of URL mappings +/datum/asset/proc/get_serialized_url_mappings() + if (isnull(cached_serialized_url_mappings) || cached_serialized_url_mappings_transport_type != SSassets.transport.type) + cached_serialized_url_mappings = TGUI_CREATE_MESSAGE("asset/mappings", get_url_mappings()) + cached_serialized_url_mappings_transport_type = SSassets.transport.type + + return cached_serialized_url_mappings + /datum/asset/proc/register() return /datum/asset/proc/send(client) return +/// Returns whether or not the asset should attempt to read from cache +/datum/asset/proc/should_refresh() + return !cross_round_cachable || !CONFIG_GET(flag/cache_assets) /// If you don't need anything complicated. /datum/asset/simple @@ -67,7 +103,7 @@ GLOBAL_LIST_EMPTY(asset_datums) /datum/asset/group/register() for(var/type in children) - get_asset_datum(type) + load_asset_datum(type) /datum/asset/group/send(client/C) for(var/type in children) @@ -92,12 +128,61 @@ GLOBAL_LIST_EMPTY(asset_datums) /datum/asset/spritesheet _abstract = /datum/asset/spritesheet var/name + /// List of arguments to pass into queuedInsert + /// Exists so we can queue icon insertion, mostly for stuff like preferences + var/list/to_generate = list() var/list/sizes = list() // "32x32" -> list(10, icon/normal, icon/stripped) var/list/sprites = list() // "foo_bar" -> list("32x32", 5) + var/list/cached_spritesheets_needed + var/generating_cache = FALSE + var/fully_generated = FALSE + /// If this asset should be fully loaded on new + /// Defaults to false so we can process this stuff nicely + var/load_immediately = FALSE + +/datum/asset/spritesheet/should_refresh() + if (..()) + return TRUE + + // Static so that the result is the same, even when the files are created, for this run + var/static/should_refresh = null + + if (isnull(should_refresh)) + // `fexists` seems to always fail on static-time + should_refresh = !fexists("[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name].css") + + return should_refresh /datum/asset/spritesheet/register() + SHOULD_NOT_OVERRIDE(TRUE) + if (!name) CRASH("spritesheet [type] cannot register without a name") + + if (!should_refresh() && read_from_cache()) + fully_generated = TRUE + return + + // If it's cached, may as well load it now, while the loading is cheap + if(CONFIG_GET(flag/cache_assets) && cross_round_cachable) + load_immediately = TRUE + + create_spritesheets() + if(load_immediately) + realize_spritesheets(yield = FALSE) + else + SSasset_loading.generate_queue += src + +/datum/asset/spritesheet/proc/realize_spritesheets(yield) + if(fully_generated) + return + while(length(to_generate)) + var/list/stored_args = to_generate[to_generate.len] + to_generate.len-- + queuedInsert(arglist(stored_args)) + if(yield && TICK_CHECK) + return + ensure_stripped() for(var/size_id in sizes) var/size = sizes[size_id] @@ -109,17 +194,39 @@ GLOBAL_LIST_EMPTY(asset_datums) SSassets.transport.register_asset(res_name, fcopy_rsc(fname)) fdel(fname) -/datum/asset/spritesheet/send(client/C) + if (CONFIG_GET(flag/cache_assets) && cross_round_cachable) + write_to_cache() + fully_generated = TRUE + // If we were ever in there, remove ourselves + SSasset_loading.generate_queue -= src + +/datum/asset/spritesheet/queued_generation() + realize_spritesheets(yield = TRUE) + +/datum/asset/spritesheet/ensure_ready() + if(!fully_generated) + realize_spritesheets(yield = FALSE) + return ..() + +/datum/asset/spritesheet/send(client/client) if (!name) return + + if (!should_refresh()) + return send_from_cache(client) + var/all = list("spritesheet_[name].css") for(var/size_id in sizes) all += "[name]_[size_id].png" - . = SSassets.transport.send_assets(C, all) + . = SSassets.transport.send_assets(client, all) /datum/asset/spritesheet/get_url_mappings() if (!name) return + + if (!should_refresh()) + return get_cached_url_mappings() + . = list("spritesheet_[name].css" = SSassets.transport.get_asset_url("spritesheet_[name].css")) for(var/size_id in sizes) .["[name]_[size_id].png"] = SSassets.transport.get_asset_url("[name]_[size_id].png") @@ -147,7 +254,7 @@ GLOBAL_LIST_EMPTY(asset_datums) for (var/size_id in sizes) var/size = sizes[size_id] var/icon/tiny = size[SPRSZ_ICON] - out += ".[name][size_id]{display:inline-block;width:[tiny.Width()]px;height:[tiny.Height()]px;background:url('[SSassets.transport.get_asset_url("[name]_[size_id].png")]') no-repeat;}" + out += ".[name][size_id]{display:inline-block;width:[tiny.Width()]px;height:[tiny.Height()]px;background:url('[get_background_url("[name]_[size_id].png")]') no-repeat;}" for (var/sprite_id in sprites) var/sprite = sprites[sprite_id] @@ -165,7 +272,75 @@ GLOBAL_LIST_EMPTY(asset_datums) return out.Join("\n") +/datum/asset/spritesheet/proc/read_from_cache() + var/replaced_css = file2text("[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name].css") + + var/regex/find_background_urls = regex(@"background:url\('%(.+?)%'\)", "g") + while (find_background_urls.Find(replaced_css)) + var/asset_id = find_background_urls.group[1] + var/asset_cache_item = SSassets.transport.register_asset(asset_id, "[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[asset_id]") + var/asset_url = SSassets.transport.get_asset_url(asset_cache_item = asset_cache_item) + replaced_css = replacetext(replaced_css, find_background_urls.match, "background:url('[asset_url]')") + LAZYADD(cached_spritesheets_needed, asset_id) + + var/replaced_css_filename = "data/spritesheets/spritesheet_[name].css" + rustg_file_write(replaced_css, replaced_css_filename) + SSassets.transport.register_asset("spritesheet_[name].css", replaced_css_filename) + + fdel(replaced_css_filename) + + return TRUE + +/datum/asset/spritesheet/proc/send_from_cache(client/client) + if (isnull(cached_spritesheets_needed)) + stack_trace("cached_spritesheets_needed was null when sending assets from [type] from cache") + cached_spritesheets_needed = list() + + return SSassets.transport.send_assets(client, cached_spritesheets_needed + "spritesheet_[name].css") + +/// Returns the URL to put in the background:url of the CSS asset +/datum/asset/spritesheet/proc/get_background_url(asset) + if (generating_cache) + return "%[asset]%" + else + return SSassets.transport.get_asset_url(asset) + +/datum/asset/spritesheet/proc/write_to_cache() + for (var/size_id in sizes) + fcopy(SSassets.cache["[name]_[size_id].png"].resource, "[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name]_[size_id].png") + + generating_cache = TRUE + var/mock_css = generate_css() + generating_cache = FALSE + + rustg_file_write(mock_css, "[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name].css") + +/datum/asset/spritesheet/proc/get_cached_url_mappings() + var/list/mappings = list() + mappings["spritesheet_[name].css"] = SSassets.transport.get_asset_url("spritesheet_[name].css") + + for (var/asset_name in cached_spritesheets_needed) + mappings[asset_name] = SSassets.transport.get_asset_url(asset_name) + + return mappings + +/// Override this in order to start the creation of the spritehseet. +/// This is where all your Insert, InsertAll, etc calls should be inside. +/datum/asset/spritesheet/proc/create_spritesheets() + SHOULD_CALL_PARENT(FALSE) + CRASH("create_spritesheets() not implemented for [type]!") + /datum/asset/spritesheet/proc/Insert(sprite_name, icon/I, icon_state="", dir=SOUTH, frame=1, moving=FALSE) + if(load_immediately) + queuedInsert(sprite_name, I, icon_state, dir, frame, moving) + else + to_generate += list(args.Copy()) + +// LEMON NOTE +// A GOON CODER SAYS BAD ICON ERRORS CAN BE THROWN BY THE "ICON CACHE" +// APPARENTLY IT MAKES ICONS IMMUTABLE +// LOOK INTO USING THE MUTABLE APPEARANCE PATTERN HERE +/datum/asset/spritesheet/proc/queuedInsert(sprite_name, icon/I, icon_state="", dir=SOUTH, frame=1, moving=FALSE) I = icon(I, icon_state=icon_state, dir=dir, frame=frame, moving=moving) if (!I || !length(icon_states(I))) // that direction or state doesn't exist return @@ -178,8 +353,10 @@ GLOBAL_LIST_EMPTY(asset_datums) if (size) var/position = size[SPRSZ_COUNT]++ var/icon/sheet = size[SPRSZ_ICON] + var/icon/sheet_copy = icon(sheet) size[SPRSZ_STRIPPED] = null - sheet.Insert(I, icon_state=sprite_name) + sheet_copy.Insert(I, icon_state=sprite_name) + size[SPRSZ_ICON] = sheet_copy sprites[sprite_name] = list(size_id, position) else sizes[size_id] = size = list(1, I, null) @@ -228,10 +405,9 @@ GLOBAL_LIST_EMPTY(asset_datums) _abstract = /datum/asset/spritesheet/simple var/list/assets -/datum/asset/spritesheet/simple/register() +/datum/asset/spritesheet/simple/create_spritesheets() for (var/key in assets) Insert(key, assets[key]) - ..() //Generates assets based on iconstates of a single icon /datum/asset/simple/icon_states @@ -315,3 +491,31 @@ GLOBAL_LIST_EMPTY(asset_datums) /datum/asset/simple/namespaced/proc/get_htmlloader(filename) return url2htmlloader(SSassets.transport.get_asset_url(filename, assets[filename])) + +/// A subtype to generate a JSON file from a list +/datum/asset/json + _abstract = /datum/asset/json + /// The filename, will be suffixed with ".json" + var/name + +/datum/asset/json/send(client) + return SSassets.transport.send_assets(client, "[name].json") + +/datum/asset/json/get_url_mappings() + return list( + "[name].json" = SSassets.transport.get_asset_url("[name].json"), + ) + +/datum/asset/json/register() + var/filename = "data/[name].json" + fdel(filename) + text2file(json_encode(generate()), filename) + SSassets.transport.register_asset("[name].json", fcopy_rsc(filename)) + fdel(filename) + +/// Returns the data that will be JSON encoded +/datum/asset/json/proc/generate() + SHOULD_CALL_PARENT(FALSE) + CRASH("generate() not implemented for [type]!") + +#undef ASSET_CROSS_ROUND_CACHE_DIRECTORY diff --git a/code/modules/asset_cache/asset_list_items.dm b/code/modules/asset_cache/asset_list_items.dm index 7aa087fbbdee..75cfe0aeb42d 100644 --- a/code/modules/asset_cache/asset_list_items.dm +++ b/code/modules/asset_cache/asset_list_items.dm @@ -177,7 +177,7 @@ /datum/asset/spritesheet/chat name = "chat" -/datum/asset/spritesheet/chat/register() +/datum/asset/spritesheet/chat/create_spritesheets() InsertAll("emoji", 'icons/emoji.dmi') // pre-loading all lanugage icons also helps to avoid meta InsertAll("language", 'icons/misc/language.dmi') @@ -188,7 +188,6 @@ if (icon != 'icons/misc/language.dmi') var/icon_state = initial(L.icon_state) Insert("language-[icon_state]", icon, icon_state=icon_state) - ..() /datum/asset/simple/lobby assets = list( @@ -288,16 +287,15 @@ /datum/asset/spritesheet/pipes name = "pipes" -/datum/asset/spritesheet/pipes/register() +/datum/asset/spritesheet/pipes/create_spritesheets() for (var/each in list('icons/obj/atmospherics/pipes/pipe_item.dmi', 'icons/obj/atmospherics/pipes/disposal.dmi', 'icons/obj/atmospherics/pipes/transit_tube.dmi', 'icons/obj/plumbing/fluid_ducts.dmi')) InsertAll("", each, GLOB.alldirs) - ..() // Representative icons for each research design /datum/asset/spritesheet/research_designs name = "design" -/datum/asset/spritesheet/research_designs/register() +/datum/asset/spritesheet/research_designs/create_spritesheets() for (var/path in subtypesof(/datum/design)) var/datum/design/D = path @@ -308,9 +306,11 @@ if(initial(D.research_icon) && initial(D.research_icon_state)) //If the design has an icon replacement skip the rest icon_file = initial(D.research_icon) icon_state = initial(D.research_icon_state) + #ifdef UNIT_TESTS if(!(icon_state in icon_states(icon_file))) - warning("design [D] with icon '[icon_file]' missing state '[icon_state]'") + stack_trace("design [D] with icon '[icon_file]' missing state '[icon_state]'") continue + #endif I = icon(icon_file, icon_state, SOUTH) else @@ -332,10 +332,11 @@ icon_file = initial(item.icon) icon_state = initial(item.icon_state) - + #ifdef UNIT_TESTS if(!(icon_state in icon_states(icon_file))) - warning("design [D] with icon '[icon_file]' missing state '[icon_state]'") + stack_trace("design [D] with icon '[icon_file]' missing state '[icon_state]'") continue + #endif I = icon(icon_file, icon_state, SOUTH) // computers (and snowflakes) get their screen and keyboard sprites @@ -350,12 +351,11 @@ I.Blend(icon(icon_file, keyboard, SOUTH), ICON_OVERLAY) Insert(initial(D.id), I) - return ..() /datum/asset/spritesheet/vending name = "vending" -/datum/asset/spritesheet/vending/register() +/datum/asset/spritesheet/vending/create_spritesheets() for (var/k in GLOB.vending_products) var/atom/item = k if (!ispath(item, /atom)) @@ -367,33 +367,35 @@ var/obj/item/ammo_box/ammoitem = item if(initial(ammoitem.multiple_sprites)) icon_state = "[icon_state]-[initial(ammoitem.max_ammo)]" - var/icon/I + #ifdef UNIT_TESTS var/icon_states_list = icon_states(icon_file) - if(icon_state in icon_states_list) - I = icon(icon_file, icon_state, SOUTH) - var/c = initial(item.color) - if (!isnull(c) && c != "#FFFFFF") - I.Blend(c, ICON_MULTIPLY) - else + if (!(icon_state in icon_states_list)) var/icon_states_string for (var/an_icon_state in icon_states_list) if (!icon_states_string) icon_states_string = "[json_encode(an_icon_state)](\ref[an_icon_state])" else icon_states_string += ", [json_encode(an_icon_state)](\ref[an_icon_state])" + stack_trace("[item] does not have a valid icon state, icon=[icon_file], icon_state=[json_encode(icon_state)](\ref[icon_state]), icon_states=[icon_states_string]") - I = icon('icons/turf/floors.dmi', "", SOUTH) + continue + #endif + + var/icon/I = icon(icon_file, icon_state, SOUTH) + var/c = initial(item.color) + if (!isnull(c) && c != "#FFFFFF") + I.Blend(c, ICON_MULTIPLY) var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-") Insert(imgid, I) - return ..() /datum/asset/spritesheet/uplink name = "uplink" + load_immediately = TRUE // needed to prevent duplicates -/datum/asset/spritesheet/uplink/register() +/datum/asset/spritesheet/uplink/create_spritesheets() for(var/path in GLOB.uplink_items) var/datum/uplink_item/U = path if (!ispath(U, /datum/uplink_item)) @@ -409,15 +411,10 @@ var/obj/item/ammo_box/ammoitem = item if(initial(ammoitem.multiple_sprites)) icon_state = "[icon_state]-[initial(ammoitem.max_ammo)]" - var/icon/I + #ifdef UNIT_TESTS var/icon_states_list = icon_states(icon_file) - if(icon_state in icon_states_list) - I = icon(icon_file, icon_state, SOUTH) - var/c = initial(item.color) - if (!isnull(c) && c != "#FFFFFF") - I.Blend(c, ICON_MULTIPLY) - else + if (!(icon_state in icon_states_list)) var/icon_states_string for (var/an_icon_state in icon_states_list) if (!icon_states_string) @@ -425,13 +422,18 @@ else icon_states_string += ", [json_encode(an_icon_state)](\ref[an_icon_state])" stack_trace("[item] does not have a valid icon state, icon=[icon_file], icon_state=[json_encode(icon_state)](\ref[icon_state]), icon_states=[icon_states_string]") - I = icon('icons/turf/floors.dmi', "", SOUTH) + continue + #endif - var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-") + var/icon/I = icon(icon_file, icon_state, SOUTH) + var/c = initial(item.color) + if (!isnull(c) && c != "#FFFFFF") + I.Blend(c, ICON_MULTIPLY) + var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-") + if(!sprites[imgid]) Insert(imgid, I) - return ..() /datum/asset/simple/genetics assets = list( @@ -453,14 +455,13 @@ /datum/asset/spritesheet/sheetmaterials name = "sheetmaterials" -/datum/asset/spritesheet/sheetmaterials/register() +/datum/asset/spritesheet/sheetmaterials/create_spritesheets() InsertAll("", 'icons/obj/stack_objects.dmi') // Special case to handle Bluespace Crystals Insert("polycrystal", 'icons/obj/telescience.dmi', "polycrystal") Insert("dilithium_polycrystal", 'yogstation/icons/obj/telescience.dmi', "dilithium_polycrystal") //yogs: same as above but for dilithium - ..() /datum/asset/simple/portraits @@ -468,10 +469,10 @@ assets = list() /datum/asset/simple/portraits/New() - if(!SSpersistence.paintings || !SSpersistence.paintings[tab] || !length(SSpersistence.paintings[tab])) + if(!length(SSpersistent_paintings.paintings[tab])) return - for(var/p in SSpersistence.paintings[tab]) - var/list/portrait = p + + for(var/list/portrait as anything in SSpersistent_paintings.paintings[tab]) var/png = "data/paintings/[tab]/[portrait["md5"]].png" if(fexists(png)) var/asset_name = "[tab]_[portrait["md5"]]" @@ -484,7 +485,7 @@ /datum/asset/spritesheet/supplypods name = "supplypods" -/datum/asset/spritesheet/supplypods/register() +/datum/asset/spritesheet/supplypods/create_spritesheets() for (var/style in 1 to length(GLOB.podstyles)) var/icon_file = 'icons/obj/supplypods.dmi' if (style == STYLE_SEETHROUGH) @@ -509,4 +510,3 @@ glow = "pod_glow_[glow]" podIcon.Blend(icon(icon_file, glow), ICON_OVERLAY) Insert("pod_asset[style]", podIcon) - return ..() diff --git a/code/modules/asset_cache/transports/asset_transport.dm b/code/modules/asset_cache/transports/asset_transport.dm index f5a1af4f0573..555d96e76a6a 100644 --- a/code/modules/asset_cache/transports/asset_transport.dm +++ b/code/modules/asset_cache/transports/asset_transport.dm @@ -137,7 +137,7 @@ /// Precache files without clogging up the browse() queue, used for passively sending files on connection start. -/datum/asset_transport/proc/send_assets_slow(client/client, list/files, filerate = 3) +/datum/asset_transport/proc/send_assets_slow(client/client, list/files, filerate = 6) var/startingfilerate = filerate for (var/file in files) if (!client) diff --git a/code/modules/client/client_defines.dm b/code/modules/client/client_defines.dm index 8e3fe4c312b1..2cc30eccb5be 100644 --- a/code/modules/client/client_defines.dm +++ b/code/modules/client/client_defines.dm @@ -167,3 +167,6 @@ var/next_move_dir_add /// On next move, subtract this dir from the move that would otherwise be done var/next_move_dir_sub + + /// Whether or not this client has standard hotkeys enabled + var/hotkeys = TRUE diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index 77b0826125d6..b0844f74ff71 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -140,13 +140,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( hsrc = mob if("mentor") // YOGS - Mentor stuff hsrc = mentor_datum // YOGS - Mentor stuff - if("prefs") - if (inprefs) - return - inprefs = TRUE - . = prefs.process_link(usr,href_list) - inprefs = FALSE - return if("vars") return view_var_Topic(href,href_list,hsrc) @@ -259,6 +252,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( prefs = GLOB.preferences_datums[ckey] if(prefs) prefs.parent = src + prefs.apply_all_client_preferences() else prefs = new /datum/preferences(src) GLOB.preferences_datums[ckey] = prefs @@ -899,7 +893,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( to_chat(src, span_danger("Your previous click was ignored because you've done too many in a second")) return - if (prefs.hotkeys) + if (hotkeys) // If hotkey mode is enabled, then clicking the map will automatically // unfocus the text bar. This removes the red color from the text bar // so that the visual focus indicator matches reality. @@ -996,8 +990,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( if(!D?.key_bindings) return movement_keys = list() - for(var/key in D.key_bindings) - for(var/kb_name in D.key_bindings[key]) + for(var/kb_name in D.key_bindings) + for(var/key in D.key_bindings[kb_name]) switch(kb_name) if("North") movement_keys[key] = NORTH @@ -1059,26 +1053,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( if(prefs && prefs.chat_toggles & CHAT_PULLR) to_chat(src, announcement) -/client/proc/show_character_previews(mutable_appearance/MA) - var/pos = 0 - for(var/D in GLOB.cardinals) - pos++ - var/atom/movable/screen/O = LAZYACCESS(char_render_holders, "[D]") - if(!O) - O = new - LAZYSET(char_render_holders, "[D]", O) - screen |= O - O.appearance = MA - O.dir = D - O.screen_loc = "character_preview_map:0,[pos]" - -/client/proc/clear_character_previews() - for(var/index in char_render_holders) - var/atom/movable/screen/S = char_render_holders[index] - screen -= S - qdel(S) - char_render_holders = null - /// compiles a full list of verbs and sends it to the browser /client/proc/init_verbs() if(IsAdminAdvancedProcCall()) @@ -1106,3 +1080,11 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( return to_chat(src, span_userdanger("Statpanel failed to load, click here to reload the panel ")) tgui_panel.initialize() + +/client/verb/stop_client_sounds() + set name = "Stop Sounds" + set category = "OOC" + set desc = "Stop Current Sounds" + SEND_SOUND(usr, sound(null)) + tgui_panel?.stop_music() + SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Stop Self Sounds")) diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index 5b7ce54921d8..99a4ef692041 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -28,8 +28,15 @@ GLOBAL_LIST_EMPTY(preferences_datums) var/UI_style = null var/buttons_locked = FALSE var/hotkeys = TRUE - // Custom Keybindings + + /// Custom keybindings. Map of keybind names to keyboard inputs. + /// For example, by default would have "swap_hands" -> list("X") var/list/key_bindings = list() + + /// Cached list of keybindings, mapping keys to actions. + /// For example, by default would have "X" -> list("swap_hands") + var/list/key_bindings_by_key = list() + var/tgui_fancy = TRUE var/tgui_lock = FALSE var/windowflashing = TRUE @@ -95,7 +102,7 @@ GLOBAL_LIST_EMPTY(preferences_datums) //Job preferences 2.0 - indexed by job title , no key or value implies never var/list/job_preferences = list() - // Want randomjob if preferences already filled - Donkie + // Want randomjob if preferences already filled - Donkie var/joblessrole = BERANDOMJOB //defaults to 1 for fewer assistants // 0 = character settings, 1 = game preferences @@ -156,13 +163,47 @@ GLOBAL_LIST_EMPTY(preferences_datums) var/disable_balloon_alerts = FALSE + + + var/list/randomise = list() + + /// The current window, PREFERENCE_TAB_* in [`code/__DEFINES/preferences.dm`] + var/current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES + + /// A preview of the current character + var/atom/movable/screen/character_preview_view/character_preview_view + + /// A list of instantiated middleware + var/list/datum/preference_middleware/middleware = list() + + /// The savefile relating to core preferences, PREFERENCE_PLAYER + var/savefile/game_savefile + + /// The savefile relating to character preferences, PREFERENCE_CHARACTER + var/savefile/character_savefile + + /// A list of keys that have been updated since the last save. + var/list/recently_updated_keys = list() + + /// A cache of preference entries to values. + /// Used to avoid expensive READ_FILE every time a preference is retrieved. + var/value_cache = list() + + /// If set to TRUE, will update character_profiles on the next ui_data tick. + var/tainted_character_profiles = FALSE + +/datum/preferences/Destroy(force, ...) + QDEL_NULL(character_preview_view) + QDEL_LIST(middleware) + value_cache = null + return ..() + /datum/preferences/New(client/C) parent = C - for(var/custom_name_id in GLOB.preferences_custom_names) - custom_names[custom_name_id] = get_default_name(custom_name_id) + for (var/middleware_type in subtypesof(/datum/preference_middleware)) + middleware += new middleware_type(src) - UI_style = GLOB.available_ui_styles[1] if(istype(C)) if(!IsGuestKey(C.key)) load_path(C.ckey) @@ -173,1040 +214,382 @@ GLOBAL_LIST_EMPTY(preferences_datums) if(is_donator(C) || (C.ckey in get_donators())) // the Latter handles race cases where the prefs are not fully loaded in, or GLOB.donators hasn't loaded in yet max_save_slots += DONOR_CHARACTER_SLOTS // yogs end + + // give them default keybinds and update their movement keys + key_bindings = deepCopyList(GLOB.default_hotkeys) + key_bindings_by_key = get_key_bindings_by_key(key_bindings) + randomise = get_default_randomization() + var/loaded_preferences_successfully = load_preferences() if(loaded_preferences_successfully) if(load_character()) return //we couldn't load character data so just randomize the character appearance + name random_character() //let's create a random character then - rather than a fat, bald and naked man. - key_bindings = deepCopyList(GLOB.hotkey_keybinding_list_by_key) // give them default keybinds and update their movement keys - C?.set_macros() - real_name = pref_species.random_name(gender,1) + if(C) + apply_all_client_preferences() + C.set_macros() if(!loaded_preferences_successfully) save_preferences() save_character() //let's save this new random character so it doesn't keep generating new ones. - menuoptions = list() - return - -#define APPEARANCE_CATEGORY_COLUMN "" -#define MAX_MUTANT_ROWS 4 - -/datum/preferences/proc/ShowChoices(mob/user) - if(!user || !user.client) - return - - if(!SSjob || (SSjob.occupations.len <= 0)) - to_chat(user, span_notice("The job SSticker is not yet finished creating jobs, please try again later")) - return - - update_preview_icon() - var/list/dat = list("
") - - dat += "Character Settings" - dat += "Game Preferences" - dat += "OOC Preferences" - dat += "Donator Preferences" // yogs - Donor features - dat += "Keybindings" // yogs - Custom keybindings - - if(!path) - dat += "
Please create an account to save your preferences
" - - dat += "
" - - dat += "
" - - switch(current_tab) - if (0) // Character Settings# - if(path) - var/savefile/S = new /savefile(path) - if(S) - dat += "
" - var/name - var/unspaced_slots = 0 - for(var/i=1, i<=max_save_slots, i++) - unspaced_slots++ - if(unspaced_slots > 4) - dat += "
" - unspaced_slots = 0 - S.cd = "/character[i]" - S["real_name"] >> name - if(!name) - name = "Character[i]" - dat += "[name] " - dat += "
" - - dat += "

Occupation Choices

" - dat += "Set Occupation Preferences
" - if(CONFIG_GET(flag/roundstart_traits)) - dat += "

Quirk Setup

" - dat += "Configure Quirks
" - dat += "
Current Quirks: [all_quirks.len ? all_quirks.Join(", ") : "None"]
" - dat += "

Identity

" - dat += "" - - 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 += "

" - - dat += "Custom Job Preferences:
" - dat += "Preferred AI Core Display: [preferred_ai_core_display]
" - dat += "Preferred Security Department: [prefered_security_department]
" - dat += "Preferred Engineering Department: [prefered_engineering_department]
" - - - dat += "Language:
" - dat += "Accent: [accent ? accent : "None"]
" - - dat += "

Body

" - dat += "Random Body " - dat += "Always Random Body: [be_random_body ? "Yes" : "No"]" - dat += "Unlock all" - dat += "Lock all
" - dat += "Background: [background_options[background]]

" - - 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 += "

Hair Style

" - - dat += "[hair_style]" - dat += "[random_locks["hair_style"] ? "Unlock" : "Lock"]
" - - dat += "<>
" - - dat += "   Change" - dat += "[random_locks["hair"] ? "Unlock" : "Lock"]
" - - dat += "

Facial Hair Style

" - 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 += "

Teeth

" + if (href_list["open_keybindings"]) + current_window = PREFERENCE_TAB_KEYBINDINGS + update_static_data(usr) + ui_interact(usr) + return TRUE - dat += "[features["teeth"]]" - dat += "[random_locks["teeth"] ? "Unlock" : "Lock"]
" +/datum/preferences/proc/create_character_preview_view(mob/user) + character_preview_view = new(null, src, user.client) + character_preview_view.update_body() + character_preview_view.register_to_client(user.client) - mutant_category++ - if(mutant_category >= MAX_MUTANT_ROWS) - dat += "" - mutant_category = 0 + return character_preview_view - if("dome" in pref_species.default_features) - if(!mutant_category) - dat += APPEARANCE_CATEGORY_COLUMN +/datum/preferences/proc/compile_character_preferences(mob/user) + var/list/preferences = list() - dat += "

Dome

" + 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 += "

Helmet Style

" - - dat += "[features["plasmaman_helmet"]]" - dat += "[random_locks["plasmaman_helmet"] ? "Unlock" : "Lock"]
" - - if(CONFIG_GET(flag/join_with_mutant_humans)) - - if("wings" in pref_species.default_features && GLOB.r_wings_list.len >1) - if(!mutant_category) - dat += APPEARANCE_CATEGORY_COLUMN - - dat += "

Wings

" + plane_masters += plane_master - dat += "[features["wings"]]" - dat += "[random_locks["wings"] ? "Unlock" : "Lock"]
" + client?.register_map_obj(src) - mutant_category++ - if(mutant_category >= MAX_MUTANT_ROWS) - dat += "" - mutant_category = 0 +/datum/preferences/proc/create_character_profiles() + var/list/profiles = list() - if(mutant_category) - dat += "" - mutant_category = 0 - dat += "
" - - dat += "Species:
[pref_species.name]" - dat += "[random_locks["species"] ? "Unlock" : "Lock"]
" - - dat += "Underwear:
[underwear]" - dat += "[random_locks["underwear"] ? "Unlock" : "Lock"]
" - - dat += "Undershirt:
[undershirt]" - dat += "[random_locks["undershirt"] ? "Unlock" : "Lock"]
" - - dat += "Socks:
[socks]" - dat += "[random_locks["socks"] ? "Unlock" : "Lock"]
" - - dat += "Backpack:
[backbag]" - dat += "Jumpsuit:
[jumpsuit_style]
" - dat += "[random_locks["bag"] ? "Unlock" : "Lock"]
" - if((HAS_FLESH in pref_species.species_traits) || (HAS_BONE in pref_species.species_traits)) - dat += "
Temporal Scarring:
[(persistent_scars) ? "Enabled" : "Disabled"]" - dat += "Clear scar slots
" - dat += "Uplink Spawn Location:
[uplink_spawn_loc]
" + 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 += "
" - dat += "

General Settings

" - dat += "UI Style: [UI_style]
" - dat += "tgui Window Mode: [(tgui_fancy) ? "Fancy (default)" : "Compatible (slower)"]
" - dat += "tgui Window Placement: [(tgui_lock) ? "Primary monitor" : "Free (default)"]
" - dat += "Show Runechat Chat Bubbles: [chat_on_map ? "Enabled" : "Disabled"]
" - dat += "Runechat message char limit: [max_chat_length]
" - dat += "See Runechat for non-mobs: [see_chat_non_mob ? "Enabled" : "Disabled"]
" - dat += "See Runechat emotes: [see_rc_emotes ? "Enabled" : "Disabled"]
" - dat += "Hear alternative station announcers: [disable_alternative_announcers ? "Disabled" : "Enabled"]
" - dat += "Balloon Alerts: [disable_balloon_alerts ? "Disabled" : "Enabled"]
" - dat += "
" - dat += "Action Buttons: [(buttons_locked) ? "Locked In Place" : "Unlocked"]
" - dat += "Hotkey mode: [(hotkeys) ? "Hotkeys" : "Default"]
" - dat += "
" - dat += "PDA Color:     Change
" - dat += "PDA Style: [pda_style]
" - dat += "PDA Theme: [pda_theme]
" - dat += "PDA Starts in ID Slot: [id_in_pda ? "Enabled" : "Disabled"]
" - dat += "Skillcape: [(skillcape_id != "None") ? "[GLOB.skillcapes[skillcape_id]]" : "None"]
" - dat += "Flare: [flare ? "Enabled" : "Disabled"]
" - dat += "Map: [map ? "Enabled" : "Disabled"]
" - dat += "Preferred Box Bar: [bar_choice]
" - dat += "
" - dat += "Ghost Ears: [(chat_toggles & CHAT_GHOSTEARS) ? "All Speech" : "Nearest Creatures"]
" - dat += "Ghost Radio: [(chat_toggles & CHAT_GHOSTRADIO) ? "All Messages":"No Messages"]
" - dat += "Ghost Sight: [(chat_toggles & CHAT_GHOSTSIGHT) ? "All Emotes" : "Nearest Creatures"]
" - dat += "Ghost Whispers: [(chat_toggles & CHAT_GHOSTWHISPER) ? "All Speech" : "Nearest Creatures"]
" - dat += "Ghost PDA: [(chat_toggles & CHAT_GHOSTPDA) ? "All Messages" : "Nearest Creatures"]
" + var/name + READ_FILE(savefile["real_name"], name) - if(unlock_content) - dat += "Ghost Form: [ghost_form]
" - dat += "Ghost Orbit: [ghost_orbit]
" - - var/button_name = "If you see this something went wrong." - switch(ghost_accs) - if(GHOST_ACCS_FULL) - button_name = GHOST_ACCS_FULL_NAME - if(GHOST_ACCS_DIR) - button_name = GHOST_ACCS_DIR_NAME - if(GHOST_ACCS_NONE) - button_name = GHOST_ACCS_NONE_NAME - - dat += "Ghost Accessories: [button_name]
" - - switch(ghost_others) - if(GHOST_OTHERS_THEIR_SETTING) - button_name = GHOST_OTHERS_THEIR_SETTING_NAME - if(GHOST_OTHERS_DEFAULT_SPRITE) - button_name = GHOST_OTHERS_DEFAULT_SPRITE_NAME - if(GHOST_OTHERS_SIMPLE) - button_name = GHOST_OTHERS_SIMPLE_NAME - - dat += "Ghosts of Others: [button_name]
" - dat += "
" - - dat += "Income Updates: [(chat_toggles & CHAT_BANKCARD) ? "Allowed" : "Muted"]
" - dat += "
" - - dat += "FPS: [clientfps]
" - - dat += "Parallax (Fancy Space): " - switch (parallax) - if (PARALLAX_LOW) - dat += "Low" - if (PARALLAX_MED) - dat += "Medium" - if (PARALLAX_INSANE) - dat += "Insane" - if (PARALLAX_DISABLE) - dat += "Disabled" - else - dat += "High" - dat += "
" - - dat += "Ambient Occlusion: [ambientocclusion ? "Enabled" : "Disabled"]
" - dat += "Fit Viewport: [auto_fit_viewport ? "Auto" : "Manual"]
" - if (CONFIG_GET(string/default_view) != CONFIG_GET(string/default_view_square)) - dat += "Widescreen: [widescreenpref ? "Enabled ([CONFIG_GET(string/default_view)])" : "Disabled ([CONFIG_GET(string/default_view_square)])"]
" - - button_name = pixel_size - dat += "Pixel Scaling: [(button_name) ? "Pixel Perfect [button_name]x" : "Stretch to fit"]
" - - switch(scaling_method) - if(SCALING_METHOD_NORMAL) - button_name = "Nearest Neighbor" - if(SCALING_METHOD_DISTORT) - button_name = "Point Sampling" - if(SCALING_METHOD_BLUR) - button_name = "Bilinear" - dat += "Scaling Method: [button_name]
" - - if (CONFIG_GET(flag/maprotation)) - var/p_map = preferred_map - if (!p_map) - p_map = "Default" - if (config.defaultmap) - p_map += " ([config.defaultmap.map_name])" - else - if (p_map in config.maplist) - var/datum/map_config/VM = config.maplist[p_map] - if (!VM) - p_map += " (No longer exists)" - else - p_map = VM.map_name - else - p_map += " (No longer exists)" - if(CONFIG_GET(flag/preference_map_voting)) - dat += "Preferred Map: [p_map]
" - //yogs start -- Mood preference toggling - if(CONFIG_GET(flag/disable_human_mood)) - dat += "Mood: [yogtoggles & PREF_MOOD ? "Enabled" : "Disabled"]
" - dat += "Mood Tail Wagging: [mood_tail_wagging ? "Enabled" : "Disabled"]
" - //yogs end - - dat += "
" - - dat += "

Special Role Settings

" - - if(is_banned_from(user.ckey, ROLE_SYNDICATE)) - dat += "You are banned from antagonist roles.
" - src.be_special = list() - - - for (var/i in GLOB.special_roles) - if(is_banned_from(user.ckey, i)) - dat += "Be [capitalize(i)]: BANNED
" - else - var/days_remaining = null - if(ispath(GLOB.special_roles[i]) && CONFIG_GET(flag/use_age_restriction_for_jobs)) //If it's a game mode antag, check if the player meets the minimum age - var/mode_path = GLOB.special_roles[i] - var/datum/game_mode/temp_mode = new mode_path - days_remaining = temp_mode.get_remaining_days(user.client) - - if(days_remaining) - dat += "Be [capitalize(i)]: \[IN [days_remaining] DAYS]
" - // yogs start - Donor features - else if(src.yogtoggles & QUIET_ROUND) - dat += "Be [capitalize(i)]: \[QUIET ROUND\]
" - // yogs end - else - dat += "Be [capitalize(i)]: [(i in be_special) ? "Enabled" : "Disabled"]
" - dat += "
" - dat += "Midround Antagonist: [(toggles & MIDROUND_ANTAG) ? "Enabled" : "Disabled"]
" + if (isnull(name)) + profiles += null + continue - // yogs start - Donor features - if(is_donator(user.client)) - dat += "Quiet round: [(src.yogtoggles & QUIET_ROUND) ? "Yes" : "No"]
" - // yogs end - dat += "
" - if(2) //OOC Preferences - dat += "" - - if(user.client.holder) - dat +="" - dat += "
" - dat += "

OOC Settings

" - dat += "Window Flashing: [(windowflashing) ? "Enabled":"Disabled"]
" - dat += "
" - dat += "Play Admin MIDIs: [(toggles & SOUND_MIDI) ? "Enabled":"Disabled"]
" - dat += "Play Lobby Music: [(toggles & SOUND_LOBBY) ? "Enabled":"Disabled"]
" - dat += "See Pull Requests: [(chat_toggles & CHAT_PULLR) ? "Enabled":"Disabled"]
" - dat += "
" - - - if(user.client) - if(unlock_content) - dat += "BYOND Membership Publicity: [(toggles & MEMBER_PUBLIC) ? "Public" : "Hidden"]
" - - if(unlock_content || check_rights_for(user.client, R_ADMIN)) - dat += "OOC Color:     Change
" - - dat += "
" - - dat += "

Admin Settings

" - - dat += "Adminhelp Sounds: [(toggles & SOUND_ADMINHELP)?"Enabled":"Disabled"]
" - dat += "Prayer Sounds: [(toggles & SOUND_PRAYERS)?"Enabled":"Disabled"]
" - dat += "Announce Login: [(toggles & ANNOUNCE_LOGIN)?"Enabled":"Disabled"]
" - dat += "
" - dat += "Combo HUD Lighting: [(toggles & COMBOHUD_LIGHTING)?"Full-bright":"No Change"]
" - dat += "
" - dat += "Hide Dead Chat: [(chat_toggles & CHAT_DEAD)?"Shown":"Hidden"]
" - dat += "Hide Radio Messages: [(chat_toggles & CHAT_RADIO)?"Shown":"Hidden"]
" - dat += "Hide Prayers: [(chat_toggles & CHAT_PRAYER)?"Shown":"Hidden"]
" - dat += "Split Admin Tabs: [(extra_toggles & SPLIT_ADMIN_TABS)?"Enabled":"Disabled"]
" - dat += "Fast MC Refresh: [(extra_toggles & FAST_MC_REFRESH)?"Enabled":"Disabled"]
" - if(CONFIG_GET(flag/allow_admin_asaycolor)) - dat += "
" - dat += "ASAY Color:     Change
" - - //deadmin - dat += "

Deadmin While Playing

" - if(CONFIG_GET(flag/auto_deadmin_players)) - dat += "Always Deadmin: FORCED
" - else - dat += "Always Deadmin: [(toggles & DEADMIN_ALWAYS)?"Enabled":"Disabled"]
" - if(!(toggles & DEADMIN_ALWAYS)) - dat += "
" - if(!CONFIG_GET(flag/auto_deadmin_antagonists)) - dat += "As Antag: [(toggles & DEADMIN_ANTAGONIST)?"Deadmin":"Keep Admin"]
" - else - dat += "As Antag: FORCED
" - - if(!CONFIG_GET(flag/auto_deadmin_heads)) - dat += "As Command: [(toggles & DEADMIN_POSITION_HEAD)?"Deadmin":"Keep Admin"]
" - else - dat += "As Command: FORCED
" - - if(!CONFIG_GET(flag/auto_deadmin_security)) - dat += "As Security: [(toggles & DEADMIN_POSITION_SECURITY)?"Deadmin":"Keep Admin"]
" - else - dat += "As Security: FORCED
" - - if(!CONFIG_GET(flag/auto_deadmin_silicons)) - dat += "As Silicon: [(toggles & DEADMIN_POSITION_SILICON)?"Deadmin":"Keep Admin"]
" - else - dat += "As Silicon: FORCED
" - - if(!CONFIG_GET(flag/auto_deadmin_critical)) - dat += "As Critical Roles: [(toggles & DEADMIN_POSITION_CRITICAL)?"Deadmin":"Keep Admin"]
" - else - dat += "As Critical Roles: FORCED
" - - 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 += "

" - dat += "\[Reset to default\]" - dat += "" - dat += "
" - - if(!IsGuestKey(user.key)) - dat += "Undo " - dat += "Save Setup " - - dat += "Reset Setup" - dat += "
" - - winshow(user, "preferences_window", TRUE) - var/datum/browser/popup = new(user, "preferences_browser", "
Character Setup
", 640, 770) - popup.set_content(dat.Join()) - popup.open(FALSE) - onclose(user, "preferences_window", src) - -#undef APPEARANCE_CATEGORY_COLUMN -#undef MAX_MUTANT_ROWS - -/datum/preferences/proc/CaptureKeybinding(mob/user, datum/keybinding/kb, var/old_key) - var/HTML = {" -
Keybinding: [kb.full_name]
[kb.description]

Press any key to change
Press ESC to clear
- - "} - winshow(user, "capturekeypress", TRUE) - var/datum/browser/popup = new(user, "capturekeypress", "
Keybindings
", 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 += "
Done

" // Easier to press up here. - - else - HTML += "Choose occupation chances
" - HTML += "
Left-click to raise an occupation preference, right-click to lower it.
" - HTML += "
Done

" // 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 += "" - HTML += "
  
" - index = 0 - - HTML += "" - continue - var/required_playtime_remaining = job.required_playtime_remaining(user.client) - if(required_playtime_remaining) - HTML += "[rank]" - continue - if(!job.player_old_enough(user.client)) - var/available_in_days = job.available_in_days(user.client) - HTML += "[rank]" - continue - if((job_preferences[overflow] == JP_LOW) && (rank != SSjob.overflow_role) && !is_banned_from(user.ckey, SSjob.overflow_role)) - HTML += "[rank]" - continue - // yogs start - Donor features, quiet round - if(((rank in GLOB.command_positions) || (rank in GLOB.nonhuman_positions)) && (src.yogtoggles & QUIET_ROUND)) - HTML += "[rank]" - 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 += "
" - var/rank = job.title - lastJob = job - if(is_banned_from(user.ckey, rank)) - HTML += "[rank] BANNED
\[ [get_exp_format(required_playtime_remaining)] as [job.get_exp_req_type()] \]
\[IN [(available_in_days)] DAYS\]
\[QUIET\]
" - - var/prefLevelLabel = "ERROR" - var/prefLevelColor = "pink" - var/prefUpperLevel = -1 // level to assign on left click - var/prefLowerLevel = -1 // level to assign on right click - - switch(job_preferences[job.title]) - if(JP_HIGH) - prefLevelLabel = "High" - prefLevelColor = "slateblue" - prefUpperLevel = 4 - prefLowerLevel = 2 - if(JP_MEDIUM) - prefLevelLabel = "Medium" - prefLevelColor = "green" - prefUpperLevel = 1 - prefLowerLevel = 3 - if(JP_LOW) - prefLevelLabel = "Low" - prefLevelColor = "orange" - prefUpperLevel = 2 - prefLowerLevel = 4 - else - prefLevelLabel = "NEVER" - prefLevelColor = "red" - prefUpperLevel = 3 - prefLowerLevel = 1 + return output - HTML += "" +/// Returns the default `randomise` variable ouptut +/datum/preferences/proc/get_default_randomization() + var/list/default_randomization = list() - if(rank == SSjob.overflow_role)//Overflow is special - if(job_preferences[overflow.title] == JP_LOW) - HTML += "Yes" - else - HTML += "No" - 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 += "

[message]
" - HTML += "
Reset Preferences
" - var/datum/browser/popup = new(user, "mob_occupation", "
Occupation Preferences
", width, height) - popup.set_window_options("can_close=0") - popup.set_content(HTML) - popup.open(FALSE) /datum/preferences/proc/GetPlayerAltTitle(datum/job/job) return player_alt_titles.Find(job.title) > 0 \ @@ -1221,119 +604,28 @@ GLOBAL_LIST_EMPTY(preferences_datums) if(job.title != new_title) player_alt_titles[job.title] = new_title -/datum/preferences/proc/SetJobPreferenceLevel(datum/job/job, level) +/datum/preferences/proc/set_job_preference_level(datum/job/job, level) if (!job) return FALSE - if (level == JP_HIGH) // to high - //Set all other high to medium - for(var/j in job_preferences) - if(job_preferences[j] == JP_HIGH) - job_preferences[j] = JP_MEDIUM - //technically break here - - job_preferences[job.title] = level - return TRUE - -/datum/preferences/proc/UpdateJobPreference(mob/user, role, desiredLvl) - if(!SSjob || SSjob.occupations.len <= 0) - return - var/datum/job/job = SSjob.GetJob(role) - - if(!job) - user << browse(null, "window=mob_occupation") - ShowChoices(user) - return - - if (!isnum(desiredLvl)) - to_chat(user, span_danger("UpdateJobPreference - desired level was not a number. Please notify coders!")) - ShowChoices(user) - return - - var/jpval = null - switch(desiredLvl) - if(3) - jpval = JP_LOW - if(2) - jpval = JP_MEDIUM - if(1) - jpval = JP_HIGH - - if(role == SSjob.overflow_role) - if(job_preferences[job.title] == JP_LOW) - jpval = null - else - jpval = JP_LOW - - SetJobPreferenceLevel(job, jpval) - SetChoices(user) - - return 1 + if (level == JP_HIGH) + var/datum/job/overflow_role = SSjob.overflow_role + var/overflow_role_title = initial(overflow_role.title) + for(var/other_job in job_preferences) + if(job_preferences[other_job] == JP_HIGH) + // Overflow role needs to go to NEVER, not medium! + if(other_job == overflow_role_title) + job_preferences[other_job] = null + else + job_preferences[other_job] = JP_MEDIUM -/datum/preferences/proc/ResetJobs() - job_preferences = list() - -/datum/preferences/proc/SetQuirks(mob/user) - if(!SSquirks) - to_chat(user, span_danger("The quirk subsystem is still initializing! Try again in a minute.")) - return - - var/list/dat = list() - if(!SSquirks.quirks.len) - dat += "The quirk subsystem hasn't finished initializing, please hold..." - dat += "
Done

" + if(level == null) + job_preferences -= job.title else - dat += "
Choose quirk setup

" - dat += "
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.
" - dat += "
Done
" - dat += "
" - dat += "
Current quirks: [all_quirks.len ? all_quirks.Join(", ") : "None"]
" - dat += "
[GetPositiveQuirkCount()] / [MAX_QUIRKS] max positive quirks
\ - Quirk balance remaining: [GetQuirkBalance()]

" - for(var/V in SSquirks.quirks) - var/datum/quirk/T = SSquirks.quirks[V] - var/quirk_name = initial(T.name) - var/has_quirk - var/quirk_cost = initial(T.value) * -1 - var/lock_reason = FALSE // Also marks whether this quirk ought to be locked at all; FALSE implies it's OK for this person to have this quirk - for(var/_V in all_quirks) - if(_V == quirk_name) - has_quirk = TRUE - if(initial(T.mood_quirk) && (CONFIG_GET(flag/disable_human_mood) && !(yogtoggles & PREF_MOOD)))//Yogs -- Adds mood to preferences - lock_reason = "Mood is disabled." - else - var/datum/quirk/t = new T(no_init = TRUE) - lock_reason = t.check_quirk(src) // Yogs -- allows for specific denial of quirks based on current preferences - qdel(t) - if(has_quirk) - if(lock_reason) - all_quirks -= quirk_name - has_quirk = FALSE - else - quirk_cost *= -1 //invert it back, since we'd be regaining this amount - if(quirk_cost > 0) - quirk_cost = "+[quirk_cost]" - var/font_color = "#AAAAFF" - if(initial(T.value) != 0) - font_color = initial(T.value) > 0 ? "#AAFFAA" : "#FFAAAA" - if(lock_reason) - dat += "[quirk_name] - [initial(T.desc)] \ - LOCKED: [lock_reason]
" - else - if(has_quirk) - dat += "[has_quirk ? "Remove" : "Take"] ([quirk_cost] pts.) \ - [quirk_name] - [initial(T.desc)]
" - else - dat += "[has_quirk ? "Remove" : "Take"] ([quirk_cost] pts.) \ - [quirk_name] - [initial(T.desc)]
" - dat += "
Reset Quirks
" + job_preferences[job.title] = level - var/datum/browser/popup = new(user, "mob_occupation", "
Quirk Preferences
", 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" +``` + +![Preference categories for the main page](https://raw.githubusercontent.com/tgstation/documentation-assets/main/preferences/preference_categories.png) + +> 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: + +![Changeling icon](https://raw.githubusercontent.com/tgstation/documentation-assets/main/preferences/changeling.png) + +## 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 0000000000000000000000000000000000000000..6c4f2b33e0fee9d3f99ff3febc0760cfd078aed2 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnL3?x0byx0z;m;-!5Tn`*LkmkKF1;}MA3GxeO zaCmkj4amu^3W+FjNi9w;$}A|!%+F(BsF)KRR!~&>{Y!Ac$FEPcymhtCojD)8A=Kca z@q&JF>8>?&JF>8>?r978O6lM^IZ7bl4HG(F^GV0pm6sL^WE Q0hDF%boFyt=akR{025G63;+NC literal 0 HcmV?d00001 diff --git a/icons/blanks/blank_title.png b/icons/blanks/blank_title.png new file mode 100644 index 0000000000000000000000000000000000000000..387a21f03a013222955c883c585ed77afad0bd69 GIT binary patch literal 181 zcmeAS@N?(olHy`uVBq!ia0y~yU`k+MV0^&H3>2~W>wFDJF%}28J29*~C-V}>VM%xN zb!1@J*w6hZkrl{i4)6(a1=7o_Y;OT6&H|6fVg?4j!ywFfJby(BP|(`b#WAGf*4u-I oj6mL@1z-JhIpP(7OhVv62=jyl2F404&0vsHPgg&ebxsLQ0RK=WZ2$lO literal 0 HcmV?d00001 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 @@ + +image/svg+xml + + + + + + + + + + + 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 @@ + +image/svg+xml + + + + + + + + + + + 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 @@ + +image/svg+xml + + 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 @@ + +image/svg+xml + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + 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 ( +
+ {text} + { + index !== antagonist.description.length - 1 + && + } +
+ ); + }) + } position="bottom"> + { + if (isBanned) { + return; + } + + if (predictedState.has(antagonist.key)) { + disableAntags([antagonist.key]); + } else { + enableAntags([antagonist.key]); + } + }} + > + + + {isBanned && ( + + )} + + {daysLeft > 0 && ( + + {daysLeft} days left + + )} + +
+
+
+
+ ); + })} +
+
+ ); +}; + +export const AntagsPage = () => { + return ( + + + + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx new file mode 100644 index 000000000000..19132641a8af --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx @@ -0,0 +1,164 @@ +import { exhaustiveCheck } from "common/exhaustive"; +import { useBackend, useLocalState } from "../../backend"; +import { Button, Stack } from "../../components"; +import { Window } from "../../layouts"; +import { PreferencesMenuData } from "./data"; +import { PageButton } from "./PageButton"; +import { AntagsPage } from "./AntagsPage"; +import { JobsPage } from "./JobsPage"; +import { MainPage } from "./MainPage"; +import { SpeciesPage } from "./SpeciesPage"; +import { QuirksPage } from "./QuirksPage"; + +enum Page { + Antags, + Main, + Jobs, + Species, + Quirks, +} + +const CharacterProfiles = (props: { + activeSlot: number, + onClick: (index: number) => void, + profiles: (string | null)[], +}) => { + const { profiles } = props; + + return ( + + {profiles.map((profile, slot) => ( + + + + ))} + + ); +}; + +export const CharacterPreferenceWindow = (props, context) => { + const { act, data } = useBackend(context); + + const [currentPage, setCurrentPage] = useLocalState(context, "currentPage", Page.Main); + + let pageContents; + + switch (currentPage) { + case Page.Antags: + pageContents = ; + break; + case Page.Jobs: + pageContents = ; + break; + case Page.Main: + pageContents = ( setCurrentPage(Page.Species)} + />); + + break; + case Page.Species: + pageContents = ( setCurrentPage(Page.Main)} + />); + + break; + case Page.Quirks: + pageContents = ; + break; + default: + exhaustiveCheck(currentPage); + } + + return ( + + + + + { + act("change_slot", { + slot: slot + 1, + }); + }} + profiles={data.character_profiles} + /> + + + {!data.content_unlocked && ( + + Donating to the server unlocks more character slots. + + )} + + + + + + + + Character + + + + + + {/* + Fun fact: This isn't "Jobs" so that it intentionally + catches your eyes, because it's really important! + */} + + Occupations + + + + + + Antagonists + + + + + + Quirks + + + + + + + + + {pageContents} + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx new file mode 100644 index 000000000000..71622d4937ee --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx @@ -0,0 +1,14 @@ +import { ByondUi } from '../../components'; + +export const CharacterPreview = (props: { height: string; id: string }) => { + return ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx new file mode 100644 index 000000000000..4bddd3305edb --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx @@ -0,0 +1,77 @@ +import { Stack } from "../../components"; +import { Window } from "../../layouts"; +import { KeybindingsPage } from "./KeybindingsPage"; +import { GamePreferencesPage } from "./GamePreferencesPage"; +import { PageButton } from "./PageButton"; +import { useBackend, useLocalState } from "../../backend"; +import { GamePreferencesSelectedPage, PreferencesMenuData } from "./data"; +import { exhaustiveCheck } from "common/exhaustive"; + +export const GamePreferenceWindow = (props: { + startingPage?: GamePreferencesSelectedPage, +}, context) => { + const { act, data } = useBackend(context); + + const [currentPage, setCurrentPage] + = useLocalState( + context, + "currentPage", + props.startingPage ?? GamePreferencesSelectedPage.Settings, + ); + + let pageContents; + + switch (currentPage) { + case GamePreferencesSelectedPage.Keybindings: + pageContents = ; + break; + case GamePreferencesSelectedPage.Settings: + pageContents = ; + break; + default: + exhaustiveCheck(currentPage); + } + + + return ( + + + + + + + + Settings + + + + + + Keybindings + + + + + + + + + {pageContents} + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx new file mode 100644 index 000000000000..7e0245ed7661 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx @@ -0,0 +1,102 @@ +import { binaryInsertWith, sortBy } from "common/collections"; +import { InfernoNode } from "inferno"; +import { useBackend } from "../../backend"; +import { Box, Flex, Tooltip } from "../../components"; +import { PreferencesMenuData } from "./data"; +import features from "./preferences/features"; +import { FeatureValueInput } from "./preferences/features/base"; +import { TabbedMenu } from "./TabbedMenu"; + +type PreferenceChild = { + name: string, + children: InfernoNode, +}; + +const binaryInsertPreference = binaryInsertWith( + (child) => child.name, +); + +const sortByName = sortBy<[string, PreferenceChild[]]>(([name]) => name); + +export const GamePreferencesPage = (props, context) => { + const { act, data } = useBackend(context); + + const gamePreferences: Record = {}; + + for (const [featureId, value] of Object.entries( + data.character_preferences.game_preferences + )) { + const feature = features[featureId]; + + let nameInner: InfernoNode = feature?.name || featureId; + + if (feature?.description) { + nameInner = ( + + {nameInner} + + ); + } + + let name: InfernoNode = ( + + {nameInner} + + ); + + if (feature?.description) { + name = ( + + {name} + + ); + } + + const child = ( + + {name} + + + {feature && || ( + + ...is not filled out properly!!! + + )} + + + ); + + const entry = { + name: feature?.name || featureId, + children: child, + }; + + const category = feature?.category || "ERROR"; + + gamePreferences[category] + = binaryInsertPreference(gamePreferences[category] || [], entry); + } + + const gamePreferenceEntries: [string, InfernoNode][] = sortByName( + Object.entries(gamePreferences) + ).map( + ([category, preferences]) => { + return [category, preferences.map(entry => entry.children)]; + }); + + return ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx new file mode 100644 index 000000000000..d79fbcd40714 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx @@ -0,0 +1,413 @@ +import { sortBy } from 'common/collections'; +import { classes } from 'common/react'; +import { InfernoNode, SFC } from 'inferno'; +import { useBackend } from '../../backend'; +import { Box, Button, Dropdown, Stack, Tooltip } from '../../components'; +import { createSetPreference, Job, JoblessRole, JobPriority, PreferencesMenuData } from './data'; +import { ServerPreferencesFetcher } from './ServerPreferencesFetcher'; + +const sortJobs = (entries: [string, Job][], head?: string) => + sortBy<[string, Job]>( + ([key, _]) => (key === head ? -1 : 1), + ([key, _]) => key + )(entries); + +const PRIORITY_BUTTON_SIZE = '18px'; + +const PriorityButton = (props: { + name: string; + color: string; + modifier?: string; + enabled: boolean; + onClick: () => void; +}) => { + const className = `PreferencesMenu__Jobs__departments__priority`; + + return ( + + + ); + + if (typingHotkey && onClick) { + return ( + // onClick will cancel it + + {child} + + ); + } else { + return child; + } + } +} + +const KeybindingName = (props: { + keybinding: Keybinding, +}) => { + const { keybinding } = props; + + return keybinding.description + ? ( + + + {keybinding.name} + + + ) + : {keybinding.name}; +}; + +KeybindingName.defaultHooks = { + onComponentShouldUpdate: (lastProps, nextProps) => { + return lastProps.keybinding !== nextProps.keybinding; + }, +}; + +const ResetToDefaultButton = (props: { + keybindingId: string, +}, context) => { + const { act } = useBackend(context); + + return ( + + ); +}; + +export class KeybindingsPage extends Component<{}, KeybindingsPageState> { + cancelNextKeyUp?: number; + keybindingOnClicks: Record void)[]> = {}; + lastKeybinds?: PreferencesMenuData["keybindings"]; + + state: KeybindingsPageState = { + lastKeyboardEvent: undefined, + keybindings: undefined, + selectedKeybindings: undefined, + rebindingHotkey: undefined, + }; + + constructor() { + super(); + + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + componentDidMount() { + this.populateSelectedKeybindings(); + this.populateKeybindings(); + } + + componentDidUpdate() { + const { data } = useBackend(this.context); + + // keybindings is static data, so it'll pass `===` checks. + // This'll change when resetting to defaults. + if (data.keybindings !== this.lastKeybinds) { + this.populateSelectedKeybindings(); + } + } + + setRebindingHotkey(value?: string) { + const { act } = useBackend(this.context); + + this.setState((state) => { + let selectedKeybindings = state.selectedKeybindings; + if (!selectedKeybindings) { + return state; + } + + if (!state.rebindingHotkey) { + return state; + } + + selectedKeybindings = { ...selectedKeybindings }; + + const [keybindName, slot] = state.rebindingHotkey; + + if (selectedKeybindings[keybindName]) { + if (value) { + selectedKeybindings[keybindName][Math.min( + selectedKeybindings[keybindName].length, + slot + )] = value; + } else { + selectedKeybindings[keybindName].splice(slot, 1); + } + } else if (!value) { + return state; + } else { + selectedKeybindings[keybindName] = [value]; + } + + act("set_keybindings", { + "keybind_name": keybindName, + "hotkeys": selectedKeybindings[keybindName], + }); + + return { + lastKeyboardEvent: undefined, + rebindingHotkey: undefined, + selectedKeybindings, + }; + }); + } + + handleKeyDown(keyEvent: KeyEvent) { + const event = keyEvent.event; + const rebindingHotkey = this.state.rebindingHotkey; + + if (!rebindingHotkey) { + return; + } + + event.preventDefault(); + + this.cancelNextKeyUp = keyEvent.code; + + if (isStandardKey(event)) { + this.setRebindingHotkey(formatKeyboardEvent(event)); + return; + } else if (event.key === "Esc") { + this.setRebindingHotkey(undefined); + return; + } + + this.setState({ + lastKeyboardEvent: event, + }); + } + + handleKeyUp(keyEvent: KeyEvent) { + if (this.cancelNextKeyUp === keyEvent.code) { + this.cancelNextKeyUp = undefined; + keyEvent.event.preventDefault(); + } + + const { lastKeyboardEvent, rebindingHotkey } = this.state; + + if (rebindingHotkey && lastKeyboardEvent) { + this.setRebindingHotkey(formatKeyboardEvent(lastKeyboardEvent)); + } + } + + getKeybindingOnClick( + keybindingId: string, + slot: number, + ): () => void { + if (!this.keybindingOnClicks[keybindingId]) { + this.keybindingOnClicks[keybindingId] = []; + } + + if (!this.keybindingOnClicks[keybindingId][slot]) { + this.keybindingOnClicks[keybindingId][slot] = () => { + if (this.state.rebindingHotkey === undefined) { + this.setState({ + lastKeyboardEvent: undefined, + rebindingHotkey: [keybindingId, slot], + }); + } else { + this.setState({ + lastKeyboardEvent: undefined, + rebindingHotkey: undefined, + }); + } + }; + } + + return this.keybindingOnClicks[keybindingId][slot]; + } + + getTypingHotkey(keybindingId: string, slot: number): string | undefined { + const { lastKeyboardEvent, rebindingHotkey } = this.state; + + if (!rebindingHotkey) { + return undefined; + } + + if (rebindingHotkey[0] !== keybindingId + || rebindingHotkey[1] !== slot + ) { + return undefined; + } + + if (lastKeyboardEvent === undefined) { + return "..."; + } + + return formatKeyboardEvent(lastKeyboardEvent); + } + + async populateKeybindings() { + const keybindingsResponse = await fetchRetry(resolveAsset("keybindings.json")); + const keybindingsData: Keybindings = await keybindingsResponse.json(); + + this.setState({ + keybindings: keybindingsData, + }); + } + + populateSelectedKeybindings() { + const { data } = useBackend(this.context); + + this.lastKeybinds = data.keybindings; + + this.setState({ + selectedKeybindings: Object.fromEntries( + Object.entries(data.keybindings) + .map(([keybind, hotkeys]) => { + return [keybind, hotkeys.filter(value => value !== "Unbound")]; + }) + ), + }); + } + + render() { + const { act } = useBackend(this.context); + const keybindings = this.state.keybindings; + + if (!keybindings) { + return Loading keybindings...; + } + + const keybindingEntries = sortKeybindingsByCategory( + Object.entries(keybindings) + ); + + moveToBottom(keybindingEntries, "EMOTE"); + moveToBottom(keybindingEntries, "ADMIN"); + + return ( + <> + + + + + { + return [category, ( + + {sortKeybindings(Object.entries(keybindings)).map( + ([keybindingId, keybinding]) => { + const keys + = this.state.selectedKeybindings![keybindingId] + || []; + + const name = ( + + + + ); + + return ( + + + {name} + + {range(0, 3).map(key => ( + + + + ))} + + + + + + + ); + } + )} + + )]; + } + )} + /> + + + + act("reset_all_keybinds")} + /> + + + + + ); + } +} diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx new file mode 100644 index 000000000000..408cc297819e --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx @@ -0,0 +1,596 @@ +import { classes } from "common/react"; +import { sendAct, useBackend, useLocalState } from "../../backend"; +import { Autofocus, Box, Button, Flex, LabeledList, Popper, Stack, TrackOutsideClicks } from "../../components"; +import { createSetPreference, PreferencesMenuData, RandomSetting } from "./data"; +import { CharacterPreview } from "./CharacterPreview"; +import { RandomizationButton } from "./RandomizationButton"; +import { ServerPreferencesFetcher } from "./ServerPreferencesFetcher"; +import { MultiNameInput, NameInput } from "./names"; +import { Gender, GENDERS } from "./preferences/gender"; +import features from "./preferences/features"; +import { FeatureChoicedServerData, FeatureValueInput } from "./preferences/features/base"; +import { filterMap, sortBy } from "common/collections"; +import { useRandomToggleState } from "./useRandomToggleState"; + +const CLOTHING_CELL_SIZE = 48; +const CLOTHING_SIDEBAR_ROWS = 9; + +const CLOTHING_SELECTION_CELL_SIZE = 48; +const CLOTHING_SELECTION_WIDTH = 5.4; +const CLOTHING_SELECTION_MULTIPLIER = 5.2; + +const CharacterControls = (props: { + handleRotate: () => void, + handleOpenSpecies: () => void, + gender: Gender, + setGender: (gender: Gender) => void, + showGender: boolean, +}) => { + return ( + + + + + + + + + + + {Object.entries(catalog.icons).map(([name, image], index) => { + return ( + + + + ); + })} + + + + + + ); +}; + +const GenderButton = (props: { + handleSetGender: (gender: Gender) => void, + gender: Gender, +}, context) => { + const [genderMenuOpen, setGenderMenuOpen] = useLocalState(context, "genderMenuOpen", false); + + return ( + + {[Gender.Male, Gender.Female, Gender.Other].map(gender => { + return ( + + + + ); +}; + +const createSetRandomization = ( + act: typeof sendAct, + preference: string, +) => (newSetting: RandomSetting) => { + act("set_random_preference", { + preference, + value: newSetting, + }); +}; + +const sortPreferences = sortBy<[string, unknown]>( + ([featureId, _]) => { + const feature = features[featureId]; + return feature?.name; + }); + +const PreferenceList = (props: { + act: typeof sendAct, + preferences: Record, + randomizations: Record, +}) => { + return ( + + + { + sortPreferences(Object.entries(props.preferences)) + .map(([featureId, value]) => { + const feature = features[featureId]; + const randomSetting = props.randomizations[featureId]; + + if (feature === undefined) { + return ( + + Feature {featureId} is not recognized. + + ); + } + + return ( + + + {randomSetting && ( + + + + )} + + + + + + + ); + }) + } + + + ); +}; + +export const MainPage = (props: { + openSpecies: () => void, +}, context) => { + const { act, data } = useBackend(context); + const [currentClothingMenu, setCurrentClothingMenu] + = useLocalState(context, "currentClothingMenu", null); + const [multiNameInputOpen, setMultiNameInputOpen] + = useLocalState(context, "multiNameInputOpen", false); + const [randomToggleEnabled] = useRandomToggleState(context); + + return ( + { + const currentSpeciesData = serverData && serverData.species[ + data + .character_preferences + .misc + .species + ]; + + const contextualPreferences + = data.character_preferences.secondary_features || []; + + const mainFeatures = [ + ...Object.entries(data.character_preferences.clothing), + ...Object.entries(data.character_preferences.features) + .filter(([featureName]) => { + if (!currentSpeciesData) { + return false; + } + + return currentSpeciesData + .enabled_features + .indexOf(featureName) !== -1; + }), + ]; + + const randomBodyEnabled + = (data.character_preferences.non_contextual.random_body + !== RandomSetting.Disabled) + || randomToggleEnabled; + + const getRandomization = ( + preferences: Record + ): Record => { + if (!serverData) { + return {}; + } + + return Object.fromEntries(filterMap( + Object.keys(preferences), preferenceKey => { + if (serverData.random.randomizable.indexOf(preferenceKey) === -1) { + return undefined; + } + + if (!randomBodyEnabled) { + return undefined; + } + + return [ + preferenceKey, + data.character_preferences.randomization[preferenceKey] + || RandomSetting.Disabled, + ]; + })); + }; + + const randomizationOfMainFeatures + = getRandomization(Object.fromEntries(mainFeatures)); + + const nonContextualPreferences + = { + ...data.character_preferences.non_contextual, + }; + + if (randomBodyEnabled) { + nonContextualPreferences["random_species"] + = data.character_preferences.randomization["species"]; + } else { + // We can't use random_name/is_accessible because the + // server doesn't know whether the random toggle is on. + delete nonContextualPreferences["random_name"]; + } + + return ( + <> + {multiNameInputOpen && setMultiNameInputOpen(false)} + handleRandomizeName={preference => act("randomize_name", { + preference, + })} + handleUpdateName={(nameType, value) => act("set_preference", { + preference: nameType, + value, + })} + names={data.character_preferences.names} + />} + + + + + + { + act("rotate"); + }} + setGender={createSetPreference(act, "gender")} + showGender={ + currentSpeciesData ? !!currentSpeciesData.sexes : true + } + /> + + + + + + + + { + setMultiNameInputOpen(true); + }} + /> + + + + + + + + {mainFeatures + .map(([clothingKey, clothing]) => { + const catalog = ( + serverData + && serverData[clothingKey] as + FeatureChoicedServerData & { + name: string, + } + ); + + return catalog && ( + + { + setCurrentClothingMenu(null); + }} + handleOpen={() => { + setCurrentClothingMenu(clothingKey); + }} + handleSelect={createSetPreference(act, clothingKey)} + randomization={ + randomizationOfMainFeatures[clothingKey] + } + setRandomization={createSetRandomization( + act, + clothingKey, + )} + /> + + ); + })} + + + + + + + + + + + + + ); + }} /> + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx new file mode 100644 index 000000000000..8ac3de359759 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx @@ -0,0 +1,30 @@ +import { InfernoNode } from "inferno"; +import { Button } from "../../components"; + +export const PageButton =

(props: { + currentPage: P, + page: P, + otherActivePages?: P[], + + setPage: (page: P) => void, + + children?: InfernoNode, +}) => { + const pageIsActive = props.currentPage === props.page + || ( + props.otherActivePages + && props.otherActivePages.indexOf(props.currentPage) !== -1 + ); + + return ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx new file mode 100644 index 000000000000..e1f8d58bc762 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx @@ -0,0 +1,327 @@ +import { StatelessComponent } from "inferno"; +import { Box, Icon, Stack, Tooltip } from "../../components"; +import { PreferencesMenuData, Quirk } from "./data"; +import { useBackend, useLocalState } from "../../backend"; +import { ServerPreferencesFetcher } from "./ServerPreferencesFetcher"; + +const getValueClass = (value: number): string => { + if (value > 0) { + return "positive"; + } else if (value < 0) { + return "negative"; + } else { + return "neutral"; + } +}; + +const QuirkList = (props: { + quirks: [string, Quirk & { + failTooltip?: string; + }][], + onClick: (quirkName: string, quirk: Quirk) => void, +}) => { + return ( + // Stack is not used here for a variety of IE flex bugs + + {props.quirks.map(([quirkKey, quirk]) => { + const className = "PreferencesMenu__Quirks__QuirkList__quirk"; + + const child = ( + { + props.onClick(quirkKey, quirk); + }} + > + + + + + + + + + + + + + {quirk.name} + + + + {quirk.value} + + + + + + {quirk.description} + + + + + + ); + + if (quirk.failTooltip) { + return ( + + {child} + + ); + } else { + return child; + } + })} + + ); +}; + +const StatDisplay: StatelessComponent<{}> = (props) => { + return ( + + {props.children} + + ); +}; + +export const QuirksPage = (props, context) => { + const { act, data } = useBackend(context); + + const [selectedQuirks, setSelectedQuirks] = useLocalState( + context, + `selectedQuirks_${data.active_slot}`, + data.selected_quirks, + ); + + return ( + { + if (!data) { + return Loading quirks...; + } + + const { + max_positive_quirks: maxPositiveQuirks, + quirk_blacklist: quirkBlacklist, + quirk_info: quirkInfo, + } = data.quirks; + + const quirks = Object.entries(quirkInfo); + quirks.sort(([_, quirkA], [__, quirkB]) => { + if (quirkA.value === quirkB.value) { + return (quirkA.name > quirkB.name) ? 1 : -1; + } else { + return quirkA.value - quirkB.value; + } + }); + + let balance = 0; + let positiveQuirks = 0; + + for (const selectedQuirkName of selectedQuirks) { + const selectedQuirk = quirkInfo[selectedQuirkName]; + if (!selectedQuirk) { + continue; + } + + if (selectedQuirk.value > 0) { + positiveQuirks += 1; + } + + balance += selectedQuirk.value; + } + + const getReasonToNotAdd = (quirkName: string) => { + const quirk = quirkInfo[quirkName]; + + if ( + quirk.value > 0 + ) { + if (positiveQuirks >= maxPositiveQuirks) { + return "You can't have any more positive quirks!"; + } else if (balance + quirk.value > 0) { + return "You need a negative quirk to balance this out!"; + } + } + + const selectedQuirkNames = selectedQuirks.map(quirkKey => { + return quirkInfo[quirkKey].name; + }); + + for (const blacklist of quirkBlacklist) { + if (blacklist.indexOf(quirk.name) === -1) { + continue; + } + + for (const incompatibleQuirk of blacklist) { + if ( + incompatibleQuirk !== quirk.name + && selectedQuirkNames.indexOf(incompatibleQuirk) !== -1 + ) { + return `This is incompatible with ${incompatibleQuirk}!`; + } + } + } + + return undefined; + }; + + const getReasonToNotRemove = (quirkName: string) => { + const quirk = quirkInfo[quirkName]; + + if (balance - quirk.value > 0) { + return "You need to remove a positive quirk first!"; + } + + return undefined; + }; + + return ( + + + + + + Positive Quirks + + + + + + {positiveQuirks} / {maxPositiveQuirks} + + + + + + Available Quirks + + + + + { + if (getReasonToNotAdd(quirkName) !== undefined) { + return; + } + + setSelectedQuirks(selectedQuirks.concat(quirkName)); + + act("give_quirk", { quirk: quirk.name }); + }} + quirks={quirks.filter(([quirkName, _]) => { + return selectedQuirks + .indexOf(quirkName) === -1; + }).map(([quirkName, quirk]) => { + return [quirkName, { + ...quirk, + failTooltip: getReasonToNotAdd(quirkName), + }]; + })} + /> + + + + + + + + + + + + + Quirk Balance + + + + + + {balance} + + + + + + Current Quirks + + + + + { + if (getReasonToNotRemove(quirkName) !== undefined) { + return; + } + + setSelectedQuirks( + selectedQuirks + .filter(otherQuirk => quirkName !== otherQuirk), + ); + + act("remove_quirk", { quirk: quirk.name }); + }} + quirks={quirks.filter(([quirkName, _]) => { + return selectedQuirks + .indexOf(quirkName) !== -1; + }).map(([quirkName, quirk]) => { + return [quirkName, { + ...quirk, + failTooltip: getReasonToNotRemove(quirkName), + }]; + })} + /> + + + + + ); + }} + /> + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx new file mode 100644 index 000000000000..ea9a4a64a604 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx @@ -0,0 +1,64 @@ +import { Dropdown, Icon } from "../../components"; +import { RandomSetting } from "./data"; +import { exhaustiveCheck } from "common/exhaustive"; + +export const RandomizationButton = (props: { + dropdownProps?: Record, + setValue: (newValue: RandomSetting) => void, + value: RandomSetting, +}) => { + const { + dropdownProps = {}, + setValue, + value, + } = props; + + let color; + + switch (value) { + case RandomSetting.AntagOnly: + color = "orange"; + break; + case RandomSetting.Disabled: + color = "red"; + break; + case RandomSetting.Enabled: + color = "green"; + break; + default: + exhaustiveCheck(value); + } + + return ( + + )} + options={[ + { + displayText: "Do not randomize", + value: RandomSetting.Disabled, + }, + + { + displayText: "Always randomize", + value: RandomSetting.Enabled, + }, + + { + displayText: "Randomize when antagonist", + value: RandomSetting.AntagOnly, + }, + ]} + nochevron + onSelected={setValue} + width="auto" + /> + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx new file mode 100644 index 000000000000..d21cff506ac5 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx @@ -0,0 +1,38 @@ +import { Component, InfernoNode } from "inferno"; +import { resolveAsset } from "../../assets"; +import { fetchRetry } from "../../http"; +import { ServerData } from "./data"; + +// Cache response so it's only sent once +let fetchServerData: Promise | undefined; + +export class ServerPreferencesFetcher extends Component<{ + render: (serverData: ServerData | undefined) => InfernoNode, +}, { + serverData?: ServerData; +}> { + state = { + serverData: undefined, + }; + + componentDidMount() { + this.populateServerData(); + } + + async populateServerData() { + if (!fetchServerData) { + fetchServerData = fetchRetry(resolveAsset("preferences.json")) + .then(response => response.json()); + } + + const preferencesData: ServerData = await fetchServerData; + + this.setState({ + serverData: preferencesData, + }); + } + + render() { + return this.props.render(this.state.serverData); + } +} diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx new file mode 100644 index 000000000000..fa88dff01473 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx @@ -0,0 +1,345 @@ +import { classes } from 'common/react'; +import { useBackend } from '../../backend'; +import { BlockQuote, Box, Button, Divider, Icon, Section, Stack, Tooltip } from '../../components'; +import { CharacterPreview } from './CharacterPreview'; +import { createSetPreference, Food, Perk, PreferencesMenuData, ServerData, Species } from './data'; +import { ServerPreferencesFetcher } from './ServerPreferencesFetcher'; + +const FOOD_ICONS = { + [Food.Cloth]: 'tshirt', + [Food.Dairy]: 'cheese', + [Food.Fried]: 'bacon', + [Food.Fruit]: 'apple-alt', + [Food.Grain]: 'bread-slice', + [Food.Gross]: 'trash', + [Food.Junkfood]: 'pizza-slice', + [Food.Meat]: 'hamburger', + [Food.Raw]: 'drumstick-bite', + [Food.Sugar]: 'candy-cane', + [Food.Toxic]: 'biohazard', + [Food.Vegetables]: 'carrot', +}; + +const FOOD_NAMES: Record = { + [Food.Cloth]: 'Clothing', + [Food.Dairy]: 'Dairy', + [Food.Fried]: 'Fried food', + [Food.Fruit]: 'Fruit', + [Food.Grain]: 'Grain', + [Food.Gross]: 'Gross food', + [Food.Junkfood]: 'Junk food', + [Food.Meat]: 'Meat', + [Food.Raw]: 'Raw', + [Food.Sugar]: 'Sugar', + [Food.Toxic]: 'Toxic food', + [Food.Vegetables]: 'Vegetables', +}; + +const IGNORE_UNLESS_LIKED: Set = new Set([ + Food.Cloth, + Food.Gross, + Food.Toxic, +]); + +const notIn = function (set: Set) { + return (value: T) => { + return !set.has(value); + }; +}; + +const FoodList = (props: { + food: Food[]; + icon: string; + name: string; + className: string; +}) => { + if (props.food.length === 0) { + return null; + } + + return ( + + {props.name} + + + {props.food + .reduce((names, food) => { + const foodName = FOOD_NAMES[food]; + return foodName ? names.concat(foodName) : names; + }, []) + .join(', ')} + + + }> + + {props.food.map((food) => { + return ( + FOOD_ICONS[food] && ( + + + + ) + ); + })} + + + ); +}; + +const Diet = (props: { diet: Species['diet'] }) => { + if (!props.diet) { + return null; + } + + const { liked_food, disliked_food, toxic_food } = props.diet; + + return ( + + + + + + + + + + + + + + ); +}; + +const SpeciesPerk = (props: { className: string; perk: Perk }) => { + const { className, perk } = props; + + return ( + + {perk.name} + + {perk.description} + + }> + + + + + ); +}; + +const SpeciesPerks = (props: { perks: Species['perks'] }) => { + const { positive, negative, neutral } = props.perks; + + return ( + + + + {positive.map((perk) => { + return ( + + + + ); + })} + + + + + {neutral.map((perk) => { + return ( + + + + ); + })} + + + + {negative.map((perk) => { + return ( + + + + ); + })} + + + ); +}; + +const SpeciesPageInner = ( + props: { + handleClose: () => void; + species: ServerData['species']; + }, + context +) => { + const { act, data } = useBackend(context); + const setSpecies = createSetPreference(act, 'species'); + + let species: [string, Species][] = Object.entries(props.species).map( + ([species, data]) => { + return [species, data]; + } + ); + + // Humans are always the top of the list + const humanIndex = species.findIndex(([species]) => species === 'human'); + const swapWith = species[0]; + species[0] = species[humanIndex]; + species[humanIndex] = swapWith; + + const currentSpecies = species.filter(([speciesKey]) => { + return speciesKey === data.character_preferences.misc.species; + })[0][1]; + + return ( + + + + ); + })} + + + + + + + + +

+ ) + }> +
+ {currentSpecies.desc} +
+ +
+ +
+
+ + + + + + + + + +
+
+ {currentSpecies.lore.map((text, index) => ( + + {text} + {index !== currentSpecies.lore.length - 1 && ( + <> +
+
+ + )} +
+ ))} +
+
+
+ + + + + + ); +}; + +export const SpeciesPage = (props: { closeSpecies: () => void }) => { + return ( + { + if (serverData) { + return ( + + ); + } else { + return Loading species...; + } + }} + /> + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx new file mode 100644 index 000000000000..450bf0dc3e49 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx @@ -0,0 +1,94 @@ +import { Component, createRef, InfernoNode, RefObject } from "inferno"; +import { Button, Section, Stack } from "../../components"; +import { FlexProps } from "../../components/Flex"; + +type TabbedMenuProps = { + categoryEntries: [string, InfernoNode][], + contentProps?: FlexProps, +}; + +export class TabbedMenu extends Component { + categoryRefs: Record> = {}; + sectionRef: RefObject = createRef(); + + getCategoryRef(category: string): RefObject { + if (!this.categoryRefs[category]) { + this.categoryRefs[category] = createRef(); + } + + return this.categoryRefs[category]; + } + + render() { + return ( + + + + {this.props.categoryEntries.map(([category]) => { + return ( + + + + ); + })} + + + + + + {this.props.categoryEntries.map( + ([category, children]) => { + return ( + +
+ {children} +
+
+ ); + } + )} +
+
+
+ ); + } +} diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts new file mode 100644 index 000000000000..9f11609f29ff --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts @@ -0,0 +1,25 @@ +import { Antagonist, Category } from "../base"; +import { multiline } from "common/string"; + +export const TRAITOR_MECHANICAL_DESCRIPTION + = multiline` + Start with a set of sinister objectives and an uplink to purchase + items to get the job done. + `; + +const Traitor: Antagonist = { + key: "traitor", + name: "Traitor", + description: [ + multiline` + An unpaid debt. A score to be settled. Maybe you were just in the wrong + place at the wrong time. Whatever the reasons, you were selected to + infiltrate Space Station 13. + `, + TRAITOR_MECHANICAL_DESCRIPTION, + ], + category: Category.Roundstart, + priority: -1, +}; + +export default Traitor; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts new file mode 100644 index 000000000000..ce59209c2432 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts @@ -0,0 +1,35 @@ +/** + * This folder represents the antagonists you can choose in the preferences + * menu. + * + * Every file in this folder represents one antagonist. + * + * For example "Syndicate Sleeper Agent" -> syndicatesleeperagent.ts + * + * "Antagonist" in this context actually means ruleset. + * This is an important distinction--it means that players can choose to be + * a roundstart traitor, but not a latejoin traitor. + * + * Icons are generated from the antag datums themselves, provided by the + * `antag_datum` variable on the /datum/dynamic_ruleset. + * + * The icon used is whatever the return value of get_preview_icon() is. + * Most antagonists, unless they want an especially cool effect, can simply + * set preview_outfit to some typepath representing their character. + */ + + export type Antagonist = { + // the antag_flag, made lowercase, and with non-alphanumerics removed. + key: string; + + name: string; + description: string[]; + category: Category; + priority?: number; +}; + +export enum Category { + Roundstart, + Midround, + Latejoin, +} diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts new file mode 100644 index 000000000000..f536691ce870 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts @@ -0,0 +1,188 @@ +import { BooleanLike } from 'common/react'; +import { sendAct } from '../../backend'; +import { Gender } from './preferences/gender'; + +export enum Food { + Alcohol = 'ALCOHOL', + Breakfast = 'BREAKFAST', + Bugs = 'BUGS', + Cloth = 'CLOTH', + Dairy = 'DAIRY', + Fried = 'FRIED', + Fruit = 'FRUIT', + Grain = 'GRAIN', + Gross = 'GROSS', + Junkfood = 'JUNKFOOD', + Meat = 'MEAT', + Nuts = 'NUTS', + Pineapple = 'PINEAPPLE', + Raw = 'RAW', + Seafood = 'SEAFOOD', + Sugar = 'SUGAR', + Toxic = 'TOXIC', + Vegetables = 'VEGETABLES', +} + +export enum JobPriority { + Low = 1, + Medium = 2, + High = 3, +} + +export type Name = { + can_randomize: BooleanLike; + explanation: string; + group: string; +}; + +export type Species = { + name: string; + desc: string; + lore: string[]; + icon: string; + + use_skintones: BooleanLike; + sexes: BooleanLike; + + enabled_features: string[]; + + perks: { + positive: Perk[]; + negative: Perk[]; + neutral: Perk[]; + }; + + diet?: { + liked_food: Food[]; + disliked_food: Food[]; + toxic_food: Food[]; + }; +}; + +export type Perk = { + ui_icon: string; + name: string; + description: string; +}; + +export type Department = { + head?: string; +}; + +export type Job = { + description: string; + department: string; +}; + +export type Quirk = { + description: string; + icon: string; + name: string; + value: number; +}; + +export type QuirkInfo = { + max_positive_quirks: number; + quirk_info: Record; + quirk_blacklist: string[][]; +}; + +export enum RandomSetting { + AntagOnly = 1, + Disabled = 2, + Enabled = 3, +} + +export enum JoblessRole { + BeOverflow = 1, + BeRandomJob = 2, + ReturnToLobby = 3, +} + +export enum GamePreferencesSelectedPage { + Settings, + Keybindings, +} + +export const createSetPreference = + (act: typeof sendAct, preference: string) => (value: unknown) => { + act('set_preference', { + preference, + value, + }); + }; + +export enum Window { + Character = 0, + Game = 1, + Keybindings = 2, +} + +export type PreferencesMenuData = { + character_preview_view: string; + character_profiles: (string | null)[]; + + character_preferences: { + clothing: Record; + features: Record; + game_preferences: Record; + non_contextual: { + random_body: RandomSetting; + [otherKey: string]: unknown; + }; + secondary_features: Record; + supplemental_features: Record; + + names: Record; + + misc: { + gender: Gender; + joblessrole: JoblessRole; + species: string; + }; + + randomization: Record; + }; + + content_unlocked: BooleanLike; + + job_bans?: string[]; + job_days_left?: Record; + job_required_experience?: Record< + string, + { + experience_type: string; + required_playtime: number; + } + >; + job_preferences: Record; + + keybindings: Record; + overflow_role: string; + selected_quirks: string[]; + + antag_bans?: string[]; + antag_days_left?: Record; + selected_antags: string[]; + + active_slot: number; + name_to_use: string; + + window: Window; +}; + +export type ServerData = { + jobs: { + departments: Record; + jobs: Record; + }; + names: { + types: Record; + }; + quirks: QuirkInfo; + random: { + randomizable: string[]; + }; + species: Record; + [otheyKey: string]: unknown; +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx new file mode 100644 index 000000000000..f7b3f87c8a0a --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx @@ -0,0 +1,24 @@ +import { exhaustiveCheck } from "common/exhaustive"; +import { useBackend } from "../../backend"; +import { GamePreferencesSelectedPage, PreferencesMenuData, Window } from "./data"; +import { CharacterPreferenceWindow } from "./CharacterPreferenceWindow"; +import { GamePreferenceWindow } from "./GamePreferenceWindow"; + +export const PreferencesMenu = (props, context) => { + const { data } = useBackend(context); + + const window = data.window; + + switch (window) { + case Window.Character: + return ; + case Window.Game: + return ; + case Window.Keybindings: + return (); + default: + exhaustiveCheck(window); + } +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx new file mode 100644 index 000000000000..3b2395303b95 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx @@ -0,0 +1,235 @@ +import { binaryInsertWith, sortBy } from "common/collections"; +import { useLocalState } from "../../backend"; +import { Box, Button, FitText, Icon, Input, LabeledList, Modal, Section, Stack, TrackOutsideClicks } from "../../components"; +import { Name } from "./data"; +import { ServerPreferencesFetcher } from "./ServerPreferencesFetcher"; + +type NameWithKey = { + key: string, + name: Name, +}; + +const binaryInsertName = binaryInsertWith(({ key }) => key); + +const sortNameWithKeyEntries = sortBy<[string, NameWithKey[]]>( + ([key]) => key, +); + +export const MultiNameInput = (props: { + handleClose: () => void, + handleRandomizeName: (nameType: string) => void, + handleUpdateName: (nameType: string, value: string) => void, + names: Record, +}, context) => { + const [currentlyEditingName, setCurrentlyEditingName] + = useLocalState(context, "currentlyEditingName", null); + + return ( + { + if (!data) { + return null; + } + + const namesIntoGroups: Record = {}; + + for (const [key, name] of Object.entries(data.names.types)) { + namesIntoGroups[name.group] = binaryInsertName( + namesIntoGroups[name.group] || [], + { + key, + name, + } + ); + } + + return ( + + +
+ Close + + )} + title="Alternate names" + > + + {sortNameWithKeyEntries(Object.entries(namesIntoGroups)) + .map(([_, names], index, collection) => ( + <> + {names.map(({ key, name }) => { + let content; + + if (currentlyEditingName === key) { + const updateName = (event, value) => { + props.handleUpdateName( + key, + value, + ); + + setCurrentlyEditingName(null); + }; + + content = ( + { + setCurrentlyEditingName(null); + }} + value={props.names[key]} + /> + ); + } else { + content = ( + + ); + } + + return ( + + + + {content} + + + {!!name.can_randomize && ( + +
+
+
+ ); + }} /> + ); +}; + +export const NameInput = (props: { + handleUpdateName: (name: string) => void, + name: string, + openMultiNameInput: () => void, +}, context) => { + const [lastNameBeforeEdit, setLastNameBeforeEdit] + = useLocalState(context, "lastNameBeforeEdit", null); + const editing = lastNameBeforeEdit === props.name; + + const updateName = (e, value) => { + setLastNameBeforeEdit(null); + props.handleUpdateName(value); + }; + + return ( + + + ) : null} /> + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx new file mode 100644 index 000000000000..a582ac539ccd --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx @@ -0,0 +1,333 @@ +import { sortBy, sortStrings } from "common/collections"; +import { BooleanLike, classes } from "common/react"; +import { ComponentType, createComponentVNode, InfernoNode } from "inferno"; +import { VNodeFlags } from "inferno-vnode-flags"; +import { sendAct, useBackend, useLocalState } from "../../../../backend"; +import { Box, Button, Dropdown, NumberInput, Stack } from "../../../../components"; +import { createSetPreference, PreferencesMenuData } from "../../data"; +import { ServerPreferencesFetcher } from "../../ServerPreferencesFetcher"; + +export const sortChoices = sortBy<[string, InfernoNode]>(([name]) => name); + +export type Feature< + TReceiving, + TSending = TReceiving, + TServerData = undefined, +> = { + name: string; + component: FeatureValue< + TReceiving, + TSending, + TServerData + >; + category?: string; + description?: string; +}; + +/** + * Represents a preference. + * TReceiving = The type you will be receiving + * TSending = The type you will be sending + * TServerData = The data the server sends through preferences.json + */ +type FeatureValue< + TReceiving, + TSending = TReceiving, + TServerData = undefined, +> + = ComponentType>; + +export type FeatureValueProps< + TReceiving, + TSending = TReceiving, + TServerData = undefined, +> = { + act: typeof sendAct, + featureId: string, + handleSetValue: (newValue: TSending) => void, + serverData: TServerData | undefined, + shrink?: boolean, + value: TReceiving, +}; + +export const FeatureColorInput = (props: FeatureValueProps) => { + return ( + + ); +}; + +export type FeatureToggle = Feature; + +export const CheckboxInput = ( + props: FeatureValueProps +) => { + return ( { + props.handleSetValue(!props.value); + }} + />); +}; + +export const CheckboxInputInverse = ( + props: FeatureValueProps +) => { + return ( { + props.handleSetValue(!props.value); + }} + />); +}; + +export const createDropdownInput = ( + // Map of value to display texts + choices: Record, + dropdownProps?: Record, +): FeatureValue => { + return (props: FeatureValueProps) => { + return ( { + return { + displayText: label, + value: dataValue, + }; + })} + {...dropdownProps} + />); + }; +}; + +export type FeatureChoicedServerData = { + choices: string[]; + display_names?: Record; + icons?: Record; +}; + +export type FeatureChoiced = Feature; + +const capitalizeFirstLetter = (text: string) => ( + text.toString().charAt(0).toUpperCase() + text.toString().slice(1) +); + +export const StandardizedDropdown = (props: { + choices: string[], + disabled?: boolean, + displayNames: Record, + onSetValue: (newValue: string) => void, + value: string, +}) => { + const { + choices, + disabled, + displayNames, + onSetValue, + value, + } = props; + + return ( { + return { + displayText: displayNames[choice], + value: choice, + }; + }) + } + />); +}; + +export const FeatureDropdownInput = ( + props: FeatureValueProps & { + disabled?: boolean, + }, +) => { + const serverData = props.serverData; + if (!serverData) { + return null; + } + + const displayNames = serverData.display_names + || Object.fromEntries( + serverData.choices.map(choice => [choice, capitalizeFirstLetter(choice)]) + ); + + return (); +}; + +export type FeatureWithIcons = Feature< + { value: T }, + T, + FeatureChoicedServerData +>; + +export const FeatureIconnedDropdownInput = ( + props: FeatureValueProps<{ + value: string, + }, string, FeatureChoicedServerData>, +) => { + const serverData = props.serverData; + if (!serverData) { + return null; + } + + const icons = serverData.icons; + + const textNames = serverData.display_names + || Object.fromEntries( + serverData.choices.map(choice => [choice, capitalizeFirstLetter(choice)]) + ); + + const displayNames = Object.fromEntries( + Object.entries(textNames).map(([choice, textName]) => { + let element: InfernoNode = textName; + + if (icons && icons[choice]) { + const icon = icons[choice]; + element = ( + + + + + + + {element} + + + ); + } + + return [choice, element]; + }) + ); + + return (); +}; + + +export type FeatureNumericData = { + minimum: number, + maximum: number, + step: number, +} + +export type FeatureNumeric = Feature; + +export const FeatureNumberInput = ( + props: FeatureValueProps +) => { + if (!props.serverData) { + return Loading...; + } + + return ( { + props.handleSetValue(value); + }} + minValue={props.serverData.minimum} + maxValue={props.serverData.maximum} + step={props.serverData.step} + value={props.value} + />); +}; + +export const FeatureValueInput = (props: { + feature: Feature, + featureId: string, + shrink?: boolean, + value: unknown, + + act: typeof sendAct, +}, context) => { + const { data } = useBackend(context); + + const feature = props.feature; + + const [predictedValue, setPredictedValue] = useLocalState( + context, + `${props.featureId}_predictedValue_${data.active_slot}`, + props.value, + ); + + const changeValue = (newValue: unknown) => { + setPredictedValue(newValue); + createSetPreference(props.act, props.featureId)(newValue); + }; + + return ( + { + return createComponentVNode( + VNodeFlags.ComponentUnknown, + feature.component, + { + act: props.act, + featureId: props.featureId, + serverData: serverData && serverData[props.featureId], + shrink: props.shrink, + + handleSetValue: changeValue, + value: predictedValue, + }); + }} + /> + ); +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx new file mode 100644 index 000000000000..41889a1fc685 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx @@ -0,0 +1,6 @@ +import { Feature, FeatureNumberInput } from "../base"; + +export const age: Feature = { + name: "Age", + component: FeatureNumberInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx new file mode 100644 index 000000000000..b67e62dbe8d5 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx @@ -0,0 +1,6 @@ +import { FeatureIconnedDropdownInput, FeatureWithIcons } from "../base"; + +export const preferred_ai_core_display: FeatureWithIcons = { + name: "AI core display", + component: FeatureIconnedDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx new file mode 100644 index 000000000000..ce7d999614c0 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx @@ -0,0 +1,6 @@ +import { FeatureChoiced, FeatureDropdownInput } from "../base"; + +export const prefered_engineering_department: FeatureChoiced = { + name: "Engineering department", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx new file mode 100644 index 000000000000..823459a2a212 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx @@ -0,0 +1,15 @@ +import { Feature, FeatureColorInput, FeatureDropdownInput } from "../base"; + +export const pda_color: Feature = { + name: "PDA color", + category: "GAMEPLAY", + description: "The background color of your PDA.", + component: FeatureColorInput, +}; + +export const pda_style: Feature = { + name: "PDA style", + category: "GAMEPLAY", + description: "The style of your equipped PDA. Changes font.", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx new file mode 100644 index 000000000000..118f2cf46785 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx @@ -0,0 +1,6 @@ +import { FeatureChoiced, FeatureDropdownInput } from "../base"; + +export const prefered_security_department: FeatureChoiced = { + name: "Security department", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skillcape.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skillcape.tsx new file mode 100644 index 000000000000..7f581bd4f494 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skillcape.tsx @@ -0,0 +1,6 @@ +import { FeatureChoiced, FeatureDropdownInput } from "../base"; + +export const skillcape: FeatureChoiced = { + name: "Skillcape", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx new file mode 100644 index 000000000000..3ee93eb571bd --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx @@ -0,0 +1,65 @@ +import { sortBy } from "common/collections"; +import { Box, Stack } from "../../../../../components"; +import { Feature, FeatureChoicedServerData, FeatureValueProps, StandardizedDropdown } from "../base"; + +type HexValue = { + lightness: number, + value: string, +}; + +type SkinToneServerData = FeatureChoicedServerData & { + display_names: NonNullable, + to_hex: Record, +}; + +const sortHexValues + = sortBy<[string, HexValue]>(([_, hexValue]) => -hexValue.lightness); + +export const skin_tone: Feature = { + name: "Skin tone", + component: (props: FeatureValueProps) => { + const { + handleSetValue, + serverData, + value, + } = props; + + if (!serverData) { + return null; + } + + return ( + key)} + displayNames={Object.fromEntries( + Object.entries(serverData.display_names) + .map(([key, displayName]) => { + const hexColor = serverData.to_hex[key]; + + return [key, ( + + + + + + + {displayName} + + + )]; + }) + )} + onSetValue={handleSetValue} + value={value} + /> + ); + }, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx new file mode 100644 index 000000000000..459892597089 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx @@ -0,0 +1,6 @@ +import { FeatureChoiced, FeatureDropdownInput } from "../base"; + +export const uplink_loc: FeatureChoiced = { + name: "Uplink Spawn Location", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx new file mode 100644 index 000000000000..b59aa47fbae1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx @@ -0,0 +1,15 @@ +import { FeatureColorInput, Feature, FeatureDropdownInput } from "../base"; + +export const asaycolor: Feature = { + name: "Admin chat color", + category: "ADMIN", + description: "The color of your messages in Adminsay.", + component: FeatureColorInput, +}; + +export const brief_outfit: Feature = { + name: "Brief outfit", + category: "ADMIN", + description: "The outfit to gain when spawning as the briefing officer.", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx new file mode 100644 index 000000000000..4884c07377fb --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx @@ -0,0 +1,8 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const ambientocclusion: FeatureToggle = { + name: "Enable ambient occlusion", + category: "GAMEPLAY", + description: "Enable ambient occlusion, light shadows around characters.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx new file mode 100644 index 000000000000..5339de43d15a --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx @@ -0,0 +1,7 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const auto_fit_viewport: FeatureToggle = { + name: "Auto fit viewport", + category: "UI", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx new file mode 100644 index 000000000000..67849342094d --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx @@ -0,0 +1,8 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const buttons_locked: FeatureToggle = { + name: "Lock action buttons", + category: "GAMEPLAY", + description: "When enabled, action buttons will be locked in place.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx new file mode 100644 index 000000000000..55c091a9714f --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx @@ -0,0 +1,56 @@ +import { Dropdown, NumberInput, Stack } from "../../../../../components"; +import { Feature, FeatureNumericData, FeatureValueProps } from "../base"; + +type FpsServerData = FeatureNumericData & { + recommended_fps: number; +} + +const FpsInput = ( + props: FeatureValueProps +) => { + const { handleSetValue, serverData } = props; + + let recommened = `Recommended`; + if (serverData) { + recommened += ` (${serverData.recommended_fps})`; + } + + return ( + + + { + if (value === recommened) { + handleSetValue(-1); + } else { + handleSetValue(serverData?.recommended_fps || 60); + } + }} + width="100%" + options={[ + recommened, + "Custom", + ]} + /> + + + + {serverData && props.value !== -1 && ( { + props.handleSetValue(value); + }} + minValue={1} + maxValue={serverData.maximum} + value={props.value} + />)} + + + ); +}; + +export const clientfps: Feature = { + name: "FPS", + category: "GAMEPLAY", + component: FpsInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx new file mode 100644 index 000000000000..c9dc71ab1fa9 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx @@ -0,0 +1,139 @@ +import { multiline } from "common/string"; +import { CheckboxInput, FeatureChoiced, FeatureChoicedServerData, FeatureDropdownInput, FeatureToggle, FeatureValueProps } from "../base"; +import { Box, Dropdown, Flex } from "../../../../../components"; +import { classes } from "common/react"; +import { InfernoNode } from "inferno"; +import { binaryInsertWith } from "common/collections"; +import { useBackend } from "../../../../../backend"; +import { PreferencesMenuData } from "../../../data"; + +export const ghost_accs: FeatureChoiced = { + name: "Ghost accessories", + category: "GHOST", + description: "Determines what adjustments your ghost will have.", + component: FeatureDropdownInput, +}; + +const insertGhostForm + = binaryInsertWith<{ + displayText: InfernoNode, + value: string, + }>(({ value }) => value); + +const GhostFormInput = ( + props: FeatureValueProps, + context, +) => { + const { data } = useBackend(context); + + const serverData = props.serverData; + if (!serverData) { + return; + } + + const displayNames = serverData.display_names; + if (!displayNames) { + return ( + + No display names for ghost_form! + + ); + } + + const displayTexts = {}; + let options: { + displayText: InfernoNode, + value: string, + }[] = []; + + for (const [name, displayName] of Object.entries(displayNames)) { + const displayText = ( + + + + + + + {displayName} + + + ); + + displayTexts[name] = displayText; + + const optionEntry = { + displayText, + value: name, + }; + + // Put the default ghost on top + if (name === "ghost") { + options.unshift(optionEntry); + } else { + options = insertGhostForm(options, optionEntry); + } + } + + return (); +}; + +export const ghost_form: FeatureChoiced = { + name: "Ghosts form", + category: "GHOST", + description: "The appearance of your ghost. Requires BYOND membership.", + component: GhostFormInput, +}; + +export const ghost_hud: FeatureToggle = { + name: "Ghost HUD", + category: "GHOST", + description: "Enable HUD buttons for ghosts.", + component: CheckboxInput, +}; + +export const ghost_orbit: FeatureChoiced = { + name: "Ghost orbit", + category: "GHOST", + description: multiline` + The shape in which your ghost will orbit. + Requires BYOND membership. + `, + component: ( + props: FeatureValueProps, + context, + ) => { + const { data } = useBackend(context); + + return (); + }, +}; + +export const ghost_others: FeatureChoiced = { + name: "Ghosts of others", + category: "GHOST", + description: multiline` + 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? + `, + component: FeatureDropdownInput, +}; + +export const inquisitive_ghost: FeatureToggle = { + name: "Ghost inquisitiveness", + category: "GHOST", + description: "Clicking on something as a ghost will examine it.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx new file mode 100644 index 000000000000..73cc3c9de16e --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx @@ -0,0 +1,8 @@ +import { CheckboxInputInverse, FeatureToggle } from "../base"; + +export const hotkeys: FeatureToggle = { + name: "Classic hotkeys", + category: "GAMEPLAY", + description: "When enabled, will revert to the legacy hotkeys, using the input bar rather than popups.", + component: CheckboxInputInverse, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx new file mode 100644 index 000000000000..dc359e57d459 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx @@ -0,0 +1,89 @@ +import { multiline } from "common/string"; +import { FeatureToggle, CheckboxInput } from "../base"; + +export const chat_bankcard: FeatureToggle = { + name: "Enable income updates", + category: "CHAT", + description: "Receive notifications for your bank account.", + component: CheckboxInput, +}; + +export const chat_dead: FeatureToggle = { + name: "Enable deadchat", + category: "ADMIN", + component: CheckboxInput, +}; + +export const chat_ghostears: FeatureToggle = { + name: "Hear all messages", + category: "GHOST", + description: multiline` + When enabled, you will be able to hear all speech as a ghost. + When disabled, you will only be able to hear nearby speech. + `, + component: CheckboxInput, +}; + +export const chat_ghostlaws: FeatureToggle = { + name: "Enable law change updates", + category: "GHOST", + description: "When enabled, be notified of any new law changes as a ghost.", + component: CheckboxInput, +}; + +export const chat_ghostpda: FeatureToggle = { + name: "Enable PDA notifications", + category: "GHOST", + description: "When enabled, be notified of any PDA messages as a ghost.", + component: CheckboxInput, +}; + +export const chat_ghostradio: FeatureToggle = { + name: "Enable radio", + category: "GHOST", + description: "When enabled, be notified of any radio messages as a ghost.", + component: CheckboxInput, +}; + +export const chat_ghostsight: FeatureToggle = { + name: "See all emotes", + category: "GHOST", + description: "When enabled, see all emotes as a ghost.", + component: CheckboxInput, +}; + +export const chat_ghostwhisper: FeatureToggle = { + name: "See all whispers", + category: "GHOST", + description: multiline` + When enabled, you will be able to hear all whispers as a ghost. + When disabled, you will only be able to hear nearby whispers. + `, + component: CheckboxInput, +}; + +export const chat_login_logout: FeatureToggle = { + name: "See login/logout messages", + category: "GHOST", + description: "When enabled, be notified when a player logs in or out.", + component: CheckboxInput, +}; + +export const chat_ooc: FeatureToggle = { + name: "Enable OOC", + category: "CHAT", + component: CheckboxInput, +}; + +export const chat_prayer: FeatureToggle = { + name: "Listen to prayers", + category: "ADMIN", + component: CheckboxInput, +}; + +export const chat_pullr: FeatureToggle = { + name: "Enable pull request notifications", + category: "CHAT", + description: "Be notified when a pull request is made, closed, or merged.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx new file mode 100644 index 000000000000..6b6c44661e5c --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx @@ -0,0 +1,153 @@ +import { multiline } from "common/string"; +import { FeatureToggle, CheckboxInput, CheckboxInputInverse } from "../base"; + +export const admin_ignore_cult_ghost: FeatureToggle = { + name: "Prevent being summoned as a cult ghost", + category: "ADMIN", + description: multiline` + When enabled and observing, prevents Spirit Realm from forcing you + into a cult ghost. + `, + component: CheckboxInput, +}; + +export const announce_login: FeatureToggle = { + name: "Announce login", + category: "ADMIN", + description: "Admins will be notified when you login.", + component: CheckboxInput, +}; + +export const combohud_lighting: FeatureToggle = { + name: "Enable fullbright Combo HUD", + category: "ADMIN", + component: CheckboxInput, +}; + +export const deadmin_always: FeatureToggle = { + name: "Auto deadmin - Always", + category: "ADMIN", + description: "When enabled, you will automatically deadmin.", + component: CheckboxInput, +}; + +export const deadmin_antagonist: FeatureToggle = { + name: "Auto deadmin - Antagonist", + category: "ADMIN", + description: "When enabled, you will automatically deadmin as an antagonist.", + component: CheckboxInput, +}; + +export const deadmin_position_head: FeatureToggle = { + name: "Auto deadmin - Head of Staff", + category: "ADMIN", + description: "When enabled, you will automatically deadmin as a head of staff.", + component: CheckboxInput, +}; + +export const deadmin_position_security: FeatureToggle = { + name: "Auto deadmin - Security", + category: "ADMIN", + description: "When enabled, you will automatically deadmin as a member of security.", + component: CheckboxInput, +}; + +export const deadmin_position_silicon: FeatureToggle = { + name: "Auto deadmin - Silicon", + category: "ADMIN", + description: "When enabled, you will automatically deadmin as a silicon.", + component: CheckboxInput, +}; + +export const disable_arrivalrattle: FeatureToggle = { + name: "Notify for new arrivals", + category: "GHOST", + description: "When enabled, you will be notified as a ghost for new crew.", + component: CheckboxInputInverse, +}; + +export const disable_deathrattle: FeatureToggle = { + name: "Notify for deaths", + category: "GHOST", + description: "When enabled, you will be notified as a ghost whenever someone dies.", + component: CheckboxInputInverse, +}; + +export const member_public: FeatureToggle = { + name: "Publicize BYOND membership", + category: "CHAT", + description: "When enabled, a BYOND logo will be shown next to your name in OOC.", + component: CheckboxInput, +}; + +export const sound_adminhelp: FeatureToggle = { + name: "Enable adminhelp sounds", + category: "ADMIN", + component: CheckboxInput, +}; + +export const sound_ambience: FeatureToggle = { + name: "Enable ambience", + category: "SOUND", + component: CheckboxInput, +}; + +export const sound_announcements: FeatureToggle = { + name: "Enable announcement sounds", + category: "SOUND", + description: "When enabled, hear sounds for command reports, notices, etc.", + component: CheckboxInput, +}; + +export const sound_combatmode: FeatureToggle = { + name: "Enable combat mode sound", + category: "SOUND", + description: "When enabled, hear sounds when toggling combat mode.", + component: CheckboxInput, +}; + +export const sound_endofround: FeatureToggle = { + name: "Enable end of round sounds", + category: "SOUND", + description: "When enabled, hear a sound when the server is rebooting.", + component: CheckboxInput, +}; + +export const sound_instruments: FeatureToggle = { + name: "Enable instruments", + category: "SOUND", + description: "When enabled, be able hear instruments in game.", + component: CheckboxInput, +}; + +export const sound_lobby: FeatureToggle = { + name: "Enable lobby music", + category: "SOUND", + component: CheckboxInput, +}; + +export const sound_midi: FeatureToggle = { + name: "Enable admin music", + category: "SOUND", + description: "When enabled, admins will be able to play music to you.", + component: CheckboxInput, +}; + +export const sound_prayers: FeatureToggle = { + name: "Enable prayer sound", + category: "ADMIN", + component: CheckboxInput, +}; + +export const sound_ship_ambience: FeatureToggle = { + name: "Enable ship ambience", + category: "SOUND", + component: CheckboxInput, +}; + +export const split_admin_tabs: FeatureToggle = { + name: "Split admin tabs", + category: "ADMIN", + description: "When enabled, will split the 'Admin' panel into several tabs.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx new file mode 100644 index 000000000000..e4f5c5466f0e --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx @@ -0,0 +1,8 @@ +import { FeatureColorInput, Feature } from "../base"; + +export const ooccolor: Feature = { + name: "OOC color", + category: "CHAT", + description: "The color of your OOC messages.", + component: FeatureColorInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx new file mode 100644 index 000000000000..1fbb2230ff20 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx @@ -0,0 +1,7 @@ +import { Feature, FeatureDropdownInput } from "../base"; + +export const parallax: Feature = { + name: "Parallax (fancy space)", + category: "GAMEPLAY", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx new file mode 100644 index 000000000000..bb07453e340f --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx @@ -0,0 +1,13 @@ +import { createDropdownInput, Feature } from "../base"; + +export const pixel_size: Feature = { + name: "Pixel Scaling", + category: "UI", + component: createDropdownInput({ + 0: "Stretch to fit", + 1: "Pixel Perfect 1x", + 1.5: "Pixel Perfect 1.5x", + 2: "Pixel Perfect 2x", + 3: "Pixel Perfect 3x", + }), +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx new file mode 100644 index 000000000000..3acfad00445a --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx @@ -0,0 +1,13 @@ +import { multiline } from "common/string"; +import { Feature, FeatureDropdownInput } from "../base"; + +export const preferred_map: Feature = { + name: "Preferred map", + category: "GAMEPLAY", + description: multiline` + During map rotation, prefer this map be chosen. + This does not affect the map vote, only random rotation when a vote + is not held. + `, + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx new file mode 100644 index 000000000000..d85a814d8add --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx @@ -0,0 +1,29 @@ +import { CheckboxInput, FeatureNumberInput, FeatureNumeric, FeatureToggle } from "../base"; + +export const chat_on_map: FeatureToggle = { + name: "Enable Runechat", + category: "RUNECHAT", + description: "Chat messages will show above heads.", + component: CheckboxInput, +}; + +export const see_chat_non_mob: FeatureToggle = { + name: "Enable Runechat on objects", + category: "RUNECHAT", + description: "Chat messages will show above objects when they speak.", + component: CheckboxInput, +}; + +export const see_rc_emotes: FeatureToggle = { + name: "Enable Runechat emotes", + category: "RUNECHAT", + description: "Emotes will show above heads.", + component: CheckboxInput, +}; + +export const max_chat_length: FeatureNumeric = { + name: "Max chat length", + category: "RUNECHAT", + description: "The maximum length a Runechat message will show as.", + component: FeatureNumberInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx new file mode 100644 index 000000000000..114cc80ef5e8 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx @@ -0,0 +1,11 @@ +import { createDropdownInput, Feature } from "../base"; + +export const scaling_method: Feature = { + name: "Scaling method", + category: "UI", + component: createDropdownInput({ + blur: "Bilinear", + distort: "Nearest Neighbor", + normal: "Point Sampling", + }), +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx new file mode 100644 index 000000000000..f436e9d45a59 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx @@ -0,0 +1,15 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const tgui_fancy: FeatureToggle = { + name: "Enable fancy tgui", + category: "UI", + description: "Makes tgui windows look better, at the cost of compatibility.", + component: CheckboxInput, +}; + +export const tgui_lock: FeatureToggle = { + name: "Lock tgui to main monitor", + category: "UI", + description: "Locks tgui windows to your main monitor.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx new file mode 100644 index 000000000000..83a315bccac1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx @@ -0,0 +1,63 @@ +import { classes } from "common/react"; +import { FeatureChoiced, FeatureChoicedServerData, FeatureValueProps, sortChoices } from "../base"; +import { Box, Dropdown, Stack } from "../../../../../components"; + +const UIStyleInput = ( + props: FeatureValueProps +) => { + const { serverData, value } = props; + if (!serverData) { + return null; + } + + const { icons } = serverData; + + if (!icons) { + return (ui_style had no icons!); + } + + const choices = Object.fromEntries( + Object.entries(icons) + .map(([name, icon]) => { + return [name, ( + + + + + + + {name} + + + )]; + }) + ); + + return ( + { + return { + displayText: label, + value: dataValue, + }; + })} + /> + ); +}; + +export const UI_style: FeatureChoiced = { + name: "UI Style", + category: "UI", + component: UIStyleInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx new file mode 100644 index 000000000000..e4d7595775ee --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx @@ -0,0 +1,7 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const widescreenpref: FeatureToggle = { + name: "Enable widescreen", + category: "UI", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx new file mode 100644 index 000000000000..58f32c947688 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx @@ -0,0 +1,12 @@ +import { multiline } from "common/string"; +import { CheckboxInput, FeatureToggle } from "../base"; + +export const windowflashing: FeatureToggle = { + name: "Enable window flashing", + category: "UI", + description: multiline` + When toggled, some important events will make your game icon flash on your + task tray. + `, + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts new file mode 100644 index 000000000000..d487a282e58c --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts @@ -0,0 +1,23 @@ +// Unlike species and others, feature files export arrays of features +// rather than individual ones. This is because a lot of features are +// extremely small, and so it's easier for everyone to just combine them +// together. +// This still helps to prevent the server from needing to send client UI data +import { Feature } from "./base"; + +// while also preventing downstreams from needing to mutate existing files. +const features: Record> = {}; + +const requireFeature = require.context("./", true, /.tsx$/); + +for (const key of requireFeature.keys()) { + if (key === "index" || key === "base") { + continue; + } + + for (const [featureKey, feature] of Object.entries(requireFeature(key))) { + features[featureKey] = feature as Feature; + } +} + +export default features; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx new file mode 100644 index 000000000000..3d47560edf9e --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx @@ -0,0 +1,90 @@ +import { useBackend } from "../../../../backend"; +import { Button, Stack } from "../../../../components"; +import { PreferencesMenuData, RandomSetting } from "../../data"; +import { RandomizationButton } from "../../RandomizationButton"; +import { useRandomToggleState } from "../../useRandomToggleState"; +import { CheckboxInput, Feature, FeatureToggle } from "./base"; + +export const random_body: Feature = { + name: "Random body", + component: (props, context) => { + const [randomToggle, setRandomToggle] = useRandomToggleState(context); + + return ( + + + props.handleSetValue(newValue)} + value={props.value} + /> + + + { + randomToggle + ? ( + <> + + + + + + + + + ) + : ( + + + + ) + } + + + ); + }, +}; + +export const random_hardcore: FeatureToggle = { + name: "Hardcore random", + component: CheckboxInput, +}; + +export const random_name: Feature = { + name: "Random name", + component: (props, context) => { + return ( + props.handleSetValue(value)} + value={props.value} + /> + ); + }, +}; + +export const random_species: Feature = { + name: "Random species", + component: (props, context) => { + const { act, data } = useBackend(context); + + const species = data.character_preferences.randomization["species"]; + + return ( + act("set_random_preference", { + preference: "species", + value: newValue, + })} + value={species || RandomSetting.Disabled} + /> + ); + }, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/species_features.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/species_features.tsx new file mode 100644 index 000000000000..aafa1588fde8 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/species_features.tsx @@ -0,0 +1,51 @@ +import { FeatureColorInput, Feature, FeatureChoiced, FeatureDropdownInput } from "./base"; + +export const eye_color: Feature = { + name: "Eye color", + component: FeatureColorInput, +}; + +export const facial_hair_color: Feature = { + name: "Facial hair color", + component: FeatureColorInput, +}; + +export const hair_color: Feature = { + name: "Hair color", + component: FeatureColorInput, +}; + +export const feature_human_ears: FeatureChoiced = { + name: "Ears", + component: FeatureDropdownInput, +}; + +export const feature_human_tail: FeatureChoiced = { + name: "Tail", + component: FeatureDropdownInput, +}; + +export const feature_lizard_legs: FeatureChoiced = { + name: "Legs", + component: FeatureDropdownInput, +}; + +export const feature_lizard_spines: FeatureChoiced = { + name: "Spines", + component: FeatureDropdownInput, +}; + +export const feature_lizard_tail: FeatureChoiced = { + name: "Tail", + component: FeatureDropdownInput, +}; + +export const feature_mcolor: Feature = { + name: "Mutant color", + component: FeatureColorInput, +}; + +export const underwear_color: Feature = { + name: "Underwear color", + component: FeatureColorInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts new file mode 100644 index 000000000000..787a3c900cb0 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts @@ -0,0 +1,22 @@ +export enum Gender { + Male = "male", + Female = "female", + Other = "plural", +} + +export const GENDERS = { + [Gender.Male]: { + icon: "male", + text: "Male", + }, + + [Gender.Female]: { + icon: "female", + text: "Female", + }, + + [Gender.Other]: { + icon: "tg-non-binary", + text: "Other", + }, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts new file mode 100644 index 000000000000..6721ee70cf5c --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts @@ -0,0 +1,4 @@ +import { useLocalState } from "../../backend"; + +export const useRandomToggleState + = context => useLocalState(context, "randomToggle", false); diff --git a/tgui/packages/tgui/logging.js b/tgui/packages/tgui/logging.js index 663d524bdc61..11241611eed8 100644 --- a/tgui/packages/tgui/logging.js +++ b/tgui/packages/tgui/logging.js @@ -19,6 +19,7 @@ const log = (level, ns, ...args) => { } // Send important logs to the backend if (level >= LEVEL_INFO) { + // prettier-ignore const logEntry = [ns, ...args] .map(value => { if (typeof value === 'string') { @@ -32,9 +33,7 @@ const log = (level, ns, ...args) => { .filter(value => value) .join(' ') + '\nUser Agent: ' + navigator.userAgent; - Byond.topic({ - tgui: 1, - window_id: window.__windowId__, + Byond.sendMessage({ type: 'log', ns, message: logEntry, @@ -42,7 +41,7 @@ const log = (level, ns, ...args) => { } }; -export const createLogger = ns => { +export const createLogger = (ns) => { return { debug: (...args) => log(LEVEL_DEBUG, ns, ...args), log: (...args) => log(LEVEL_LOG, ns, ...args), diff --git a/tgui/packages/tgui/package.json b/tgui/packages/tgui/package.json index a0572e71ca1e..0f9cba52939d 100644 --- a/tgui/packages/tgui/package.json +++ b/tgui/packages/tgui/package.json @@ -5,9 +5,11 @@ "dependencies": { "@popperjs/core": "^2.9.3", "common": "workspace:*", + "dateformat": "^4.5.1", "dompurify": "^2.3.1", "inferno": "^7.4.8", "inferno-vnode-flags": "^7.4.8", + "js-yaml": "^4.1.0", "marked": "^4.0.10", "tgui-dev-server": "workspace:*", "tgui-polyfill": "workspace:*" diff --git a/tgui/packages/tgui/styles/atomic/centered-image.scss b/tgui/packages/tgui/styles/atomic/centered-image.scss new file mode 100644 index 000000000000..cce5bfdf2c11 --- /dev/null +++ b/tgui/packages/tgui/styles/atomic/centered-image.scss @@ -0,0 +1,7 @@ +.centered-image { + position: absolute; + height: 100%; + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(-50%) scale(0.8); +} diff --git a/tgui/packages/tgui/styles/atomic/fit-text.scss b/tgui/packages/tgui/styles/atomic/fit-text.scss new file mode 100644 index 000000000000..2a899601ba26 --- /dev/null +++ b/tgui/packages/tgui/styles/atomic/fit-text.scss @@ -0,0 +1,10 @@ +$mqIterations: 19; +@mixin fontResize($iterations) { + $i: 1; + @while $i <= $iterations { + @media all and (min-width: 100px * $i) { .fit-text { font-size:0.1em * $i; } } + $i: $i + 1; + } +} + +@include fontResize($mqIterations); diff --git a/tgui/packages/tgui/styles/colors.scss b/tgui/packages/tgui/styles/colors.scss index bc6c5067ce24..49ccac082196 100644 --- a/tgui/packages/tgui/styles/colors.scss +++ b/tgui/packages/tgui/styles/colors.scss @@ -24,6 +24,7 @@ $pink: #e03997 !default; $magenta: #ff0fff !default; $brown: #a5673f !default; $grey: #767676 !default; +$light-grey: #aaa !default; $primary: #4972a1 !default; $good: #5baa27 !default; @@ -60,6 +61,7 @@ $_gen_map: ( 'pink': $pink, 'brown': $brown, 'grey': $grey, + 'light-grey': $light-grey, 'good': $good, 'average': $average, 'bad': $bad, diff --git a/tgui/packages/tgui/styles/components/Section.scss b/tgui/packages/tgui/styles/components/Section.scss index ca3e67b90693..d1f81b1f7669 100644 --- a/tgui/packages/tgui/styles/components/Section.scss +++ b/tgui/packages/tgui/styles/components/Section.scss @@ -65,10 +65,6 @@ $separator-color: colors.$primary !default; flex-grow: 1; } -.Section__content { - height: 100%; -} - .Section--fill > .Section__rest > .Section__content { height: 100%; } diff --git a/tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss b/tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss new file mode 100644 index 000000000000..e70d235e0e10 --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss @@ -0,0 +1,240 @@ +@use 'sass:color'; +@use 'sass:map'; +@use '../components/Button.scss'; +@use '../colors.scss'; + +$department_map: ( + 'Assistant': colors.$grey, + 'Captain': colors.fg(colors.$blue), + 'Cargo': colors.$brown, + 'Command': colors.$yellow, + 'Security': colors.$red, + 'Engineering': #f1a839, + 'Medical': colors.$teal, + 'Science': colors.fg(colors.$purple), + 'Service': colors.$green, + 'Silicon': colors.$pink, +); + +.PreferencesMenu { + &__Antags { + &__antagSelection { + $antagonist_bottom_padding: 10px; + + margin-bottom: -$antagonist_bottom_padding; + + @mixin animate-hover { + .antagonist-icon-parent .antagonist-icon { + &:hover { + transform: scale(1.3); + transition: transform 1s ease-out; + } + } + } + + &__antagonist { + padding-bottom: $antagonist_bottom_padding; + padding-right: 20px; + + .antagonist-icon-parent { + border-style: solid; + border-radius: 50%; + border-width: 4px; + box-sizing: content-box; + overflow: hidden; + position: relative; + + height: 96px; + width: 96px; + + .antagonist-icon { + border-radius: 50%; + -ms-interpolation-mode: nearest-neighbor; + overflow: hidden; + transition: transform 0.1s ease-in; + } + } + + &--off { + @include animate-hover; + + .antagonist-icon-parent { + border-color: colors.$red; + + .antagonist-icon { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + } + } + + &--on { + @include animate-hover; + + .antagonist-icon-parent { + border-color: colors.$green; + } + } + + &--banned { + .antagonist-icon-parent { + border-color: colors.$grey; + } + + .antagonist-icon { + opacity: 0.5; + } + } + + .antagonist-banned-slash { + background: colors.$grey; + + width: 100%; + height: 3px; + + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%) translateX(-50%) rotate(35deg); + + opacity: 0.8; + } + + .antagonist-days-left { + text-align: center; + text-shadow: 1px 1px 1px #222; + + width: 100%; + + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%) translateX(-50%); + } + } + } + } + + &__Jobs { + > * { + flex: 1; + } + + &__departments { + @each $department-name, $color-value in $department_map { + &--#{$department-name} { + &.head { + background: $color-value; + + .job-name { + font-weight: bold; + } + } + + background: colors.fg($color-value); + border-bottom: 2px solid rgba(0, 0, 0, 0.3); + border-left: 2px solid rgba(0, 0, 0, 0.3); + border-right: 2px solid rgba(0, 0, 0, 0.3); + color: black; + + > * { + height: calc(100% + 0.2em); + padding-bottom: 0.2em; + } + + &:first-child { + border-top: 2px solid rgba(0, 0, 0, 0.3); + } + + .options { + background: rgba(0, 0, 0, 0.2); + height: 100%; + } + } + + &--Captain { + border: 3px solid rgba(200, 200, 0, 1); + + &:first-child { + border-top: 3px solid rgba(200, 200, 0, 1); + } + + .job-name { + font-size: 17px; + } + } + } + + &__priority { + border: 1px solid rgba(0, 0, 0, 0.3); + + &--off::after { + content: ""; + + background: rgba(0, 0, 0, 0.2); + display: block; + height: 80%; + left: 50%; + position: relative; + top: 50%; + transform: translateX(-50%) translateY(-50%) rotate(40deg); + width: 2px; + } + } + } + + &__PriorityHeader { + font-weight: bold; + transform: + translateX(-4px) + translateY(-8px) + rotate(315deg); + white-space: nowrap; + width: 19px; + } + } + + &__Quirks { + &__QuirkList { + background-color: colors.$light-grey; + height: calc(90vh - 170px); + min-height: 100%; + overflow-y: scroll; + + &__quirk { + background-color: colors.$white; + border-bottom: 1px solid black; + color: #111; + transition: background-color 0.1s ease-in; + + $quality_map: ( + "positive": colors.$green, + "neutral": colors.$white, + "negative": colors.$red, + ); + + @each $quality, $color-value in $quality_map { + &--#{$quality} { + background-color: $color-value; + transition: background-color 0.1s ease-in; + } + } + + &:hover { + background-color: colors.$grey; + transition: background-color 0.1s ease-out; + + @each $quality, $color-value in $quality_map { + .PreferencesMenu__Quirks__QuirkList__quirk--#{$quality} { + background-color: color.scale($color-value, $lightness: -25%); + transition: background-color 0.1s ease-out; + } + } + } + } + } + } +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index f72afbabd53d..6bb80e2c301f 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -11,8 +11,10 @@ // Atomic classes @include meta.load-css('./atomic/candystripe.scss'); +@include meta.load-css('./atomic/centered-image.scss'); @include meta.load-css('./atomic/color.scss'); @include meta.load-css('./atomic/debug-layout.scss'); +@include meta.load-css('./atomic/fit-text.scss'); @include meta.load-css('./atomic/outline.scss'); @include meta.load-css('./atomic/text.scss'); @@ -44,6 +46,7 @@ @include meta.load-css('./interfaces/ListInput.scss'); @include meta.load-css('./interfaces/AlertModal.scss'); @include meta.load-css('./interfaces/CameraConsole.scss'); +@include meta.load-css('./interfaces/PreferencesMenu.scss'); @include meta.load-css('./interfaces/NuclearBomb.scss'); @include meta.load-css('./interfaces/Roulette.scss'); @include meta.load-css('./interfaces/Safe.scss'); diff --git a/tgui/yarn.lock b/tgui/yarn.lock index d038935ef43c..a72820ac4e3a 100644 --- a/tgui/yarn.lock +++ b/tgui/yarn.lock @@ -2439,6 +2439,24 @@ __metadata: languageName: node linkType: hard +"@types/webpack-env@npm:^1.16.2": + version: 1.18.0 + resolution: "@types/webpack-env@npm:1.18.0" + checksum: ecf4daa31cb37d474ac0ce058d83a3cadeb9881ca8107ae93c2299eaa9954943aae09b43e143c62ccbe4288a14db00c918c9debd707afe17c3998f873eaabc59 + languageName: node + linkType: hard + +"@types/webpack@npm:^5.28.0": + version: 5.28.0 + resolution: "@types/webpack@npm:5.28.0" + dependencies: + "@types/node": "*" + tapable: ^2.2.0 + webpack: ^5 + checksum: a038d7e12dd109c6a8d2eb744fd32070ef94f1655e730fb1443b370db98864c3a0e408638b02d12ba08269b9c012b3be8b801117ced2d1102e7676203fd663ed + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 20.2.0 resolution: "@types/yargs-parser@npm:20.2.0" @@ -3899,6 +3917,13 @@ __metadata: languageName: node linkType: hard +"dateformat@npm:^4.5.1": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: c3aa0617c0a5b30595122bc8d1bee6276a9221e4d392087b41cbbdf175d9662ae0e50d0d6dcdf45caeac5153c4b5b0844265f8cd2b2245451e3da19e39e3b65d + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.3": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -4377,6 +4402,28 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-unused-imports@npm:^1.1.4": + version: 1.1.5 + resolution: "eslint-plugin-unused-imports@npm:1.1.5" + dependencies: + eslint-rule-composer: ^0.3.0 + peerDependencies: + "@typescript-eslint/eslint-plugin": ^4.14.2 + eslint: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + "@typescript-eslint/eslint-plugin": + optional: true + checksum: fd1ef93fd414240594ea2f78cd7334523c244af12a610442c6dbf78b0058915c363ce78032730962f510fdd2792ded0607bc13adea9a724d1c5cec736704c99d + languageName: node + linkType: hard + +"eslint-rule-composer@npm:^0.3.0": + version: 0.3.0 + resolution: "eslint-rule-composer@npm:0.3.0" + checksum: c2f57cded8d1c8f82483e0ce28861214347e24fd79fd4144667974cd334d718f4ba05080aaef2399e3bbe36f7d6632865110227e6b176ed6daa2d676df9281b1 + languageName: node + linkType: hard + "eslint-scope@npm:5.1.1, eslint-scope@npm:^5.1.1": version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" @@ -6344,6 +6391,17 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a + languageName: node + linkType: hard + "jsbn@npm:~0.1.0": version: 0.1.1 resolution: "jsbn@npm:0.1.1" @@ -8885,7 +8943,7 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"terser@npm:^5.14.1": +"terser@npm:^5.12.1, terser@npm:^5.14.1": version: 5.16.1 resolution: "terser@npm:5.16.1" dependencies: @@ -8956,7 +9014,8 @@ resolve@^2.0.0-next.3: dependencies: core-js: ^3.16.1 regenerator-runtime: ^0.13.9 - whatwg-fetch: ^3.6.2 + terser: ^5.12.1 + unfetch: ^4.2.0 languageName: unknown linkType: soft @@ -8973,6 +9032,8 @@ resolve@^2.0.0-next.3: "@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 @@ -8983,6 +9044,7 @@ resolve@^2.0.0-next.3: 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 @@ -9007,9 +9069,11 @@ resolve@^2.0.0-next.3: dependencies: "@popperjs/core": ^2.9.3 common: "workspace:*" + dateformat: ^4.5.1 dompurify: ^2.3.1 inferno: ^7.4.8 inferno-vnode-flags: ^7.4.8 + js-yaml: ^4.1.0 marked: ^4.0.10 tgui-dev-server: "workspace:*" tgui-polyfill: "workspace:*" @@ -9282,6 +9346,13 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"unfetch@npm:^4.2.0": + version: 4.2.0 + resolution: "unfetch@npm:4.2.0" + checksum: 6a4b2557e1d921eaa80c4425ce27a404945ec26491ed06e62598f333996a91a44c7908cb26dc7c2746d735762b13276cf4aa41829b4c8f438dde63add3045d7a + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^1.0.4": version: 1.0.4 resolution: "unicode-canonical-property-names-ecmascript@npm:1.0.4" @@ -9621,7 +9692,7 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"webpack@npm:^5.50.0": +"webpack@npm:^5, webpack@npm:^5.50.0": version: 5.75.0 resolution: "webpack@npm:5.75.0" dependencies: @@ -9667,13 +9738,6 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"whatwg-fetch@npm:^3.6.2": - version: 3.6.2 - resolution: "whatwg-fetch@npm:3.6.2" - checksum: ee976b7249e7791edb0d0a62cd806b29006ad7ec3a3d89145921ad8c00a3a67e4be8f3fb3ec6bc7b58498724fd568d11aeeeea1f7827e7e1e5eae6c8a275afed - languageName: node - linkType: hard - "whatwg-mimetype@npm:^2.3.0": version: 2.3.0 resolution: "whatwg-mimetype@npm:2.3.0" diff --git a/yogstation.dme b/yogstation.dme index 8c68cfbac232..74b106efad97 100644 --- a/yogstation.dme +++ b/yogstation.dme @@ -188,6 +188,7 @@ #include "code\__HELPERS\areas.dm" #include "code\__HELPERS\AStar.dm" #include "code\__HELPERS\cmp.dm" +#include "code\__HELPERS\colors.dm" #include "code\__HELPERS\config.dm" #include "code\__HELPERS\dates.dm" #include "code\__HELPERS\dna.dm" @@ -226,6 +227,7 @@ #include "code\__HELPERS\sorts\InsertSort.dm" #include "code\__HELPERS\sorts\MergeSort.dm" #include "code\__HELPERS\sorts\TimSort.dm" +#include "code\_globalvars\_regexes.dm" #include "code\_globalvars\bitfields.dm" #include "code\_globalvars\configuration.dm" #include "code\_globalvars\game_modes.dm" @@ -311,6 +313,7 @@ #include "code\controllers\subsystem\adjacent_air.dm" #include "code\controllers\subsystem\air.dm" #include "code\controllers\subsystem\area_contents.dm" +#include "code\controllers\subsystem\asset_loading.dm" #include "code\controllers\subsystem\assets.dm" #include "code\controllers\subsystem\atoms.dm" #include "code\controllers\subsystem\augury.dm" @@ -322,6 +325,7 @@ #include "code\controllers\subsystem\demo.dm" #include "code\controllers\subsystem\discord.dm" #include "code\controllers\subsystem\disease.dm" +#include "code\controllers\subsystem\early_assets.dm" #include "code\controllers\subsystem\economy.dm" #include "code\controllers\subsystem\events.dm" #include "code\controllers\subsystem\explosions.dm" @@ -347,6 +351,7 @@ #include "code\controllers\subsystem\parallax.dm" #include "code\controllers\subsystem\pathfinder.dm" #include "code\controllers\subsystem\persistence.dm" +#include "code\controllers\subsystem\persistent_paintings.dm" #include "code\controllers\subsystem\profiler.dm" #include "code\controllers\subsystem\radiation.dm" #include "code\controllers\subsystem\radio.dm" @@ -1885,9 +1890,56 @@ #include "code\modules\client\message.dm" #include "code\modules\client\player_details.dm" #include "code\modules\client\preferences.dm" +#include "code\modules\client\preferences_menu.dm" #include "code\modules\client\preferences_savefile.dm" -#include "code\modules\client\preferences_toggles.dm" -#include "code\modules\client\verbs\etips.dm" +#include "code\modules\client\preferences\_preference.dm" +#include "code\modules\client\preferences\admin.dm" +#include "code\modules\client\preferences\age.dm" +#include "code\modules\client\preferences\ai_core_display.dm" +#include "code\modules\client\preferences\ambient_occlusion.dm" +#include "code\modules\client\preferences\assets.dm" +#include "code\modules\client\preferences\auto_fit_viewport.dm" +#include "code\modules\client\preferences\buttons_locked.dm" +#include "code\modules\client\preferences\clothing.dm" +#include "code\modules\client\preferences\engineering_department.dm" +#include "code\modules\client\preferences\fps.dm" +#include "code\modules\client\preferences\gender.dm" +#include "code\modules\client\preferences\ghost.dm" +#include "code\modules\client\preferences\hotkeys.dm" +#include "code\modules\client\preferences\jobless_role.dm" +#include "code\modules\client\preferences\names.dm" +#include "code\modules\client\preferences\ooc.dm" +#include "code\modules\client\preferences\parallax.dm" +#include "code\modules\client\preferences\pda.dm" +#include "code\modules\client\preferences\pixel_size.dm" +#include "code\modules\client\preferences\preferred_map.dm" +#include "code\modules\client\preferences\random.dm" +#include "code\modules\client\preferences\runechat.dm" +#include "code\modules\client\preferences\scaling_method.dm" +#include "code\modules\client\preferences\security_department.dm" +#include "code\modules\client\preferences\skillcape.dm" +#include "code\modules\client\preferences\skin_tone.dm" +#include "code\modules\client\preferences\species.dm" +#include "code\modules\client\preferences\tgui_prefs_migration.dm" +#include "code\modules\client\preferences\ui_style.dm" +#include "code\modules\client\preferences\uplink_location.dm" +#include "code\modules\client\preferences\widescreen.dm" +#include "code\modules\client\preferences\window_flashing.dm" +#include "code\modules\client\preferences\middleware\_middleware.dm" +#include "code\modules\client\preferences\middleware\antags.dm" +#include "code\modules\client\preferences\middleware\jobs.dm" +#include "code\modules\client\preferences\middleware\keybindings.dm" +#include "code\modules\client\preferences\middleware\legacy_toggles.dm" +#include "code\modules\client\preferences\middleware\names.dm" +#include "code\modules\client\preferences\middleware\quirks.dm" +#include "code\modules\client\preferences\middleware\random.dm" +#include "code\modules\client\preferences\middleware\species.dm" +#include "code\modules\client\preferences\species_features\basic.dm" +#include "code\modules\client\preferences\species_features\ethereal.dm" +#include "code\modules\client\preferences\species_features\felinid.dm" +#include "code\modules\client\preferences\species_features\lizard.dm" +#include "code\modules\client\preferences\species_features\moth.dm" +#include "code\modules\client\preferences\species_features\mutants.dm" #include "code\modules\client\verbs\linkforum.dm" #include "code\modules\client\verbs\ooc.dm" #include "code\modules\client\verbs\ping.dm" @@ -3710,7 +3762,6 @@ #include "yogstation\code\modules\client\client_procs.dm" #include "yogstation\code\modules\client\preferences.dm" #include "yogstation\code\modules\client\preferences_savefile.dm" -#include "yogstation\code\modules\client\preferences_toggles.dm" #include "yogstation\code\modules\client\verbs\afk.dm" #include "yogstation\code\modules\client\verbs\antag_token.dm" #include "yogstation\code\modules\client\verbs\looc.dm" diff --git a/yogstation/code/controllers/subsystem/yogs.dm b/yogstation/code/controllers/subsystem/yogs.dm index 7ad223f77f8d..8accfa00094b 100644 --- a/yogstation/code/controllers/subsystem/yogs.dm +++ b/yogstation/code/controllers/subsystem/yogs.dm @@ -39,7 +39,7 @@ SUBSYSTEM_DEF(Yogs) portal.linked_targets = exits_by_id[portal.id] //ACCENT GENERATOR - var/list/accent_names = assoc_list_strip_value(GLOB.accents_name2file) + var/list/accent_names = assoc_to_keys(GLOB.accents_name2file) var/regex/is_phrase = regex(@"\\b[\w \.,;'\?!]+\\b","i") var/regex/is_word = regex(@"\\b[\w\.,;'\?!]+\\b","i") // Should be very similar to the above regex, except it doesn't capture on spaces and so only hits plaintext words for(var/accent in accent_names) diff --git a/yogstation/code/modules/client/preferences_toggles.dm b/yogstation/code/modules/client/preferences_toggles.dm deleted file mode 100644 index 0d877e4fd6d3..000000000000 --- a/yogstation/code/modules/client/preferences_toggles.dm +++ /dev/null @@ -1,26 +0,0 @@ -TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, listen_looc)() - set name = "Show/Hide LOOC" - set category = "Preferences" - set desc = "Show LOOC Chat" - usr.client.prefs.chat_toggles ^= CHAT_LOOC - usr.client.prefs.save_preferences() - to_chat(usr, "You will [(usr.client.prefs.chat_toggles & CHAT_LOOC) ? "now" : "no longer"] see messages on the LOOC channel.") - SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Seeing LOOC", "[usr.client.prefs.chat_toggles & CHAT_LOOC ? "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_LOOC - -TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, ghost_ckey)() - set name = "Show/Hide ckey in deadchat" - set category = "Preferences" - set desc = "Toggle ckey" - usr.client.prefs.chat_toggles ^= GHOST_CKEY - usr.client.prefs.save_preferences() - to_chat(usr, "Your ckey is [(usr.client.prefs.chat_toggles & GHOST_CKEY) ? "no longer" : "now"] visible in deadchat.") - -TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, typing_indicators)() - set name = "Show/Hide typing indicators" - set category = "Preferences" - set desc = "Toggle typing indicators" - usr.client.prefs.chat_toggles ^= CHAT_TYPING_INDICATOR - usr.client.prefs.save_preferences() - to_chat(usr, "You will [(usr.client.prefs.chat_toggles & CHAT_TYPING_INDICATOR) ? "now" : "no longer"] see typing indicators.") diff --git a/yogstation/code/modules/jobs/job_types/tourist.dm b/yogstation/code/modules/jobs/job_types/tourist.dm index cc4a60102b69..e2683c93ca49 100644 --- a/yogstation/code/modules/jobs/job_types/tourist.dm +++ b/yogstation/code/modules/jobs/job_types/tourist.dm @@ -1,5 +1,6 @@ /datum/job/tourist title = "Tourist" + description = "Enjoy the sights and scenery on board of the station." flag = TOUR orbit_icon = "camera-retro" department_flag = CIVILIAN diff --git a/yogstation/code/modules/mob/living/carbon/human/species_types/plantpeople.dm b/yogstation/code/modules/mob/living/carbon/human/species_types/plantpeople.dm index b07573ace958..37654e972d47 100644 --- a/yogstation/code/modules/mob/living/carbon/human/species_types/plantpeople.dm +++ b/yogstation/code/modules/mob/living/carbon/human/species_types/plantpeople.dm @@ -282,4 +282,26 @@ if(/obj/item/projectile/energy/florayield) H.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 + #undef STATUS_MESSAGE_COOLDOWN diff --git a/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm b/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm index 675bcef8fb6e..73a32ac29af7 100644 --- a/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm +++ b/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm @@ -7,6 +7,7 @@ adjust_charge - take a positive or negative value to adjust the charge level /datum/species/preternis name = "Preternis" + plural_form = "Preternis" id = "preternis" changesource_flags = MIRROR_BADMIN | WABBAJACK | MIRROR_PRIDE | MIRROR_MAGIC | RACE_SWAP | ERT_SPAWN | SLIME_EXTRACT inherent_traits = list(TRAIT_NOHUNGER, TRAIT_RADIMMUNE, TRAIT_MEDICALIGNORE) //Medical Ignore doesn't prevent basic treatment,only things that cannot help preternis,such as cryo and medbots @@ -273,3 +274,25 @@ adjust_charge - take a positive or negative value to adjust the charge level H.visible_message(span_danger("[P] deflects off of [H]!"), span_userdanger("[P] deflects off of you!")) return 1 return 0 + +/datum/species/preternis/get_species_description() + return "TODO: This is preternis description" + +/datum/species/preternis/get_species_lore() + return list( + "TODO: This is preternis lore" + ) + +/datum/species/preternis/create_pref_unique_perks() + var/list/to_add = list() + + // TODO + + return to_add + +/datum/species/preternis/create_pref_biotypes_perks() + var/list/to_add = list() + + // TODO + + return to_add From c98134e3cc4b1a2384e0e9e7c45470d160b3de24 Mon Sep 17 00:00:00 2001 From: Yogbot-13 Date: Fri, 6 Jan 2023 17:29:19 -0500 Subject: [PATCH 002/216] Automatic changelog generation #17368 [ci skip] --- html/changelogs/AutoChangelog-pr-17368.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 html/changelogs/AutoChangelog-pr-17368.yml diff --git a/html/changelogs/AutoChangelog-pr-17368.yml b/html/changelogs/AutoChangelog-pr-17368.yml new file mode 100644 index 000000000000..37b3d756b484 --- /dev/null +++ b/html/changelogs/AutoChangelog-pr-17368.yml @@ -0,0 +1,6 @@ +author: "Mothblocks & a lot of people from /tg/" +delete-after: true +changes: + - rscadd: "The preferences menu has been completely rewritten in tgui." + - tweak: "The \"Preferences\" tab has been removed, the menu verbs have been moved to the OOC tab" + - tweak: "The \"Stop Sounds\" verb has been moved to OOC." From d4859dc1a16aacecbff5b11d4dc11a4285993642 Mon Sep 17 00:00:00 2001 From: Jamie D <993128+JamieD1@users.noreply.github.com> Date: Sat, 7 Jan 2023 18:15:33 +0000 Subject: [PATCH 003/216] AHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH --- .gitignore | 3 +- code/controllers/subsystem/tgui.dm | 27 +- code/modules/client/client_procs.dm | 2 +- .../client/preferences/middleware/antags.dm | 2 +- code/modules/client/preferences/tgui.dm | 49 +++ code/modules/tgui/tgui.dm | 23 +- code/modules/tgui/tgui_window.dm | 51 ++- code/modules/tgui_panel/tgui_panel.dm | 40 +-- tgui/global.d.ts | 322 +++++++++-------- tgui/package.json | 4 +- tgui/packages/common/collections.spec.ts | 20 ++ tgui/packages/common/collections.ts | 146 +++----- tgui/packages/common/color.js | 24 +- tgui/packages/common/exhaustive.ts | 2 +- tgui/packages/common/math.ts | 100 ++++++ tgui/packages/common/types.ts | 1 - tgui/packages/tgfont/icons/bad-touch.svg | 61 +--- tgui/packages/tgfont/icons/non-binary.svg | 53 +-- tgui/packages/tgfont/icons/prosthetic-leg.svg | 53 ++- tgui/packages/tgfont/icons/syndicate-logo.svg | 16 +- tgui/packages/tgui-panel/Panel.js | 58 ++- .../tgui-panel/chat/ChatPageSettings.js | 2 +- tgui/packages/tgui-panel/chat/constants.js | 32 +- tgui/packages/tgui-panel/chat/middleware.js | 18 +- tgui/packages/tgui-panel/chat/model.js | 24 +- tgui/packages/tgui-panel/chat/reducer.js | 15 +- tgui/packages/tgui-panel/chat/renderer.js | 62 +++- tgui/packages/tgui-panel/game/actions.js | 1 - tgui/packages/tgui-panel/game/reducer.js | 24 -- tgui/packages/tgui-panel/index.js | 22 +- tgui/packages/tgui-panel/ping/middleware.js | 15 +- .../tgui-panel/settings/SettingsPanel.js | 142 +++----- .../packages/tgui-panel/settings/constants.js | 3 +- tgui/packages/tgui-panel/settings/hooks.js | 4 +- .../tgui-panel/settings/middleware.js | 22 +- .../packages/tgui-panel/settings/selectors.js | 4 +- .../tgui-panel/styles/components/Chat.scss | 2 +- .../tgui-panel/styles/components/Ping.scss | 2 +- .../tgui-panel/styles/goon/chat-dark.scss | 118 +++---- .../tgui-panel/styles/goon/chat-light.scss | 110 +++--- tgui/packages/tgui-panel/styles/main.scss | 1 + .../tgui-panel/styles/themes/light.scss | 65 ++-- tgui/packages/tgui-panel/telemetry.js | 11 +- tgui/packages/tgui-panel/themes.js | 19 +- .../{html5shiv.js => 00-html5shiv.js} | 0 .../tgui-polyfill/{ie8.js => 01-ie8.js} | 10 +- .../tgui-polyfill/{dom4.js => 02-dom4.js} | 0 .../tgui-polyfill/{css-om.js => 03-css-om.js} | 0 tgui/packages/tgui-polyfill/10-misc.js | 61 ++++ tgui/packages/tgui-polyfill/index.js | 8 +- tgui/packages/tgui-polyfill/inferno.js | 10 - tgui/packages/tgui-polyfill/package.json | 2 +- tgui/packages/tgui/backend.ts | 44 +-- .../tgui/components/AnimatedNumber.js | 83 +++++ tgui/packages/tgui/components/Autofocus.tsx | 2 +- tgui/packages/tgui/components/Blink.js | 13 +- tgui/packages/tgui/components/BlockQuote.js | 11 +- .../tgui/components/BodyZoneSelector.tsx | 166 +++++---- tgui/packages/tgui/components/Box.tsx | 62 ++-- tgui/packages/tgui/components/Button.js | 118 ++----- tgui/packages/tgui/components/ByondUi.js | 31 +- tgui/packages/tgui/components/Chart.js | 13 +- tgui/packages/tgui/components/Collapsible.js | 18 +- tgui/packages/tgui/components/ColorBox.js | 9 +- tgui/packages/tgui/components/Dimmer.js | 13 +- tgui/packages/tgui/components/Divider.js | 14 +- .../tgui/components/DraggableControl.js | 110 +++--- tgui/packages/tgui/components/Dropdown.js | 36 +- tgui/packages/tgui/components/FitText.tsx | 42 ++- tgui/packages/tgui/components/Flex.tsx | 26 +- tgui/packages/tgui/components/Grid.js | 11 +- tgui/packages/tgui/components/Icon.js | 40 ++- .../packages/tgui/components/InfinitePlane.js | 94 +++-- tgui/packages/tgui/components/Input.js | 25 +- tgui/packages/tgui/components/KeyListener.tsx | 14 +- tgui/packages/tgui/components/Knob.js | 42 ++- .../tgui/components/LabeledControls.js | 25 +- tgui/packages/tgui/components/LabeledList.tsx | 38 +- tgui/packages/tgui/components/Modal.js | 14 +- tgui/packages/tgui/components/NoticeBox.js | 15 +- tgui/packages/tgui/components/NumberInput.js | 90 ++--- tgui/packages/tgui/components/ProgressBar.js | 16 +- .../tgui/components/RestrictedInput.js | 44 +-- tgui/packages/tgui/components/RoundGauge.js | 54 +-- tgui/packages/tgui/components/Section.tsx | 38 +- tgui/packages/tgui/components/Slider.js | 43 ++- tgui/packages/tgui/components/Stack.tsx | 6 +- tgui/packages/tgui/components/Table.js | 36 +- tgui/packages/tgui/components/Tabs.js | 42 ++- tgui/packages/tgui/components/TextArea.js | 123 ++----- tgui/packages/tgui/components/TimeDisplay.js | 10 +- tgui/packages/tgui/components/Tooltip.tsx | 32 +- .../tgui/components/TrackOutsideClicks.tsx | 21 +- tgui/packages/tgui/components/index.js | 3 - tgui/packages/tgui/debug/middleware.js | 4 +- tgui/packages/tgui/drag.js | 98 +++--- tgui/packages/tgui/focus.js | 4 +- tgui/packages/tgui/index.js | 28 +- .../interfaces/PreferencesMenu/AntagsPage.tsx | 2 +- .../CharacterPreferenceWindow.tsx | 2 +- .../PreferencesMenu/CharacterPreview.tsx | 25 +- .../interfaces/PreferencesMenu/JobsPage.tsx | 333 +++++++++--------- .../PreferencesMenu/KeybindingsPage.tsx | 9 +- .../PreferencesMenu/SpeciesPage.tsx | 218 ++++++------ .../interfaces/PreferencesMenu/TabbedMenu.tsx | 2 +- .../antagonists/antagonists/abductor.ts | 24 ++ .../antagonists/antagonists/blob.ts | 20 ++ .../antagonists/antagonists/blobinfection.ts | 18 + .../antagonists/antagonists/bloodbrother.ts | 17 + .../antagonists/antagonists/changeling.ts | 21 ++ .../antagonists/antagonists/clownoperative.ts | 21 ++ .../antagonists/antagonists/cultist.ts | 23 ++ .../antagonists/familyheadaspirant.ts | 18 + .../antagonists/antagonists/fugitive.ts | 16 + .../antagonists/antagonists/gangster.ts | 19 + .../antagonists/headrevolutionary.ts | 20 ++ .../antagonists/antagonists/heretic.ts | 24 ++ .../antagonists/hereticsmuggler.ts | 14 + .../antagonists/antagonists/loneoperative.ts | 19 + .../antagonists/antagonists/malfai.ts | 20 ++ .../antagonists/antagonists/malfaimidround.ts | 18 + .../antagonists/antagonists/nightmare.ts | 16 + .../antagonists/antagonists/obsessed.ts | 17 + .../antagonists/antagonists/operative.ts | 25 ++ .../antagonists/operativemidround.ts | 18 + .../antagonists/antagonists/opportunist.ts | 16 + .../antagonists/antagonists/provocateur.ts | 19 + .../antagonists/antagonists/revenant.ts | 17 + .../antagonists/sentientdisease.ts | 16 + .../antagonists/antagonists/spacedragon.ts | 16 + .../antagonists/antagonists/spaceninja.ts | 24 ++ .../antagonists/syndicateinfiltrator.ts | 15 + .../antagonists/syndicatesleeperagent.ts | 19 + .../antagonists/antagonists/thief.ts | 22 ++ .../antagonists/antagonists/traitor.ts | 4 +- .../antagonists/antagonists/wizard.ts | 20 ++ .../antagonists/antagonists/wizardmidround.ts | 14 + .../antagonists/antagonists/xenomorph.ts | 16 + .../PreferencesMenu/antagonists/base.ts | 2 +- .../tgui/interfaces/PreferencesMenu/data.ts | 75 ++-- .../tgui/interfaces/PreferencesMenu/names.tsx | 2 + .../{skillcape.tsx => body_type.tsx} | 4 +- ...engineering_department.tsx => glasses.tsx} | 4 +- .../persistent_scars.tsx | 6 + .../features/character_preferences/phobia.tsx | 6 + .../playtime_reward_cloak.tsx | 7 + .../character_preferences/skin_tone.tsx | 4 +- .../features/game_preferences/admin.tsx | 16 +- .../broadcast_login_logout.tsx | 11 + .../game_preferences/darkened_flash.tsx | 12 + .../game_preferences/fov_darkness.tsx | 8 + .../game_preferences/ghost_lighting.tsx | 8 + .../game_preferences/item_outlines.tsx | 8 + .../features/game_preferences/mod_select.tsx | 8 + .../features/game_preferences/screentips.tsx | 22 ++ .../features/game_preferences/tgui.tsx | 35 +- .../features/game_preferences/tooltips.tsx | 20 ++ .../preferences/features/species_features.tsx | 26 ++ tgui/packages/tgui/interfaces/Thermometer.js | 98 ++++++ tgui/packages/tgui/layouts/Window.js | 20 +- tgui/packages/tgui/links.js | 23 +- tgui/packages/tgui/logging.js | 3 +- .../packages/tgui/public/tgui-common.chunk.js | 1 - .../packages/tgui/public/tgui-panel.bundle.js | 1 - tgui/packages/tgui/public/tgui.bundle.js | 1 - tgui/packages/tgui/stories/ByondUi.stories.js | 2 +- tgui/packages/tgui/styles/atomic/links.scss | 10 + tgui/packages/tgui/styles/colors.scss | 4 - .../tgui/styles/components/Button.scss | 20 +- .../tgui/styles/components/Dialog.scss | 105 ------ .../tgui/styles/components/Dropdown.scss | 2 +- .../packages/tgui/styles/components/Icon.scss | 1 + .../packages/tgui/styles/components/Knob.scss | 6 +- .../tgui/styles/components/LabeledList.scss | 2 +- .../tgui/styles/components/MenuBar.scss | 75 ---- .../tgui/styles/components/NoticeBox.scss | 18 +- .../tgui/styles/components/RoundGauge.scss | 21 +- .../tgui/styles/components/Section.scss | 20 -- .../packages/tgui/styles/components/Tabs.scss | 5 +- .../tgui/styles/components/TextArea.scss | 21 -- .../tgui/styles/interfaces/AlertModal.scss | 2 +- .../tgui/styles/interfaces/Changelog.scss | 13 + .../tgui/styles/interfaces/CrewManifest.scss | 58 +++ .../interfaces/ExperimentConfigure.scss | 85 +++++ .../tgui/styles/interfaces/HellishRunes.scss | 22 ++ .../tgui/styles/interfaces/HotKeysHelp.scss | 4 + .../tgui/styles/interfaces/Hypertorus.scss | 158 +++++++++ .../styles/interfaces/IntegratedCircuit.scss | 64 ++++ .../styles/interfaces/LibraryComputer.scss | 11 + .../tgui/styles/interfaces/ListInput.scss | 2 +- .../tgui/styles/interfaces/Mecha.scss | 43 +++ .../tgui/styles/interfaces/Paper.scss | 18 + .../styles/interfaces/RequestManager.scss | 106 ++++++ .../tgui/styles/interfaces/Roulette.scss | 66 ++-- .../packages/tgui/styles/interfaces/Safe.scss | 2 +- .../tgui/styles/interfaces/TachyonArray.scss | 13 + .../tgui/styles/interfaces/Techweb.scss | 78 ++++ .../tgui/styles/interfaces/Uplink.scss | 81 +++++ .../tgui/styles/layouts/TitleBar.scss | 11 +- tgui/packages/tgui/styles/main.scss | 18 +- tgui/packages/tgui/styles/themes/admin.scss | 31 ++ .../tgui/styles/themes/admintickets.scss | 30 -- .../tgui/styles/themes/clockwork.scss | 46 --- .../tgui/styles/themes/hackerman.scss | 5 + tgui/packages/tgui/styles/themes/neutral.scss | 43 +++ tgui/packages/tgui/styles/themes/ntOS95.scss | 141 -------- .../packages/tgui/styles/themes/ntos_cat.scss | 137 ------- .../tgui/styles/themes/ntos_darkmode.scss | 37 -- .../tgui/styles/themes/ntos_lightmode.scss | 63 ---- .../tgui/styles/themes/ntos_spooky.scss | 71 ---- .../tgui/styles/themes/ntos_synth.scss | 86 ----- .../tgui/styles/themes/ntos_terminal.scss | 105 ------ tgui/packages/tgui/styles/themes/paper.scss | 60 ++-- .../tgui/styles/themes/spookyconsole.scss | 64 ++++ tgui/packages/tgui/styles/themes/wizard.scss | 59 ++++ tgui/public/tgui-polyfill.min.js | 1 - tgui/public/tgui.html | 197 ++++++----- yogstation.dme | 1 + 218 files changed, 4445 insertions(+), 3214 deletions(-) create mode 100644 code/modules/client/preferences/tgui.dm create mode 100644 tgui/packages/common/collections.spec.ts create mode 100644 tgui/packages/common/math.ts rename tgui/packages/tgui-polyfill/{html5shiv.js => 00-html5shiv.js} (100%) rename tgui/packages/tgui-polyfill/{ie8.js => 01-ie8.js} (99%) rename tgui/packages/tgui-polyfill/{dom4.js => 02-dom4.js} (100%) rename tgui/packages/tgui-polyfill/{css-om.js => 03-css-om.js} (100%) create mode 100644 tgui/packages/tgui-polyfill/10-misc.js delete mode 100644 tgui/packages/tgui-polyfill/inferno.js create mode 100644 tgui/packages/tgui/components/AnimatedNumber.js create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/abductor.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/blob.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/blobinfection.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodbrother.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/changeling.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/clownoperative.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/cultist.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/familyheadaspirant.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/fugitive.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/gangster.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/headrevolutionary.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/heretic.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/hereticsmuggler.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/loneoperative.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/malfai.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/malfaimidround.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/nightmare.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/obsessed.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/operative.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/operativemidround.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/opportunist.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/provocateur.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/revenant.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/sentientdisease.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spacedragon.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spaceninja.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/syndicateinfiltrator.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/syndicatesleeperagent.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/thief.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/wizard.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/wizardmidround.ts create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/xenomorph.ts rename tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/{skillcape.tsx => body_type.tsx} (61%) rename tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/{engineering_department.tsx => glasses.tsx} (50%) create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/persistent_scars.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/phobia.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/playtime_reward_cloak.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/broadcast_login_logout.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/darkened_flash.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fov_darkness.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost_lighting.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/item_outlines.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/mod_select.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/screentips.tsx create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tooltips.tsx create mode 100644 tgui/packages/tgui/interfaces/Thermometer.js delete mode 100644 tgui/packages/tgui/public/tgui-common.chunk.js delete mode 100644 tgui/packages/tgui/public/tgui-panel.bundle.js delete mode 100644 tgui/packages/tgui/public/tgui.bundle.js create mode 100644 tgui/packages/tgui/styles/atomic/links.scss delete mode 100644 tgui/packages/tgui/styles/components/Dialog.scss delete mode 100644 tgui/packages/tgui/styles/components/MenuBar.scss create mode 100644 tgui/packages/tgui/styles/interfaces/Changelog.scss create mode 100644 tgui/packages/tgui/styles/interfaces/CrewManifest.scss create mode 100644 tgui/packages/tgui/styles/interfaces/ExperimentConfigure.scss create mode 100644 tgui/packages/tgui/styles/interfaces/HellishRunes.scss create mode 100644 tgui/packages/tgui/styles/interfaces/HotKeysHelp.scss create mode 100644 tgui/packages/tgui/styles/interfaces/Hypertorus.scss create mode 100644 tgui/packages/tgui/styles/interfaces/IntegratedCircuit.scss create mode 100644 tgui/packages/tgui/styles/interfaces/LibraryComputer.scss create mode 100644 tgui/packages/tgui/styles/interfaces/Mecha.scss create mode 100644 tgui/packages/tgui/styles/interfaces/Paper.scss create mode 100644 tgui/packages/tgui/styles/interfaces/RequestManager.scss create mode 100644 tgui/packages/tgui/styles/interfaces/TachyonArray.scss create mode 100644 tgui/packages/tgui/styles/interfaces/Techweb.scss create mode 100644 tgui/packages/tgui/styles/interfaces/Uplink.scss create mode 100644 tgui/packages/tgui/styles/themes/admin.scss delete mode 100644 tgui/packages/tgui/styles/themes/admintickets.scss delete mode 100644 tgui/packages/tgui/styles/themes/clockwork.scss create mode 100644 tgui/packages/tgui/styles/themes/neutral.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntOS95.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntos_cat.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntos_darkmode.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntos_lightmode.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntos_spooky.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntos_synth.scss delete mode 100644 tgui/packages/tgui/styles/themes/ntos_terminal.scss create mode 100644 tgui/packages/tgui/styles/themes/spookyconsole.scss create mode 100644 tgui/packages/tgui/styles/themes/wizard.scss delete mode 100644 tgui/public/tgui-polyfill.min.js diff --git a/.gitignore b/.gitignore index b20069596967..4783785ce09b 100644 --- a/.gitignore +++ b/.gitignore @@ -216,5 +216,4 @@ tools/MapAtmosFixer/MapAtmosFixer/bin/* /_maps/templates.dm #KDIFF3 files -*.orig -tgui/public/ \ No newline at end of file +*.orig \ No newline at end of file diff --git a/code/controllers/subsystem/tgui.dm b/code/controllers/subsystem/tgui.dm index 2fe7c64c72dd..92e787fa09f0 100644 --- a/code/controllers/subsystem/tgui.dm +++ b/code/controllers/subsystem/tgui.dm @@ -1,10 +1,13 @@ +/*! + * Copyright (c) 2020 Aleksej Komarov + * SPDX-License-Identifier: MIT + */ + /** * tgui subsystem * * Contains all tgui state and subsystem code. * - * Copyright (c) 2020 Aleksej Komarov - * SPDX-License-Identifier: MIT */ SUBSYSTEM_DEF(tgui) @@ -25,6 +28,10 @@ SUBSYSTEM_DEF(tgui) /datum/controller/subsystem/tgui/PreInit() basehtml = file2text('tgui/public/tgui.html') + // Inject inline polyfills + var/polyfill = file2text('tgui/public/tgui-polyfill.bundle.js') + polyfill = "" + basehtml = replacetextEx(basehtml, "", polyfill) /datum/controller/subsystem/tgui/Shutdown() close_all_uis() @@ -33,7 +40,7 @@ SUBSYSTEM_DEF(tgui) msg = "P:[length(open_uis)]" return ..() -/datum/controller/subsystem/tgui/fire(resumed = 0) +/datum/controller/subsystem/tgui/fire(resumed = FALSE) if(!resumed) src.current_run = open_uis.Copy() // Cache for sanic speed (lists are references anyways) @@ -42,8 +49,8 @@ SUBSYSTEM_DEF(tgui) var/datum/tgui/ui = current_run[current_run.len] current_run.len-- // TODO: Move user/src_object check to process() - if(ui && ui.user && ui.src_object) - ui.process() + if(ui?.user && ui.src_object) + ui.process(wait * 0.1) else open_uis.Remove(ui) if(MC_TICK_CHECK) @@ -191,8 +198,8 @@ SUBSYSTEM_DEF(tgui) return count for(var/datum/tgui/ui in open_uis_by_src[key]) // Check if UI is valid. - if(ui && ui.src_object && ui.user && ui.src_object.ui_host(ui.user)) - ui.process(force = 1) + if(ui?.src_object && ui.user && ui.src_object.ui_host(ui.user)) + ui.process(wait * 0.1, force = 1) count++ return count @@ -213,7 +220,7 @@ SUBSYSTEM_DEF(tgui) return count for(var/datum/tgui/ui in open_uis_by_src[key]) // Check if UI is valid. - if(ui && ui.src_object && ui.user && ui.src_object.ui_host(ui.user)) + if(ui?.src_object && ui.user && ui.src_object.ui_host(ui.user)) ui.close() count++ return count @@ -230,7 +237,7 @@ SUBSYSTEM_DEF(tgui) for(var/key in open_uis_by_src) for(var/datum/tgui/ui in open_uis_by_src[key]) // Check if UI is valid. - if(ui && ui.src_object && ui.user && ui.src_object.ui_host(ui.user)) + if(ui?.src_object && ui.user && ui.src_object.ui_host(ui.user)) ui.close() count++ return count @@ -251,7 +258,7 @@ SUBSYSTEM_DEF(tgui) return count for(var/datum/tgui/ui in user.tgui_open_uis) if(isnull(src_object) || ui.src_object == src_object) - ui.process(force = 1) + ui.process(wait * 0.1, force = 1) count++ return count diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index b0844f74ff71..5160929dd257 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -231,7 +231,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list( // Instantiate tgui panel tgui_panel = new(src) - tgui_panel.send_connected() + //tgui_panel.send_connected() GLOB.ahelp_tickets.ClientLogin(src) var/connecting_admin = GLOB.permissions.load_permissions_for(src) //because de-admined admins connecting should be treated like admins. diff --git a/code/modules/client/preferences/middleware/antags.dm b/code/modules/client/preferences/middleware/antags.dm index ecd1fcc19b19..888ba5911a0a 100644 --- a/code/modules/client/preferences/middleware/antags.dm +++ b/code/modules/client/preferences/middleware/antags.dm @@ -79,7 +79,7 @@ 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) + //var/antag_flag_override = initial(dynamic_ruleset.antag_flag_override) if (isnull(antag_flag)) continue diff --git a/code/modules/client/preferences/tgui.dm b/code/modules/client/preferences/tgui.dm new file mode 100644 index 000000000000..e04746e6ab0e --- /dev/null +++ b/code/modules/client/preferences/tgui.dm @@ -0,0 +1,49 @@ +/datum/preference/toggle/tgui_fancy + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tgui_fancy" + savefile_identifier = PREFERENCE_PLAYER + +/datum/preference/toggle/tgui_fancy/apply_to_client(client/client, value) + for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis) + // Force it to reload either way + tgui.update_static_data(client.mob) + +// Determines if input boxes are in tgui or old fashioned +/datum/preference/toggle/tgui_input + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tgui_input" + savefile_identifier = PREFERENCE_PLAYER + +/// Large button preference. Error text is in tooltip. +/datum/preference/toggle/tgui_input_large + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tgui_input_large" + savefile_identifier = PREFERENCE_PLAYER + default_value = FALSE + +/datum/preference/toggle/tgui_input_large/apply_to_client(client/client, value) + for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis) + // Force it to reload either way + tgui.send_full_update(client.mob) + +/// Swapped button state - sets buttons to SS13 traditional SUBMIT/CANCEL +/datum/preference/toggle/tgui_input_swapped + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tgui_input_swapped" + savefile_identifier = PREFERENCE_PLAYER + +/datum/preference/toggle/tgui_input_swapped/apply_to_client(client/client, value) + for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis) + // Force it to reload either way + tgui.send_full_update(client.mob) + +/datum/preference/toggle/tgui_lock + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tgui_lock" + savefile_identifier = PREFERENCE_PLAYER + default_value = FALSE + +/datum/preference/toggle/tgui_lock/apply_to_client(client/client, value) + for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis) + // Force it to reload either way + tgui.update_static_data(client.mob) diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm index 73ed56d361fb..b73842544b4b 100644 --- a/code/modules/tgui/tgui.dm +++ b/code/modules/tgui/tgui.dm @@ -1,4 +1,4 @@ -/** +/*! * Copyright (c) 2020 Aleksej Komarov * SPDX-License-Identifier: MIT */ @@ -54,7 +54,7 @@ */ /datum/tgui/New(mob/user, datum/src_object, interface, title, ui_x, ui_y) log_tgui(user, - "new [interface] fancy [user.client.prefs.tgui_fancy]", + "new [interface] fancy [user?.client?.prefs.read_preference(/datum/preference/toggle/tgui_fancy)]", src_object = src_object) src.user = user src.src_object = src_object @@ -62,11 +62,16 @@ src.interface = interface if(title) src.title = title - src.state = src_object.ui_state() + src.state = src_object.ui_state(user) // Deprecated if(ui_x && ui_y) src.window_size = list(ui_x, ui_y) +/datum/tgui/Destroy() + user = null + src_object = null + return ..() + /** * public * @@ -89,8 +94,8 @@ window.acquire_lock(src) if(!window.is_ready()) window.initialize( - fancy = user.client.prefs.tgui_fancy, - inline_assets = list( + fancy = user.client.prefs.read_preference(/datum/preference/toggle/tgui_fancy), + assets = list( get_asset_datum(/datum/asset/simple/tgui), )) else @@ -224,8 +229,8 @@ "window" = list( "key" = window_key, "size" = window_size, - "fancy" = user.client.prefs.tgui_fancy, - "locked" = user.client.prefs.tgui_lock, + "fancy" = user.client.prefs.read_preference(/datum/preference/toggle/tgui_fancy), + "locked" = user.client.prefs.read_preference(/datum/preference/toggle/tgui_lock), ), "client" = list( "ckey" = user.client.ckey, @@ -253,7 +258,7 @@ * Run an update cycle for this UI. Called internally by SStgui * every second or so. */ -/datum/tgui/process(force = FALSE) +/datum/tgui/process(delta_time, force = FALSE) if(closing) return var/datum/host = src_object.ui_host(user) @@ -308,7 +313,7 @@ return FALSE switch(type) if("ready") - // Send a full update when the user manually refreshes the UI + // Send a full update when the user manually refreshes the UI if(initialized) send_full_update() initialized = TRUE diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm index ee0300b87ee3..18e87468325d 100644 --- a/code/modules/tgui/tgui_window.dm +++ b/code/modules/tgui/tgui_window.dm @@ -1,4 +1,4 @@ -/** +/*! * Copyright (c) 2020 Aleksej Komarov * SPDX-License-Identifier: MIT */ @@ -18,8 +18,11 @@ var/message_queue var/sent_assets = list() // Vars passed to initialize proc (and saved for later) - var/inline_assets - var/fancy + var/initial_fancy + var/initial_assets + var/initial_inline_html + var/initial_inline_js + var/initial_inline_css /** * public @@ -44,21 +47,26 @@ * state. You can begin sending messages right after initializing. Messages * will be put into the queue until the window finishes loading. * - * optional inline_assets list List of assets to inline into the html. + * optional assets list List of assets to inline into the html. * optional inline_html string Custom HTML to inject. * optional fancy bool If TRUE, will hide the window titlebar. */ /datum/tgui_window/proc/initialize( - inline_assets = list(), + fancy = FALSE, + assets = list(), inline_html = "", - fancy = FALSE) + inline_js = "", + inline_css = "") log_tgui(client, context = "[id]/initialize", window = src) if(!client) return - src.inline_assets = inline_assets - src.fancy = fancy + src.initial_fancy = fancy + src.initial_assets = assets + src.initial_inline_html = inline_html + src.initial_inline_js = inline_js + src.initial_inline_css = inline_css status = TGUI_WINDOW_LOADING fatally_errored = FALSE // Build window options @@ -71,9 +79,9 @@ // Generate page html var/html = SStgui.basehtml html = replacetextEx(html, "\[tgui:windowId]", id) - // Inject inline assets + // Inject assets var/inline_assets_str = "" - for(var/datum/asset/asset in inline_assets) + for(var/datum/asset/asset in assets) var/mappings = asset.get_url_mappings() for(var/name in mappings) var/url = mappings[name] @@ -86,8 +94,17 @@ if(length(inline_assets_str)) inline_assets_str = "\n" html = replacetextEx(html, "\n", inline_assets_str) - // Inject custom HTML - html = replacetextEx(html, "\n", inline_html) + // Inject inline HTML + if (inline_html) + html = replacetextEx(html, "", inline_html) + // Inject inline JS + if (inline_js) + inline_js = "" + html = replacetextEx(html, "", inline_js) + // Inject inline CSS + if (inline_css) + inline_css = "" + html = replacetextEx(html, "", inline_css) // Open the window client << browse(html, "window=[id];[options]") // Detect whether the control is a browser @@ -317,7 +334,15 @@ client << link(href_list["url"]) if("cacheReloaded") // Reinitialize - initialize(inline_assets = inline_assets, fancy = fancy) + initialize( + fancy = initial_fancy, + assets = initial_assets, + inline_html = initial_inline_html, + inline_js = initial_inline_js, + inline_css = initial_inline_css) // Resend the assets for(var/asset in sent_assets) send_asset(asset) + +/datum/tgui_window/vv_edit_var(var_name, var_value) + return var_name != NAMEOF(src, id) && ..() diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm index a75ce59f0173..b98fbfbbdf82 100644 --- a/code/modules/tgui_panel/tgui_panel.dm +++ b/code/modules/tgui_panel/tgui_panel.dm @@ -1,4 +1,4 @@ -/** +/*! * Copyright (c) 2020 Aleksej Komarov * SPDX-License-Identifier: MIT */ @@ -12,7 +12,6 @@ var/datum/tgui_window/window var/broken = FALSE var/initialized_at - var/retries = 0 /datum/tgui_panel/New(client/client) src.client = client @@ -40,53 +39,27 @@ /datum/tgui_panel/proc/initialize(force = FALSE) set waitfor = FALSE // Minimal sleep to defer initialization to after client constructor - sleep(0.1 SECONDS) + sleep(1) initialized_at = world.time // Perform a clean initialization - window.initialize(inline_assets = list( + window.initialize(assets = list( get_asset_datum(/datum/asset/simple/tgui_panel), )) window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/fontawesome)) window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/tgfont)) window.send_asset(get_asset_datum(/datum/asset/spritesheet/chat)) - // Preload assets for /datum/tgui - var/datum/asset/asset_tgui = get_asset_datum(/datum/asset/simple/tgui) - var/flush_queue = asset_tgui.send(src.client) - if(flush_queue) - src.client.browse_queue_flush() // Other setup request_telemetry() - if(!telemetry_connections && retries < 6) - addtimer(CALLBACK(src, .proc/check_telemetry), 2 SECONDS) - addtimer(CALLBACK(src, .proc/on_initialize_timed_out), 2 SECONDS) - -/datum/tgui_panel/proc/check_telemetry() - if(!telemetry_connections) /// Somethings fucked lets try again. - if(retries > 2) - if(client && istype(client)) - winset(client, null, "command=.reconnect") /// Kitchen Sink - qdel(client) - if(retries > 3) - qdel(client) - if(retries > 5) - return // I give up - if(retries < 6) - retries++ - src << browse(file('html/statbrowser.html'), "window=statbrowser") /// Reloads the statpanel as well - initialize() /// Lets just start again - var/mob/dead/new_player/M = client?.mob - if(istype(M)) - M.Login() + addtimer(CALLBACK(src, .proc/on_initialize_timed_out), 5 SECONDS) /** * private * * Called when initialization has timed out. */ - /datum/tgui_panel/proc/on_initialize_timed_out() // Currently does nothing but sending a message to old chat. - SEND_TEXT(client, span_userdanger("Failed to load fancy chat, click HERE to attempt to reload it.")) + SEND_TEXT(client, "Failed to load fancy chat, click HERE to attempt to reload it.") /** * private @@ -124,6 +97,3 @@ */ /datum/tgui_panel/proc/send_roundrestart() window.send_message("roundrestart") - -/datum/tgui_panel/proc/send_connected() - window.send_message("reconnected") diff --git a/tgui/global.d.ts b/tgui/global.d.ts index 7f2cc8f7282c..348cb9c5165c 100644 --- a/tgui/global.d.ts +++ b/tgui/global.d.ts @@ -4,155 +4,179 @@ * @license MIT */ -declare global { - // Webpack asset modules. - // Should match extensions used in webpack config. - declare module '*.png' { - const content: string; - export default content; - } - - declare module '*.jpg' { - const content: string; - export default content; - } - - declare module '*.svg' { - const content: string; - export default content; - } - - type ByondType = { - /** - * True if javascript is running in BYOND. - */ - IS_BYOND: boolean; - - /** - * True if browser is IE8 or lower. - */ - IS_LTE_IE8: boolean; - - /** - * True if browser is IE9 or lower. - */ - IS_LTE_IE9: boolean; - - /** - * True if browser is IE10 or lower. - */ - IS_LTE_IE10: boolean; - - /** - * True if browser is IE11 or lower. - */ - IS_LTE_IE11: boolean; - - /** - * Makes a BYOND call. - * - * If path is empty, this will trigger a Topic call. - * You can reference a specific object by setting the "src" parameter. - * - * See: https://secure.byond.com/docs/ref/skinparams.html - */ - call(path: string, params: object): void; - - /** - * Makes an asynchronous BYOND call. Returns a promise. - */ - callAsync(path: string, params: object): Promise; - - /** - * Makes a Topic call. - * - * You can reference a specific object by setting the "src" parameter. - */ - topic(params: object): void; - - /** - * Runs a command or a verb. - */ - command(command: string): void; - - /** - * Retrieves all properties of the BYOND skin element. - * - * Returns a promise with a key-value object containing all properties. - */ - winget(id: string): Promise; - - /** - * Retrieves all properties of the BYOND skin element. - * - * Returns a promise with a key-value object containing all properties. - */ - winget(id: string, propName: '*'): Promise; - - /** - * Retrieves an exactly one property of the BYOND skin element, - * as defined in `propName`. - * - * Returns a promise with the value of that property. - */ - winget(id: string, propName: string): Promise; - - /** - * Retrieves multiple properties of the BYOND skin element, - * as defined in the `propNames` array. - * - * Returns a promise with a key-value object containing listed properties. - */ - winget(id: string, propNames: string[]): Promise; - - /** - * Assigns properties to BYOND skin elements. - */ - winset(props: object): void; - - /** - * Assigns properties to the BYOND skin element. - */ - winset(id: string, props: object): void; - - /** - * Sets a property on the BYOND skin element to a certain value. - */ - winset(id: string, propName: string, propValue: any): void; - - /** - * Parses BYOND JSON. - * - * Uses a special encoding to preverse Infinity and NaN. - */ - parseJson(text: string): any; - - /** - * Loads a stylesheet into the document. - */ - loadCss(url: string): void; - - /** - * Loads a script into the document. - */ - loadJs(url: string): void; - }; - - /** - * Object that provides access to Byond Skin API and is available in - * any tgui application. - */ - const Byond: ByondType; - - interface Window { - /** - * ID of the Byond window this script is running on. - * Should be used as a parameter to winget/winset. - */ - __windowId__: string; - Byond: ByondType; - } +// Webpack asset modules. +// Should match extensions used in webpack config. +declare module '*.png' { + const content: string; + export default content; +} + +declare module '*.jpg' { + const content: string; + export default content; +} +declare module '*.svg' { + const content: string; + export default content; } -export {}; +type TguiMessage = { + type: string; + payload?: any; + [key: string]: any; +}; + +type ByondType = { + /** + * ID of the Byond window this script is running on. + * Can be used as a parameter to winget/winset. + */ + windowId: string; + + /** + * True if javascript is running in BYOND. + */ + IS_BYOND: boolean; + + /** + * Version of Trident engine of Internet Explorer. Null if N/A. + */ + TRIDENT: number | null; + + /** + * True if browser is IE8 or lower. + */ + IS_LTE_IE8: boolean; + + /** + * True if browser is IE9 or lower. + */ + IS_LTE_IE9: boolean; + + /** + * True if browser is IE10 or lower. + */ + IS_LTE_IE10: boolean; + + /** + * True if browser is IE11 or lower. + */ + IS_LTE_IE11: boolean; + + /** + * Makes a BYOND call. + * + * If path is empty, this will trigger a Topic call. + * You can reference a specific object by setting the "src" parameter. + * + * See: https://secure.byond.com/docs/ref/skinparams.html + */ + call(path: string, params: object): void; + + /** + * Makes an asynchronous BYOND call. Returns a promise. + */ + callAsync(path: string, params: object): Promise; + + /** + * Makes a Topic call. + * + * You can reference a specific object by setting the "src" parameter. + */ + topic(params: object): void; + + /** + * Runs a command or a verb. + */ + command(command: string): void; + + /** + * Retrieves all properties of the BYOND skin element. + * + * Returns a promise with a key-value object containing all properties. + */ + winget(id: string | null): Promise; + + /** + * Retrieves all properties of the BYOND skin element. + * + * Returns a promise with a key-value object containing all properties. + */ + winget(id: string | null, propName: '*'): Promise; + + /** + * Retrieves an exactly one property of the BYOND skin element, + * as defined in `propName`. + * + * Returns a promise with the value of that property. + */ + winget(id: string | null, propName: string): Promise; + + /** + * Retrieves multiple properties of the BYOND skin element, + * as defined in the `propNames` array. + * + * Returns a promise with a key-value object containing listed properties. + */ + winget(id: string | null, propNames: string[]): Promise; + + /** + * Assigns properties to BYOND skin elements in bulk. + */ + winset(props: object): void; + + /** + * Assigns properties to the BYOND skin element. + */ + winset(id: string | null, props: object): void; + + /** + * Sets a property on the BYOND skin element to a certain value. + */ + winset(id: string | null, propName: string, propValue: any): void; + + /** + * Parses BYOND JSON. + * + * Uses a special encoding to preserve `Infinity` and `NaN`. + */ + parseJson(text: string): any; + + /** + * Sends a message to `/datum/tgui_window` which hosts this window instance. + */ + sendMessage(type: string, payload?: any): void; + sendMessage(message: TguiMessage): void; + + /** + * Subscribe to incoming messages that were sent from `/datum/tgui_window`. + */ + subscribe(listener: (type: string, payload: any) => void): void; + + /** + * Subscribe to incoming messages *of some specific type* + * that were sent from `/datum/tgui_window`. + */ + subscribeTo(type: string, listener: (payload: any) => void): void; + + /** + * Loads a stylesheet into the document. + */ + loadCss(url: string): void; + + /** + * Loads a script into the document. + */ + loadJs(url: string): void; +}; + +/** + * Object that provides access to Byond Skin API and is available in + * any tgui application. + */ +const Byond: ByondType; + +interface Window { + Byond: ByondType; +} diff --git a/tgui/package.json b/tgui/package.json index a203dc4a3a25..6c2035a5ec48 100644 --- a/tgui/package.json +++ b/tgui/package.json @@ -2,6 +2,7 @@ "private": true, "name": "tgui-workspace", "version": "4.3.0", + "packageManager": "yarn@3.1.1", "workspaces": [ "packages/*" ], @@ -55,6 +56,5 @@ "webpack": "^5.50.0", "webpack-bundle-analyzer": "^4.4.2", "webpack-cli": "^4.7.2" - }, - "packageManager": "yarn@3.0.1" + } } diff --git a/tgui/packages/common/collections.spec.ts b/tgui/packages/common/collections.spec.ts new file mode 100644 index 000000000000..58eff7f354c9 --- /dev/null +++ b/tgui/packages/common/collections.spec.ts @@ -0,0 +1,20 @@ +import { range, zip } from "./collections"; + +// Type assertions, these will lint if the types are wrong. +const _zip1: [string, number] = zip(["a"], [1])[0]; + +describe("range", () => { + test("range(0, 5)", () => { + expect(range(0, 5)).toEqual([0, 1, 2, 3, 4]); + }); +}); + +describe("zip", () => { + test("zip(['a', 'b', 'c'], [1, 2, 3, 4])", () => { + expect(zip(["a", "b", "c"], [1, 2, 3, 4])).toEqual([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + }); +}); diff --git a/tgui/packages/common/collections.ts b/tgui/packages/common/collections.ts index baf02242372e..5ab378070f0b 100644 --- a/tgui/packages/common/collections.ts +++ b/tgui/packages/common/collections.ts @@ -11,34 +11,43 @@ * * 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: (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); +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[]; + (iterateeFn: ( + value: T, + index: number, + collection: T[], + ) => U): (collection: T[]) => U[]; + + (iterateeFn: ( + value: T, + index: K, + collection: Record, + ) => U): (collection: Record) => U[]; }; /** @@ -49,8 +58,7 @@ type MapFunction = { * If collection is 'null' or 'undefined', it will be returned "as is" * without emitting any errors (which can be useful in some cases). */ -export const map: MapFunction = - (iterateeFn) => +export const map: MapFunction = (iterateeFn) => (collection: T[]): U[] => { if (collection === null || collection === undefined) { return collection; @@ -73,10 +81,9 @@ export const map: MapFunction = * 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[] => { +export const filterMap = (collection: T[], iterateeFn: ( + value: T +) => U | undefined): U[] => { const finalCollection: U[] = []; for (const value of collection) { @@ -112,22 +119,22 @@ const COMPARATOR = (objA, objB) => { * * Iteratees are called with one argument (value). */ -export const sortBy = - (...iterateeFns: ((input: T) => unknown)[]) => - (array: T[]): T[] => { +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; + criteria: unknown[], + value: T, }[] = []; for (let i = 0; i < length; i++) { const value = array[i]; mappedArray.push({ - criteria: iterateeFns.map((fn) => fn(value)), + criteria: iterateeFns.map(fn => fn(value)), value, }); } @@ -156,14 +163,15 @@ export const range = (start: number, end: number): number[] => /** * 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; } @@ -184,14 +192,13 @@ 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?: (value: T) => unknown) => - (array: T[]): T[] => { +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]; @@ -207,7 +214,8 @@ export const uniqBy = seen.push(computed); } result.push(value); - } else if (!seen.includes(computed)) { + } + else if (!seen.includes(computed)) { if (seen !== result) { seen.push(computed); } @@ -216,6 +224,7 @@ export const uniqBy = } return result; }; +/* eslint-enable indent */ export const uniq = uniqBy(); @@ -252,8 +261,7 @@ export const zip = (...arrays: T): Zip => { * specify how grouped values should be combined. The iteratee is * invoked with the elements of each group. */ -export const zipWith = - (iterateeFn: (...values: T[]) => U) => +export const zipWith = (iterateeFn: (...values: T[]) => U) => (...arrays: T[][]): U[] => { return map((values: T[]) => iterateeFn(...values))(zip(...arrays)); }; @@ -261,7 +269,7 @@ export const zipWith = const binarySearch = ( getKey: (value: T) => U, collection: readonly T[], - inserting: T + inserting: T, ): number => { if (collection.length === 0) { return 0; @@ -293,52 +301,12 @@ const binarySearch = ( return compare > insertingKey ? middle : middle + 1; }; -export const binaryInsertWith = - (getKey: (value: T) => U) => - (collection: readonly T[], value: T) => { +export const binaryInsertWith = (getKey: (value: T) => U): + ((collection: readonly T[], value: T) => T[]) => +{ + return (collection, value) => { 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; - -// Does a deep merge of two objects. DO NOT FEED CIRCULAR OBJECTS!! -export const deepMerge = (...objects: any[]): any => { - const target = {}; - for (const object of objects) { - for (const key of Object.keys(object)) { - const targetValue = target[key]; - const objectValue = object[key]; - if (Array.isArray(targetValue) && Array.isArray(objectValue)) { - target[key] = [...targetValue, ...objectValue]; - } else if (isObject(targetValue) && isObject(objectValue)) { - target[key] = deepMerge(targetValue, objectValue); - } else { - target[key] = objectValue; - } - } - } - return target; }; diff --git a/tgui/packages/common/color.js b/tgui/packages/common/color.js index 4a62b97b8491..2aadae8d6bdf 100644 --- a/tgui/packages/common/color.js +++ b/tgui/packages/common/color.js @@ -15,24 +15,12 @@ export class Color { } toString() { - return `rgba(${this.r | 0}, ${this.g | 0}, ${this.b | 0}, ${this.a | 0})`; - } - - // Darkens a color by a given percent. Returns a color, which can have toString called to get it's rgba() css value. - darken(percent) { - percent /= 100; - return new Color( - this.r - this.r * percent, - this.g - this.g * percent, - this.b - this.b * percent, - this.a - ); - } - - // Brightens a color by a given percent. Returns a color, which can have toString called to get it's rgba() css value. - lighten(percent) { - // No point in rewriting code we already have. - return this.darken(-percent); + // Alpha component needs to permit fractional values, so cannot use | + let alpha = parseFloat(this.a); + if (isNaN(alpha)) { + alpha = 1; + } + return `rgba(${this.r | 0}, ${this.g | 0}, ${this.b | 0}, ${alpha})`; } } diff --git a/tgui/packages/common/exhaustive.ts b/tgui/packages/common/exhaustive.ts index 794e3866d248..bc41757515b0 100644 --- a/tgui/packages/common/exhaustive.ts +++ b/tgui/packages/common/exhaustive.ts @@ -14,6 +14,6 @@ * exhaustiveCheck(color); * } */ - export const exhaustiveCheck = (input: never) => { +export const exhaustiveCheck = (input: never) => { throw new Error(`Unhandled case: ${input}`); }; diff --git a/tgui/packages/common/math.ts b/tgui/packages/common/math.ts new file mode 100644 index 000000000000..97e6b60b2ed4 --- /dev/null +++ b/tgui/packages/common/math.ts @@ -0,0 +1,100 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +/** + * Limits a number to the range between 'min' and 'max'. + */ +export const clamp = (value, min, max) => { + return value < min ? min : value > max ? max : value; +}; + +/** + * Limits a number between 0 and 1. + */ +export const clamp01 = value => { + return value < 0 ? 0 : value > 1 ? 1 : value; +}; + +/** + * Scales a number to fit into the range between min and max. + */ +export const scale = (value, min, max) => { + return (value - min) / (max - min); +}; + +/** + * Robust number rounding. + * + * Adapted from Locutus, see: http://locutus.io/php/math/round/ + * + * @param {number} value + * @param {number} precision + * @return {number} + */ +export const round = (value, precision) => { + if (!value || isNaN(value)) { + return value; + } + // helper variables + let m, f, isHalf, sgn; + // making sure precision is integer + precision |= 0; + m = Math.pow(10, precision); + value *= m; + // sign of the number + sgn = +(value > 0) | -(value < 0); + // isHalf = value % 1 === 0.5 * sgn; + isHalf = Math.abs(value % 1) >= 0.4999999999854481; + f = Math.floor(value); + if (isHalf) { + // rounds .5 away from zero + value = f + (sgn > 0); + } + return (isHalf ? value : Math.round(value)) / m; +}; + +/** + * Returns a string representing a number in fixed point notation. + */ +export const toFixed = (value, fractionDigits = 0) => { + return Number(value).toFixed(Math.max(fractionDigits, 0)); +}; + +/** + * Checks whether a value is within the provided range. + * + * Range is an array of two numbers, for example: [0, 15]. + */ +export const inRange = (value, range) => { + return range + && value >= range[0] + && value <= range[1]; +}; + +/** + * Walks over the object with ranges, comparing value against every range, + * and returns the key of the first matching range. + * + * Range is an array of two numbers, for example: [0, 15]. + */ +export const keyOfMatchingRange = (value, ranges) => { + for (let rangeName of Object.keys(ranges)) { + const range = ranges[rangeName]; + if (inRange(value, range)) { + return rangeName; + } + } +}; + +/** + * Get number of digits following the decimal point in a number + */ +export const numberOfDecimalDigits = value => { + if (Math.floor(value) !== value) { + return value.toString().split('.')[1].length || 0; + } + return 0; +}; diff --git a/tgui/packages/common/types.ts b/tgui/packages/common/types.ts index e219bd3b7e12..a92ac122d9fe 100644 --- a/tgui/packages/common/types.ts +++ b/tgui/packages/common/types.ts @@ -1,6 +1,5 @@ /** * Returns the arguments of a function F as an array. */ -// prettier-ignore export type ArgumentsOf = F extends (...args: infer A) => unknown ? A : never; diff --git a/tgui/packages/tgfont/icons/bad-touch.svg b/tgui/packages/tgfont/icons/bad-touch.svg index 795f4c2d840a..6dc3c9a718a7 100644 --- a/tgui/packages/tgfont/icons/bad-touch.svg +++ b/tgui/packages/tgfont/icons/bad-touch.svg @@ -1,46 +1,23 @@ - -image/svg+xml - - - - + + + + + + + - - - + + + diff --git a/tgui/packages/tgfont/icons/non-binary.svg b/tgui/packages/tgfont/icons/non-binary.svg index c708c26d9855..9aaec674bbbc 100644 --- a/tgui/packages/tgfont/icons/non-binary.svg +++ b/tgui/packages/tgfont/icons/non-binary.svg @@ -1,44 +1,17 @@ - -image/svg+xml - + + + + - - - - - - + + + + + + diff --git a/tgui/packages/tgfont/icons/prosthetic-leg.svg b/tgui/packages/tgfont/icons/prosthetic-leg.svg index f4e16dccfb65..c1f6ceee3fc3 100644 --- a/tgui/packages/tgfont/icons/prosthetic-leg.svg +++ b/tgui/packages/tgfont/icons/prosthetic-leg.svg @@ -1,35 +1,22 @@ - -image/svg+xml - - - - + + + + + + + diff --git a/tgui/packages/tgfont/icons/syndicate-logo.svg b/tgui/packages/tgfont/icons/syndicate-logo.svg index 423dd7c62754..eda92f9b3082 100644 --- a/tgui/packages/tgfont/icons/syndicate-logo.svg +++ b/tgui/packages/tgfont/icons/syndicate-logo.svg @@ -1,13 +1,3 @@ - - - - - - image/svg+xml - - - - - - - + + + diff --git a/tgui/packages/tgui-panel/Panel.js b/tgui/packages/tgui-panel/Panel.js index 927d793b17ab..d6cc9eb7efc8 100644 --- a/tgui/packages/tgui-panel/Panel.js +++ b/tgui/packages/tgui-panel/Panel.js @@ -4,11 +4,11 @@ * @license MIT */ -import { Button, Flex, Section } from 'tgui/components'; +import { Button, Section, Stack } from 'tgui/components'; import { Pane } from 'tgui/layouts'; import { NowPlayingWidget, useAudio } from './audio'; import { ChatPanel, ChatTabs } from './chat'; -import { gameReducer, useGame } from './game'; +import { useGame } from './game'; import { Notifications } from './Notifications'; import { PingIndicator } from './ping'; import { SettingsPanel, useSettings } from './settings'; @@ -34,19 +34,17 @@ export const Panel = (props, context) => { } return ( - - + +
- - + + - - +
+ -
- + + - + + -
+ + - + {audio.visible && ( - +
-
+ )} {settings.visible && ( - + - + )} - +
@@ -96,21 +94,19 @@ export const Panel = (props, context) => { )}> You are either AFK, experiencing lag or the connection - has closed. If the server has been nuked, you - are just lagging, you should be fine in a moment. + has closed. )} - {game.reconnectTimer > 0 && ( + {game.roundRestartedAt && ( The connection has been closed because the server is - restarting. Please wait while you are automatically reconnected - in {game.reconnectTimer} Seconds. + restarting. Please wait while you automatically reconnect. )}
-
- + +
); }; @@ -132,9 +128,7 @@ const HoboPanel = (props, context) => { Settings {settings.visible && ( - - - + ) || ( )} diff --git a/tgui/packages/tgui-panel/chat/ChatPageSettings.js b/tgui/packages/tgui-panel/chat/ChatPageSettings.js index 278fbab8f2f2..ba045983d2f3 100644 --- a/tgui/packages/tgui-panel/chat/ChatPageSettings.js +++ b/tgui/packages/tgui-panel/chat/ChatPageSettings.js @@ -5,7 +5,7 @@ */ import { useDispatch, useSelector } from 'common/redux'; -import { Button, Collapsible, Divider, Stack, Input, Section } from 'tgui/components'; +import { Button, Collapsible, Divider, Input, Section, Stack } from 'tgui/components'; import { removeChatPage, toggleAcceptedType, updateChatPage } from './actions'; import { MESSAGE_TYPES } from './constants'; import { selectCurrentChatPage } from './selectors'; diff --git a/tgui/packages/tgui-panel/chat/constants.js b/tgui/packages/tgui-panel/chat/constants.js index dab3172cef66..0333b6489991 100644 --- a/tgui/packages/tgui-panel/chat/constants.js +++ b/tgui/packages/tgui-panel/chat/constants.js @@ -31,12 +31,11 @@ export const MESSAGE_TYPE_OOC = 'ooc'; export const MESSAGE_TYPE_ADMINPM = 'adminpm'; export const MESSAGE_TYPE_COMBAT = 'combat'; export const MESSAGE_TYPE_ADMINCHAT = 'adminchat'; +export const MESSAGE_TYPE_MODCHAT = 'modchat'; export const MESSAGE_TYPE_EVENTCHAT = 'eventchat'; export const MESSAGE_TYPE_ADMINLOG = 'adminlog'; export const MESSAGE_TYPE_ATTACKLOG = 'attacklog'; export const MESSAGE_TYPE_DEBUG = 'debug'; -export const MESSAGE_TYPE_MENTORPM = 'mentorpm'; -export const MESSAGE_TYPE_DONATOR = 'donator'; // Metadata for each message type export const MESSAGE_TYPES = [ @@ -59,31 +58,31 @@ export const MESSAGE_TYPES = [ type: MESSAGE_TYPE_RADIO, name: 'Radio', description: 'All departments of radio messages', - selector: '.alert, .syndradio, .centradio, .airadio, .entradio, .comradio, .secradio, .engradio, .medradio, .sciradio, .supradio, .srvradio, .expradio, .radio, .deptradio, .newscaster, .commonradio, .cultitalic, .shadowling, .aiprivradio', + selector: '.alert, .minorannounce, .syndradio, .centcomradio, .aiprivradio, .comradio, .secradio, .gangradio, .engradio, .medradio, .sciradio, .suppradio, .servradio, .radio, .deptradio, .binarysay, .newscaster, .resonate', }, { type: MESSAGE_TYPE_INFO, name: 'Info', description: 'Non-urgent messages from the game and items', - selector: '.notice:not(.pm), .adminnotice, .info, .sinister, .cult', + selector: '.notice:not(.pm), .adminnotice, .info, .sinister, .cult, .infoplain, .announce, .hear, .smallnotice, .holoparasite, .boldnotice', }, { type: MESSAGE_TYPE_WARNING, name: 'Warnings', description: 'Urgent messages from the game and items', - selector: '.warning:not(.pm), .critical, .userdanger, .italics', + selector: '.warning:not(.pm), .critical, .userdanger, .italics, .alertsyndie, .warningplain', }, { type: MESSAGE_TYPE_DEADCHAT, name: 'Deadchat', description: 'All of deadchat', - selector: '.deadsay', + selector: '.deadsay, .ghostalert', }, { type: MESSAGE_TYPE_OOC, name: 'OOC', description: 'The bluewall of global OOC messages', - selector: '.ooc, .adminooc, .adminobserverooc', + selector: '.ooc, .adminooc, .adminobserverooc, .oocplain', }, { type: MESSAGE_TYPE_ADMINPM, @@ -91,18 +90,6 @@ export const MESSAGE_TYPES = [ description: 'Messages to/from admins (adminhelp)', selector: '.pm, .adminhelp', }, - { - type: MESSAGE_TYPE_MENTORPM, - name: 'Mentor PMs', - description: 'Messages to/from mentors (mentorhelp)', - selector: '.mentor', - }, - { - type: MESSAGE_TYPE_DONATOR, - name: "Donator", - description: 'Messages to/from donators', - selector: '.donator', - }, { type: MESSAGE_TYPE_COMBAT, name: 'Combat Log', @@ -122,6 +109,13 @@ export const MESSAGE_TYPES = [ selector: '.admin_channel, .adminsay', admin: true, }, + { + type: MESSAGE_TYPE_MODCHAT, + name: 'Mod Chat', + description: 'MSAY messages', + selector: '.mod_channel', + admin: true, + }, { type: MESSAGE_TYPE_ADMINLOG, name: 'Admin Log', diff --git a/tgui/packages/tgui-panel/chat/middleware.js b/tgui/packages/tgui-panel/chat/middleware.js index bb903f018aca..6cfc72bd2994 100644 --- a/tgui/packages/tgui-panel/chat/middleware.js +++ b/tgui/packages/tgui-panel/chat/middleware.js @@ -4,6 +4,7 @@ * @license MIT */ +import DOMPurify from 'dompurify'; import { storage } from 'common/storage'; import { loadSettings, updateSettings } from '../settings/actions'; import { selectSettings } from '../settings/selectors'; @@ -13,6 +14,14 @@ import { createMessage, serializeMessage } from './model'; import { chatRenderer } from './renderer'; import { selectChat, selectCurrentChatPage } from './selectors'; +// List of blacklisted tags +const FORBID_TAGS = [ + 'a', + 'iframe', + 'link', + 'video', +]; + const saveChatToStorage = async store => { const state = selectChat(store.getState()); const fromIndex = Math.max(0, @@ -30,11 +39,18 @@ const loadChatFromStorage = async store => { storage.get('chat-messages'), ]); // Discard incompatible versions - if (state && state.version <= 5) { + if (state && state.version <= 4) { store.dispatch(loadChat()); return; } if (messages) { + for (let message of messages) { + if (message.html) { + message.html = DOMPurify.sanitize(message.html, { + FORBID_TAGS, + }); + } + } const batch = [ ...messages, createMessage({ diff --git a/tgui/packages/tgui-panel/chat/model.js b/tgui/packages/tgui-panel/chat/model.js index 035bd0cf2781..7400d5e13cd3 100644 --- a/tgui/packages/tgui-panel/chat/model.js +++ b/tgui/packages/tgui-panel/chat/model.js @@ -11,14 +11,22 @@ export const canPageAcceptType = (page, type) => ( type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type] ); -export const createPage = obj => ({ - id: createUuid(), - name: 'New Tab', - acceptedTypes: {}, - unreadCount: 0, - createdAt: Date.now(), - ...obj, -}); +export const createPage = obj => { + let acceptedTypes = {}; + + for (let typeDef of MESSAGE_TYPES) { + acceptedTypes[typeDef.type] = !!typeDef.important; + } + + return { + id: createUuid(), + name: 'New Tab', + acceptedTypes: acceptedTypes, + unreadCount: 0, + createdAt: Date.now(), + ...obj, + }; +}; export const createMainPage = () => { const acceptedTypes = {}; diff --git a/tgui/packages/tgui-panel/chat/reducer.js b/tgui/packages/tgui-panel/chat/reducer.js index d4b7b809689c..0188b1fabe8b 100644 --- a/tgui/packages/tgui-panel/chat/reducer.js +++ b/tgui/packages/tgui-panel/chat/reducer.js @@ -10,7 +10,7 @@ import { canPageAcceptType, createMainPage } from './model'; const mainPage = createMainPage(); export const initialState = { - version: 6, + version: 5, currentPageId: mainPage.id, scrollTracking: true, pages: [ @@ -28,6 +28,19 @@ export const chatReducer = (state = initialState, action) => { if (payload?.version !== state.version) { return state; } + // Enable any filters that are not explicitly set, that are + // enabled by default on the main page. + // NOTE: This mutates acceptedTypes on the state. + for (let id of Object.keys(payload.pageById)) { + const page = payload.pageById[id]; + const filters = page.acceptedTypes; + const defaultFilters = mainPage.acceptedTypes; + for (let type of Object.keys(defaultFilters)) { + if (filters[type] === undefined) { + filters[type] = defaultFilters[type]; + } + } + } // Reset page message counts // NOTE: We are mutably changing the payload on the assumption // that it is a copy that comes straight from the web storage. diff --git a/tgui/packages/tgui-panel/chat/renderer.js b/tgui/packages/tgui-panel/chat/renderer.js index 3825e94c846b..b95066116053 100644 --- a/tgui/packages/tgui-panel/chat/renderer.js +++ b/tgui/packages/tgui-panel/chat/renderer.js @@ -8,8 +8,10 @@ import { EventEmitter } from 'common/events'; import { classes } from 'common/react'; import { createLogger } from 'tgui/logging'; import { COMBINE_MAX_MESSAGES, COMBINE_MAX_TIME_WINDOW, IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, IMAGE_RETRY_MESSAGE_AGE, MAX_PERSISTED_MESSAGES, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL, MESSAGE_TYPE_UNKNOWN } from './constants'; +import { render } from 'inferno'; import { canPageAcceptType, createMessage, isSameMessage } from './model'; import { highlightNode, linkifyNode } from './replaceInTextNode'; +import { Tooltip } from "../../tgui/components"; const logger = createLogger('chatRenderer'); @@ -17,6 +19,18 @@ const logger = createLogger('chatRenderer'); // that is still trackable. const SCROLL_TRACKING_TOLERANCE = 24; +// List of injectable component names to the actual type +export const TGUI_CHAT_COMPONENTS = { + Tooltip, +}; + +// List of injectable attibute names mapped to their proper prop +// We need this because attibutes don't support lowercase names +export const TGUI_CHAT_ATTRIBUTES_TO_PROPS = { + "position": "position", + "content": "content", +}; + const findNearestScrollableParent = startingNode => { const body = document.body; let node = startingNode; @@ -176,7 +190,6 @@ class ChatRenderer { this.highlightColor = null; return; } - // const allowedRegex = /^[a-z0-9_-\s]+$/ig; const lines = String(text) .split(',') // eslint-disable-next-line no-useless-escape @@ -302,6 +315,51 @@ class ChatRenderer { else { logger.error('Error: message is missing text payload', message); } + // Get all nodes in this message that want to be rendered like jsx + const nodes = node.querySelectorAll("[data-component]"); + for (let i = 0; i < nodes.length; i++) { + const childNode = nodes[i]; + const targetName = childNode.getAttribute("data-component"); + // Let's pull out the attibute info we need + let outputProps = {}; + for (let j = 0; j < childNode.attributes.length; j++) { + const attribute = childNode.attributes[j]; + + let working_value = attribute.nodeValue; + // We can't do the "if it has no value it's truthy" trick + // Because getAttribute returns "", not null. Hate IE + if (working_value === "$true") { + working_value = true; + } + else if (working_value === "$false") { + working_value = false; + } + else if (!isNaN(working_value)) { + const parsed_float = parseFloat(working_value); + if (!isNaN(parsed_float)) { + working_value = parsed_float; + } + } + + let canon_name = attribute.nodeName.replace("data-", ""); + // html attributes don't support upper case chars, so we need to map + canon_name = TGUI_CHAT_ATTRIBUTES_TO_PROPS[canon_name]; + outputProps[canon_name] = working_value; + } + const oldHtml = { __html: childNode.innerHTML }; + while (childNode.firstChild) { + childNode.removeChild(childNode.firstChild); + } + const Element = TGUI_CHAT_COMPONENTS[targetName]; + /* eslint-disable react/no-danger */ + render( + +
+ , childNode); + /* eslint-enable react/no-danger */ + + } + // Highlight text if (!message.avoidHighlighting && this.highlightRegex) { const highlighted = highlightNode(node, @@ -450,7 +508,7 @@ class ChatRenderer { cssText += 'body, html { background-color: #141414 }\n'; // Compile chat log as HTML text let messagesHtml = ''; - for (let message of this.messages) { + for (let message of this.visibleMessages) { if (message.node) { messagesHtml += message.node.outerHTML + '\n'; } diff --git a/tgui/packages/tgui-panel/game/actions.js b/tgui/packages/tgui-panel/game/actions.js index 4fb425267de2..e40014c44b04 100644 --- a/tgui/packages/tgui-panel/game/actions.js +++ b/tgui/packages/tgui-panel/game/actions.js @@ -7,6 +7,5 @@ import { createAction } from 'common/redux'; export const roundRestarted = createAction('roundrestart'); -export const reconnected = createAction('reconnected'); export const connectionLost = createAction('game/connectionLost'); export const connectionRestored = createAction('game/connectionRestored'); diff --git a/tgui/packages/tgui-panel/game/reducer.js b/tgui/packages/tgui-panel/game/reducer.js index b3853bb2dfb5..97535524c560 100644 --- a/tgui/packages/tgui-panel/game/reducer.js +++ b/tgui/packages/tgui-panel/game/reducer.js @@ -6,7 +6,6 @@ import { connectionLost } from './actions'; import { connectionRestored } from './actions'; -import { reconnected } from './actions'; const initialState = { // TODO: This is where round info should be. @@ -14,9 +13,6 @@ const initialState = { roundTime: null, roundRestartedAt: null, connectionLostAt: null, - rebooting: false, - reconnectTimer: 0, - reconnected: false, }; export const gameReducer = (state = initialState, action) => { @@ -25,23 +21,8 @@ export const gameReducer = (state = initialState, action) => { return { ...state, roundRestartedAt: meta.now, - rebooting: true, - reconnectTimer: 14, - reconnected: false, - tryingtoreconnect: true, }; } - if (type === 'reconnected') { - return { - ...state, - reconnected: true, - rebooting: false, - }; - } - if (state.rebooting === true && state.tryingtoreconnect === true) { - setInterval(() => { reconnectplease(); }, 10000); - state.tryingtoreconnect = false; - } if (type === connectionLost.type) { return { ...state, @@ -54,10 +35,5 @@ export const gameReducer = (state = initialState, action) => { connectionLostAt: null, }; } - let reconnectplease = function () { - if (state.reconnected === false) { - Byond.command('.reconnect'); - } - }; return state; }; diff --git a/tgui/packages/tgui-panel/index.js b/tgui/packages/tgui-panel/index.js index 020b7b59b329..721fa97fb50c 100644 --- a/tgui/packages/tgui-panel/index.js +++ b/tgui/packages/tgui-panel/index.js @@ -68,20 +68,11 @@ const setupApp = () => { setupPanelFocusHacks(); captureExternalLinks(); - // Subscribe for Redux state updates + // Re-render UI on store updates store.subscribe(renderApp); - // Subscribe for bankend updates - window.update = msg => store.dispatch(Byond.parseJson(msg)); - - // Process the early update queue - while (true) { - const msg = window.__updateQueue__.shift(); - if (!msg) { - break; - } - window.update(msg); - } + // Dispatch incoming messages as store actions + Byond.subscribe((type, payload) => store.dispatch({ type, payload })); // Unhide the panel Byond.winset('output', { @@ -94,6 +85,13 @@ const setupApp = () => { 'size': '0x0', }); + // Resize the panel to match the non-browser output + Byond.winget('output').then(output => { + Byond.winset('browseroutput', { + 'size': output.size, + }); + }); + // Enable hot module reloading if (module.hot) { setupHotReloading(); diff --git a/tgui/packages/tgui-panel/ping/middleware.js b/tgui/packages/tgui-panel/ping/middleware.js index fdd841095719..2d607c841740 100644 --- a/tgui/packages/tgui-panel/ping/middleware.js +++ b/tgui/packages/tgui-panel/ping/middleware.js @@ -4,13 +4,13 @@ * @license MIT */ -import { sendMessage } from 'tgui/backend'; import { pingFail, pingSuccess } from './actions'; import { PING_INTERVAL, PING_QUEUE_SIZE, PING_TIMEOUT } from './constants'; export const pingMiddleware = store => { let initialized = false; let index = 0; + let interval; const pings = []; const sendPing = () => { for (let i = 0; i < PING_QUEUE_SIZE; i++) { @@ -22,19 +22,22 @@ export const pingMiddleware = store => { } const ping = { index, sentAt: Date.now() }; pings[index] = ping; - sendMessage({ - type: 'ping', - payload: { index }, - }); + Byond.sendMessage('ping', { index }); index = (index + 1) % PING_QUEUE_SIZE; }; return next => action => { const { type, payload } = action; if (!initialized) { initialized = true; - setInterval(sendPing, PING_INTERVAL); + interval = setInterval(sendPing, PING_INTERVAL); sendPing(); } + if (type === 'roundrestart') { + // Stop pinging because dreamseeker is currently reconnecting. + // Topic calls in the middle of reconnect will crash the connection. + clearInterval(interval); + return next(action); + } if (type === 'pingReply') { const { index } = payload; const ping = pings[index]; diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.js b/tgui/packages/tgui-panel/settings/SettingsPanel.js index d449fd3e7a68..01df419ce21f 100644 --- a/tgui/packages/tgui-panel/settings/SettingsPanel.js +++ b/tgui/packages/tgui-panel/settings/SettingsPanel.js @@ -23,17 +23,13 @@ export const SettingsPanel = (props, context) => {
- {SETTINGS_TABS.map((tab) => ( + {SETTINGS_TABS.map(tab => ( - dispatch( - changeSettingsTab({ - tabId: tab.id, - }) - ) - }> + onClick={() => dispatch(changeSettingsTab({ + tabId: tab.id, + }))}> {tab.name} ))} @@ -41,8 +37,12 @@ export const SettingsPanel = (props, context) => {
- {activeTab === 'general' && } - {activeTab === 'chatPage' && } + {activeTab === 'general' && ( + + )} + {activeTab === 'chatPage' && ( + + )} ); @@ -60,7 +60,7 @@ export const SettingsGeneral = (props, context) => { matchCase, } = useSelector(context, selectSettings); const dispatch = useDispatch(context); - const [freeFont, setFreeFont] = useLocalState(context, 'freeFont', false); + const [freeFont, setFreeFont] = useLocalState(context, "freeFont", false); return (
@@ -68,48 +68,34 @@ export const SettingsGeneral = (props, context) => { - dispatch( - updateSettings({ - theme: value, - }) - ) - } - /> + onSelected={value => dispatch(updateSettings({ + theme: value, + }))} /> - {(!freeFont && ( + {!freeFont && ( - dispatch( - updateSettings({ - fontFamily: value, - }) - ) - } - /> - )) || ( + onSelected={value => dispatch(updateSettings({ + fontFamily: value, + }))} /> + ) || ( - dispatch( - updateSettings({ - fontFamily: value, - }) - ) - } + onChange={(e, value) => dispatch(updateSettings({ + fontFamily: value, + }))} /> )}