Adds a little button to quirks that allows for relatively easy customization (#79251)

## About The Pull Request

Title. Adds a little cog button that expands a popper with a preference
list of relevant preferences.


https://github.com/tgstation/tgstation/assets/59709059/5718ad5d-fadb-489f-9a31-9e7173c6f35a

## Why It's Good For The Game

Customizable quirks are cool. Having a proper framework for customizable
quirks is even cooler. Good UX is even COOLER.
## Changelog
🆑
code: Quirks are now customizable on the quirks page instead of on the
character prefs page
/🆑

---------

Co-authored-by: Mothblocks <35135081+Mothblocks@users.noreply.github.com>
This commit is contained in:
nikothedude
2023-11-12 18:11:08 -05:00
committed by GitHub
parent 56835f4148
commit 49414f7821
20 changed files with 296 additions and 47 deletions

View File

@@ -131,6 +131,9 @@
/// such as hair color being affixed to hair.
#define PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES "supplemental_features"
/// These preferences will not be rendered on the preferences page, and are practically invisible unless specifically rendered. Used for quirks, currently.
#define PREFERENCE_CATEGORY_MANUALLY_RENDERED "manually_rendered_features"
// Playtime is tracked in minutes
/// The time needed to unlock hardcore random mode in preferences
#define PLAYTIME_HARDCORE_RANDOM 120 // 2 hours

View File

@@ -0,0 +1,72 @@
GLOBAL_LIST_INIT_TYPED(all_quirk_constant_data, /datum/quirk_constant_data, generate_quirk_constant_data())
/// Constructs [GLOB.all_quirk_constant_data] by iterating through a typecache of pregen data, ignoring abstract types, and instantiating the rest.
/proc/generate_quirk_constant_data()
RETURN_TYPE(/list/datum/quirk_constant_data)
var/list/datum/quirk_constant_data/all_constant_data = list()
for (var/datum/quirk_constant_data/iterated_path as anything in typecacheof(path = /datum/quirk_constant_data, ignore_root_path = TRUE))
if (initial(iterated_path.abstract_type) == iterated_path)
continue
if (!isnull(all_constant_data[initial(iterated_path.associated_typepath)]))
stack_trace("pre-existing pregen data for [initial(iterated_path.associated_typepath)] when [iterated_path] was being considered: [all_constant_data[initial(iterated_path.associated_typepath)]]. \
this is definitely a bug, and is probably because one of the two pregen data have the wrong quirk typepath defined. [iterated_path] will not be instantiated")
continue
var/datum/quirk_constant_data/pregen_data = new iterated_path
all_constant_data[pregen_data.associated_typepath] = pregen_data
return all_constant_data
/// A singleton datum representing constant data and procs used by quirks.
/datum/quirk_constant_data
/// Abstract in OOP terms. If this is our type, we will not be instantiated.
var/abstract_type = /datum/quirk_constant_data
/// The typepath of the quirk we will be associated with in the global list. This is what we represent.
var/datum/quirk/associated_typepath
/// A lazylist of preference datum typepaths. Any character pref put in here will be rendered in the quirks page under a dropdown.
var/list/datum/preference/customization_options
/datum/quirk_constant_data/New()
. = ..()
ASSERT(abstract_type != type && !isnull(associated_typepath), "associated_typepath null - please set it! occured on: [src.type]")
/// Returns a list of savefile_keys derived from the preference typepaths in [customization_options]. Used in quirks middleware to supply the preferences to render.
/datum/quirk_constant_data/proc/get_customization_data()
RETURN_TYPE(/list)
var/list/customization_data = list()
for (var/datum/preference/pref_type as anything in customization_options)
var/datum/preference/pref_instance = GLOB.preference_entries[pref_type]
if (isnull(pref_instance))
stack_trace("get_customization_data was called before instantiation of [pref_type]!")
continue // just in case its a fluke and its only this one thats not instantiated, we'll check the other pref entries
customization_data += pref_instance.savefile_key
return customization_data
/// Is this quirk customizable? If true, a button will appear within the quirk's description box in the quirks page, and upon clicking it,
/// will open a customization menu for the quirk.
/datum/quirk_constant_data/proc/is_customizable()
return LAZYLEN(customization_options) > 0
/datum/quirk_constant_data/Destroy(force, ...)
var/error_message = "[src], a singleton quirk constant data instance, was destroyed! This should not happen!"
if (force)
error_message += " NOTE: This Destroy() was called with force == TRUE. This instance will be deleted and replaced with a new one."
stack_trace(error_message)
if (!force)
return QDEL_HINT_LETMELIVE
. = ..()
GLOB.all_quirk_constant_data[associated_typepath] = new src.type //recover

View File

@@ -25,6 +25,10 @@ GLOBAL_LIST_INIT(possible_food_allergies, list(
/// Footype flags that will trigger the allergy
var/target_foodtypes = NONE
/datum/quirk_constant_data/food_allergy
associated_typepath = /datum/quirk/item_quirk/food_allergic
customization_options = list(/datum/preference/choiced/food_allergy)
/datum/quirk/item_quirk/food_allergic/add(client/client_source)
if(target_foodtypes != NONE) // Already set, don't care
return

View File

@@ -10,6 +10,10 @@
quirk_flags = QUIRK_HUMAN_ONLY|QUIRK_CHANGES_APPEARANCE
mail_goodies = list(/obj/item/clothing/glasses/regular) // extra pair if orginal one gets broken by somebody mean
/datum/quirk_constant_data/nearsighted
associated_typepath = /datum/quirk/item_quirk/nearsighted
customization_options = list(/datum/preference/choiced/glasses)
/datum/quirk/item_quirk/nearsighted/add_unique(client/client_source)
var/glasses_name = client_source?.prefs.read_preference(/datum/preference/choiced/glasses) || "Regular"
var/obj/item/clothing/glasses/glasses_type

View File

@@ -11,6 +11,10 @@
/// the original limb from before the prosthetic was applied
var/obj/item/bodypart/old_limb
/datum/quirk_constant_data/prosthetic_limb
associated_typepath = /datum/quirk/prosthetic_limb
customization_options = list(/datum/preference/choiced/prosthetic)
/datum/quirk/prosthetic_limb/add_unique(client/client_source)
var/limb_type = GLOB.limb_choice[client_source?.prefs?.read_preference(/datum/preference/choiced/prosthetic)]
if(isnull(limb_type)) //Client gone or they chose a random prosthetic

View File

@@ -6,6 +6,10 @@
medical_record_text = "Patient has an irrational fear of something."
mail_goodies = list(/obj/item/clothing/glasses/blindfold, /obj/item/storage/pill_bottle/psicodine)
/datum/quirk_constant_data/phobia
associated_typepath = /datum/quirk/phobia
customization_options = list(/datum/preference/choiced/phobia)
// Phobia will follow you between transfers
/datum/quirk/phobia/add(client/client_source)
var/phobia = client_source?.prefs.read_preference(/datum/preference/choiced/phobia)

View File

@@ -8,6 +8,10 @@
medical_record_text = "Patient speaks multiple languages."
mail_goodies = list(/obj/item/taperecorder, /obj/item/clothing/head/frenchberet, /obj/item/clothing/mask/fakemoustache/italian)
/datum/quirk_constant_data/bilingual
associated_typepath = /datum/quirk/bilingual
customization_options = list(/datum/preference/choiced/language)
/datum/quirk/bilingual/add_unique(client/client_source)
var/wanted_language = client_source?.prefs.read_preference(/datum/preference/choiced/language)
var/datum/language/language_type

View File

@@ -14,6 +14,10 @@
/obj/item/canvas/twentythree_twentythree
)
/datum/quirk_constant_data/tagger
associated_typepath = /datum/quirk/item_quirk/tagger
customization_options = list(/datum/preference/color/paint_color)
/datum/quirk/item_quirk/tagger/add_unique(client/client_source)
var/obj/item/toy/crayon/spraycan/can = new
can.set_painting_tool_color(client_source?.prefs.read_preference(/datum/preference/color/paint_color))

View File

@@ -314,13 +314,13 @@ GLOBAL_LIST_EMPTY(preferences_datums)
if (!preference.is_accessible(src))
continue
LAZYINITLIST(preferences[preference.category])
var/value = read_preference(preference.type)
var/data = preference.compile_ui_data(user, value)
LAZYINITLIST(preferences[preference.category])
preferences[preference.category][preference.savefile_key] = data
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))

View File

@@ -1,5 +1,5 @@
/datum/preference/choiced/food_allergy
category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
savefile_key = "food_allergy"
savefile_identifier = PREFERENCE_CHARACTER
can_randomize = FALSE

View File

@@ -1,5 +1,5 @@
/datum/preference/choiced/glasses
category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
savefile_key = "glasses"
savefile_identifier = PREFERENCE_CHARACTER
should_generate_icons = TRUE

View File

@@ -1,5 +1,5 @@
/datum/preference/choiced/language
category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
savefile_key = "language"
savefile_identifier = PREFERENCE_CHARACTER

View File

@@ -33,11 +33,16 @@
for (var/quirk_name in quirks)
var/datum/quirk/quirk = quirks[quirk_name]
var/datum/quirk_constant_data/constant_data = GLOB.all_quirk_constant_data[quirk]
var/list/datum/preference/customization_options = constant_data?.get_customization_data()
quirk_info[sanitize_css_class_name(quirk_name)] = list(
"description" = initial(quirk.desc),
"icon" = initial(quirk.icon),
"name" = quirk_name,
"value" = initial(quirk.value),
"customizable" = constant_data?.is_customizable(),
"customization_options" = customization_options,
)
return list(

View File

@@ -2,7 +2,7 @@
/datum/preference/color/paint_color
savefile_key = "paint_color"
savefile_identifier = PREFERENCE_CHARACTER
category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
/datum/preference/color/paint_color/is_accessible(datum/preferences/preferences)
if (!..(preferences))

View File

@@ -1,5 +1,5 @@
/datum/preference/choiced/phobia
category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
savefile_key = "phobia"
savefile_identifier = PREFERENCE_CHARACTER

View File

@@ -1,5 +1,5 @@
/datum/preference/choiced/prosthetic
category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
savefile_key = "prosthetic"
savefile_identifier = PREFERENCE_CHARACTER

View File

@@ -1583,6 +1583,7 @@
#include "code\datums\proximity_monitor\fields\projectile_dampener.dm"
#include "code\datums\proximity_monitor\fields\timestop.dm"
#include "code\datums\quirks\_quirk.dm"
#include "code\datums\quirks\_quirk_constant_data.dm"
#include "code\datums\quirks\negative_quirks\allergic.dm"
#include "code\datums\quirks\negative_quirks\bad_back.dm"
#include "code\datums\quirks\negative_quirks\bad_touch.dm"

View File

@@ -1,7 +1,7 @@
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 { createSetPreference, PreferencesMenuData, RandomSetting, ServerData } from './data';
import { CharacterPreview } from '../common/CharacterPreview';
import { RandomizationButton } from './RandomizationButton';
import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
@@ -345,10 +345,11 @@ const sortPreferences = sortBy<[string, unknown]>(([featureId, _]) => {
return feature?.name;
});
const PreferenceList = (props: {
export const PreferenceList = (props: {
act: typeof sendAct;
preferences: Record<string, unknown>;
randomizations: Record<string, RandomSetting>;
maxHeight: string;
}) => {
return (
<Stack.Item
@@ -359,7 +360,8 @@ const PreferenceList = (props: {
padding: '4px',
}}
overflowX="hidden"
overflowY="scroll">
overflowY="auto"
maxHeight={props.maxHeight}>
<LabeledList>
{sortPreferences(Object.entries(props.preferences)).map(
([featureId, value]) => {
@@ -408,6 +410,37 @@ const PreferenceList = (props: {
);
};
export const getRandomization = (
preferences: Record<string, unknown>,
serverData: ServerData | undefined,
randomBodyEnabled: boolean,
context
): Record<string, RandomSetting> => {
if (!serverData) {
return {};
}
const { data } = useBackend<PreferencesMenuData>(context);
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,
];
})
);
};
export const MainPage = (
props: {
openSpecies: () => void;
@@ -454,36 +487,11 @@ export const MainPage = (
data.character_preferences.non_contextual.random_body !==
RandomSetting.Disabled || randomToggleEnabled;
const getRandomization = (
preferences: Record<string, unknown>
): Record<string, RandomSetting> => {
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)
Object.fromEntries(mainFeatures),
serverData,
randomBodyEnabled,
context
);
const nonContextualPreferences = {
@@ -600,14 +608,26 @@ export const MainPage = (
<Stack vertical fill>
<PreferenceList
act={act}
randomizations={getRandomization(contextualPreferences)}
randomizations={getRandomization(
contextualPreferences,
serverData,
randomBodyEnabled,
context
)}
preferences={contextualPreferences}
maxHeight="auto"
/>
<PreferenceList
act={act}
randomizations={getRandomization(nonContextualPreferences)}
randomizations={getRandomization(
nonContextualPreferences,
serverData,
randomBodyEnabled,
context
)}
preferences={nonContextualPreferences}
maxHeight="auto"
/>
</Stack>
</Stack.Item>

View File

@@ -1,8 +1,11 @@
import { StatelessComponent } from 'inferno';
import { Box, Icon, Stack, Tooltip } from '../../components';
import { PreferencesMenuData, Quirk } from './data';
import { Box, Button, Icon, Popper, Stack, Tooltip } from '../../components';
import { PreferencesMenuData, Quirk, RandomSetting, ServerData } from './data';
import { useBackend, useLocalState } from '../../backend';
import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
import { filterMap } from 'common/collections';
import { getRandomization, PreferenceList } from './MainPage';
import { useRandomToggleState } from './useRandomToggleState';
const getValueClass = (value: number): string => {
if (value > 0) {
@@ -14,6 +17,21 @@ const getValueClass = (value: number): string => {
}
};
const getCorrespondingPreferences = (
customization_options: string[],
relevant_preferences: Record<string, string>
): Record<string, unknown> => {
return Object.fromEntries(
filterMap(Object.keys(relevant_preferences), (key) => {
if (!customization_options.includes(key)) {
return undefined;
}
return [key, relevant_preferences[key]];
})
);
};
const QuirkList = (props: {
quirks: [
string,
@@ -22,13 +40,33 @@ const QuirkList = (props: {
}
][];
onClick: (quirkName: string, quirk: Quirk) => void;
selected: boolean;
serverData: ServerData;
randomBodyEnabled: boolean;
context;
}) => {
const { act, data } = useBackend<PreferencesMenuData>(props.context);
return (
// Stack is not used here for a variety of IE flex bugs
<Box className="PreferencesMenu__Quirks__QuirkList">
{props.quirks.map(([quirkKey, quirk]) => {
const [customizationExpanded, setCustomizationExpanded] =
useLocalState<boolean>(
props.context,
quirk.name + ' customization',
false
);
const className = 'PreferencesMenu__Quirks__QuirkList__quirk';
const hasExpandableCustomization =
quirk.customizable &&
props.selected &&
customizationExpanded &&
quirk.customization_options &&
Object.entries(quirk.customization_options).length > 0;
const child = (
<Box
className={className}
@@ -36,6 +74,9 @@ const QuirkList = (props: {
role="button"
tabIndex="1"
onClick={() => {
if (props.selected) {
setCustomizationExpanded(false);
}
props.onClick(quirkKey, quirk);
}}>
<Stack fill>
@@ -95,6 +136,72 @@ const QuirkList = (props: {
'padding': '3px',
}}>
{quirk.description}
{!!quirk.customizable && (
<Popper
options={{
placement: 'bottom-end',
}}
popperContent={
<Box>
{!!quirk.customization_options &&
hasExpandableCustomization && (
<Box
mt="1px"
style={{
'box-shadow':
'0px 4px 8px 3px rgba(0, 0, 0, 0.7)',
}}>
<Stack
onClick={(e) => {
e.stopPropagation();
}}
maxWidth="300px"
backgroundColor="black"
px="5px"
py="3px">
<Stack.Item>
<PreferenceList
act={act}
preferences={getCorrespondingPreferences(
quirk.customization_options,
data.character_preferences
.manually_rendered_features
)}
randomizations={getRandomization(
getCorrespondingPreferences(
quirk.customization_options,
data.character_preferences
.manually_rendered_features
),
props.serverData,
props.randomBodyEnabled,
props.context
)}
maxHeight="100px"
/>
</Stack.Item>
</Stack>
</Box>
)}
</Box>
}>
{props.selected && (
<Button
selected={customizationExpanded}
icon="cog"
tooltip="Customize"
onClick={(e) => {
e.stopPropagation();
setCustomizationExpanded(!customizationExpanded);
}}
style={{
'float': 'right',
}}
/>
)}
</Popper>
)}
</Stack.Item>
</Stack>
</Stack.Item>
@@ -129,6 +236,12 @@ const StatDisplay: StatelessComponent<{}> = (props) => {
export const QuirksPage = (props, context) => {
const { act, data } = useBackend<PreferencesMenuData>(context);
// this is mainly just here to copy from MainPage.tsx
const [randomToggleEnabled] = useRandomToggleState(context);
const randomBodyEnabled =
data.character_preferences.non_contextual.random_body !==
RandomSetting.Disabled || randomToggleEnabled;
const [selectedQuirks, setSelectedQuirks] = useLocalState(
context,
`selectedQuirks_${data.active_slot}`,
@@ -137,8 +250,8 @@ export const QuirksPage = (props, context) => {
return (
<ServerPreferencesFetcher
render={(data) => {
if (!data) {
render={(server_data) => {
if (!server_data) {
return <Box>Loading quirks...</Box>;
}
@@ -146,7 +259,7 @@ export const QuirksPage = (props, context) => {
max_positive_quirks: maxPositiveQuirks,
quirk_blacklist: quirkBlacklist,
quirk_info: quirkInfo,
} = data.quirks;
} = server_data.quirks;
const quirks = Object.entries(quirkInfo);
quirks.sort(([_, quirkA], [__, quirkB]) => {
@@ -238,6 +351,7 @@ export const QuirksPage = (props, context) => {
<Stack.Item grow width="100%">
<QuirkList
selected={false}
onClick={(quirkName, quirk) => {
if (getReasonToNotAdd(quirkName) !== undefined) {
return;
@@ -260,6 +374,9 @@ export const QuirksPage = (props, context) => {
},
];
})}
serverData={server_data}
randomBodyEnabled={randomBodyEnabled}
context={context}
/>
</Stack.Item>
</Stack>
@@ -287,6 +404,7 @@ export const QuirksPage = (props, context) => {
<Stack.Item grow width="100%">
<QuirkList
selected
onClick={(quirkName, quirk) => {
if (getReasonToNotRemove(quirkName) !== undefined) {
return;
@@ -313,6 +431,9 @@ export const QuirksPage = (props, context) => {
},
];
})}
serverData={server_data}
randomBodyEnabled={randomBodyEnabled}
context={context}
/>
</Stack.Item>
</Stack>

View File

@@ -82,6 +82,8 @@ export type Quirk = {
icon: string;
name: string;
value: number;
customizable: boolean;
customization_options?: string[];
};
export type QuirkInfo = {
@@ -135,6 +137,7 @@ export type PreferencesMenuData = {
};
secondary_features: Record<string, unknown>;
supplemental_features: Record<string, unknown>;
manually_rendered_features: Record<string, string>;
names: Record<string, string>;