[Modular] Ports the Character Directory from Coyote Bayou (see inside for link) (#784)

<!-- Write **BELOW** The Headers and **ABOVE** The comments else it may
not be viewable. -->
<!-- You can view Contributing.MD for a detailed description of the pull
request process. -->

## About The Pull Request
Source Inspiration: https://github.com/ARF-SS13/coyote-bayou/pull/3415

Adds the character directory from like, three different codebases, to
Bubberstation!

Pr is tested and ready for merging!
Big thanks to @lessthnthree for helping me debug the TGUI, a lot. This
PR wouldn't have been possible without the assistance.
<!-- Describe The Pull Request. Please be sure every change is
documented or this can delay review and even discourage maintainers from
merging your PR! -->

<!-- Please make sure to actually test your PRs. If you have not tested
your PR mention it. -->

## Why It's Good For The Game
Helps people find other people to roleplay with! ...And get sloppy with.

![image](https://github.com/Bubberstation/Bubberstation/assets/110273561/2d38f683-58fa-444a-aded-5a387d38640e)

![image](https://github.com/Bubberstation/Bubberstation/assets/110273561/12f0cd62-ed13-4792-9009-90552879a13d)

![image](https://github.com/Bubberstation/Bubberstation/assets/110273561/fe4dfa27-c220-4136-9a14-d2af33dd16ba)

<!-- Argue for the merits of your changes and how they benefit the game,
especially if they are controversial and/or far reaching. If you can't
actually explain WHY what you are doing will improve the game, then it
probably isn't good for the game in the first place. -->

## Changelog

<!-- If your PR modifies aspects of the game that can be concretely
observed by players or admins you should add a changelog. If your change
does NOT meet this description, remove this section. Be sure to properly
mark your PRs to prevent unnecessary GBP loss. You can read up on GBP
and it's effects on PRs in the tgstation guides for contributors. Please
note that maintainers freely reserve the right to remove and add tags
should they deem it appropriate. You can attempt to finagle the system
all you want, but it's best to shoot for clear communication right off
the bat. -->

🆑 ReturnToZender, LT3, Waterpig, Nevimer
add: Character directory from SPLURT, originally, I think?
/🆑

<!-- Both 🆑's are required for the changelog to work! You can put
your name to the right of the first 🆑 if you want to overwrite your
GitHub username as author ingame. -->
<!-- You can use multiple of the same prefix (they're only used for the
icon ingame) and delete the unneeded ones. Despite some of the tags,
changelogs should generally represent how a player might be affected by
the changes rather than a summary of the PR's contents. -->

<!-- By opening a pull request. You have read and understood the
repository rules located on the main README.md on this project. -->

---------

Co-authored-by: StrangeWeirdKitten <95130227+StrangeWeirdKitten@users.noreply.github.com>
This commit is contained in:
ReturnToZender
2023-12-15 14:42:04 -07:00
committed by GitHub
co-authored by StrangeWeirdKitten
parent 9be6f0879f
commit 9d3017f2d0
5 changed files with 521 additions and 0 deletions
@@ -0,0 +1,218 @@
GLOBAL_DATUM(character_directory, /datum/character_directory)
#define READ_PREFS(target, pref) (target.client.prefs.read_preference(/datum/preference/pref) || "Unset")
//We want players to be able to decide whether they show up in the directory or not
/datum/preference/toggle/show_in_directory
category = PREFERENCE_CATEGORY_GAME_PREFERENCES
default_value = TRUE
savefile_key = "show_in_directory"
savefile_identifier = PREFERENCE_PLAYER
//The advertisement that you show to people looking through the directory
/datum/preference/text/character_ad
savefile_key = "character_ad"
category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
savefile_identifier = PREFERENCE_CHARACTER
maximum_value_length = MAX_FLAVOR_LEN
//TGUI gets angry if you don't define a default on text preferences
/datum/preference/text/character_ad/create_default_value()
return ""
//Any text preference needs this for some reason
/datum/preference/text/character_ad/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences)
return FALSE
/datum/preference/choiced/attraction
savefile_key = "attraction"
category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
savefile_identifier = PREFERENCE_CHARACTER
/datum/preference/choiced/attraction/init_possible_values()
return list("Gay", "Lesbian", "Straight", "Skolio", "Bi", "Pan", "Poly", "Omni", "Ace", "Unset", "Check OOC")
/datum/preference/choiced/attraction/create_default_value()
return "Unset"
/datum/preference/choiced/attraction/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences)
return FALSE
/datum/preference/choiced/display_gender
savefile_key = "display_gender"
category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
savefile_identifier = PREFERENCE_CHARACTER
/datum/preference/choiced/display_gender/init_possible_values()
return list("Male", "Female", "Null", "Plural", "Nonbinary", "Omni", "Trans", "Andro", "Gyno", "Fluid", "Unset", "Check OOC")
/datum/preference/choiced/display_gender/create_default_value()
return "Unset"
/datum/preference/choiced/display_gender/apply_to_human(mob/living/carbon/human/target, value, datum/preferences/preferences)
return FALSE
//Can't believe Bubberstation invented attraction and gender in the year December 2023
//Add a cooldown for the character directory to the client, primarily to stop server lag from refresh spam
/client
COOLDOWN_DECLARE(char_directory_cooldown)
//Make a verb to open the character directory
/client/verb/show_character_directory()
set name = "Character Directory"
set category = "OOC"
set desc = "Shows a listing of all active characters, along with their associated OOC notes, flavor text, and more."
// This is primarily to stop malicious users from trying to lag the server by spamming this verb
if(!COOLDOWN_FINISHED(src, char_directory_cooldown))
to_chat(src, span_alert("Hold your horses! Its still refreshing!"))
return
COOLDOWN_START(src, char_directory_cooldown, 10)
//Check if there's not already a character directory open; open a new one if one is not present
if(!GLOB.character_directory)
GLOB.character_directory = new
GLOB.character_directory.ui_interact(mob)
// This is a global singleton. Keep in mind that all operations should occur on user, not src.
/datum/character_directory
/datum/character_directory/ui_state(mob/user)
return GLOB.always_state
/datum/character_directory/ui_interact(mob/user, datum/tgui/ui)
ui = SStgui.try_update_ui(user, src, ui)
if(!ui)
ui = new(user, src, "ZubbersCharacterDirectory", "Character Directory")
ui.open()
//We want this information to update any time the player updates their preferences, not just when the panel is refreshed
/datum/character_directory/ui_data(mob/user)
. = ..()
var/list/data = .
//Collect the user's own preferences for the top of the UI
if (user?.client?.prefs)
data["personalVisibility"] = READ_PREFS(user, toggle/show_in_directory)
data["personalAttraction"] = READ_PREFS(user, choiced/attraction)
data["personalGender"] = READ_PREFS(user, choiced/display_gender)
data["personalErpTag"] = READ_PREFS(user, choiced/erp_status)
data["personalVoreTag"] = READ_PREFS(user, choiced/erp_status_v)
data["personalHypnoTag"] = READ_PREFS(user, choiced/erp_status_hypno)
data["personalNonconTag"] = READ_PREFS(user, choiced/erp_status_nc)
data["prefsOnly"] = TRUE
data["canOrbit"] = isobserver(user)
return data
/datum/character_directory/ui_static_data(mob/user)
. = ..()
var/list/data = .
var/list/directory_mobs = list()
//We want the directory to display only alive players, not observers or people in the lobby
for(var/mob/mob in GLOB.alive_player_list)
// These are the variables we're trying to display in the directory
var/name = null
var/species = null
var/ooc_notes = ""
var/flavor_text = ""
var/attraction = null
var/gender = null
var/erp = null
var/vore = null
var/hypno = null
var/noncon = null
var/character_ad = ""
var/exploitable = ""
var/ref = REF(mob)
//Just in case something we get is not a mob
if(!mob)
continue
//Different approach for humans and silicons
if(ishuman(mob))
var/mob/living/carbon/human/human = mob
//If someone is obscured without flavor text visible, we don't want them on the Directory.
if((human.wear_mask && (human.wear_mask.flags_inv & HIDEFACE) && READ_PREFS(human, toggle/obscurity_examine)) || (human.head && (human.head.flags_inv & HIDEFACE) && READ_PREFS(human, toggle/obscurity_examine)) || (HAS_TRAIT(human, TRAIT_UNKNOWN)))
continue
//Display custom species, otherwise show base species instead
species = (READ_PREFS(human, text/custom_species))
if(species == "Unset")
species = "[human.dna.species.name]"
//Load standard flavor text preference
flavor_text = READ_PREFS(human, text/flavor_text)
else if(issilicon(mob))
var/mob/living/silicon/silicon = mob
//If the target is a silicon, we want it to show its brain as its species
species = READ_PREFS(silicon, choiced/brain_type)
//Load silicon flavor text in place of normal flavor text
flavor_text = READ_PREFS(silicon, text/silicon_flavor_text)
//Don't show if they are not a human or a silicon
else continue
//List of all the shown ERP preferences in the Directory. If there is none, return "Unset"
attraction = READ_PREFS(mob, choiced/attraction)
gender = READ_PREFS(mob, choiced/display_gender)
erp = READ_PREFS(mob, choiced/erp_status)
vore = READ_PREFS(mob, choiced/erp_status_v)
hypno = READ_PREFS(mob, choiced/erp_status_hypno)
noncon = READ_PREFS(mob, choiced/erp_status_nc)
character_ad = READ_PREFS(mob, text/character_ad)
ooc_notes = READ_PREFS(mob, text/ooc_notes)
//If the user is an antagonist or Observer, we want them to be able to see exploitables in the Directory.
if(user.mind?.has_antag_datum(/datum/antagonist) || isobserver(user))
if(exploitable == EXPLOITABLE_DEFAULT_TEXT)
exploitable = "Unset"
else exploitable = READ_PREFS(mob, text/exploitable)
else exploitable = "Obscured"
//And finally, we want to get the mob's name, taking into account disguised names.
name = mob.real_name ? mob.name : mob.real_name
directory_mobs.Add(list(list(
"name" = name,
"species" = species,
"ooc_notes" = ooc_notes,
"attraction" = attraction,
"gender" = gender,
"erp" = erp,
"vore" = vore,
"hypno" = hypno,
"noncon" = noncon,
"exploitable" = exploitable,
"character_ad" = character_ad,
"flavor_text" = flavor_text,
"ref" = ref
)))
data["directory"] = directory_mobs
return data
/datum/character_directory/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
. = ..()
if(.)
return
var/mob/user = usr
if(!user)
return
switch(action)
if("refresh")
// This is primarily to stop malicious users from trying to lag the server by spamming this verb
if(!COOLDOWN_FINISHED(user.client, char_directory_cooldown))
to_chat(user, "<span class='warning'>Don't spam character directory refresh.</span>")
return
COOLDOWN_START(user.client, char_directory_cooldown, 10)
update_static_data(user, ui)
return TRUE
if("orbit")
var/ref = params["ref"]
var/mob/dead/observer/ghost = user
var/atom/movable/poi = (locate(ref) in GLOB.mob_list)
if (poi == null)
return TRUE
ghost.ManualFollow(poi)
ghost.reset_perspective(null)
return TRUE
+1
View File
@@ -8045,6 +8045,7 @@
#include "modular_zubbers\code\modules\cargo\packs\metalsheets.dm"
#include "modular_zubbers\code\modules\cargo\packs\security.dm"
#include "modular_zubbers\code\modules\cargo\packs\service.dm"
#include "modular_zubbers\code\modules\client\verbs\character_directory.dm"
#include "modular_zubbers\code\modules\clothing\gloves\syndicate.dm"
#include "modular_zubbers\code\modules\clothing\head\helmet.dm"
#include "modular_zubbers\code\modules\clothing\head\jobs.dm"
@@ -0,0 +1,22 @@
import { Feature, FeatureChoiced, FeatureDropdownInput, FeatureTextInput } from '../../base';
export const character_ad: Feature<string> = {
name: 'Character Advert',
description:
'An advertisement for your character. Give information on how to approach for those interested, for either regular and erotic roleplay.',
component: FeatureTextInput,
};
export const attraction: FeatureChoiced = {
name: 'Character Attraction',
description:
'What classifies what your character is attracted to. This is displayed in the Directory.',
component: FeatureDropdownInput,
};
export const display_gender: FeatureChoiced = {
name: 'Character Gender',
description:
'What classifies as the gender for your character. This is displayed in the Directory.',
component: FeatureDropdownInput,
};
@@ -0,0 +1,8 @@
import { CheckboxInput, FeatureToggle } from '../../base';
export const show_in_directory: FeatureToggle = {
name: 'Show in Directory',
category: 'ERP',
description: 'When enabled, character will be shown in Directory',
component: CheckboxInput,
};
@@ -0,0 +1,272 @@
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Icon, LabeledList, Section, Table } from '../components';
import { Window } from '../layouts';
const erpTagColor = {
'Unset': 'label',
'Yes - Dom': 'red',
'Yes - Sub': 'blue',
'Yes - Switch': 'yellow',
'Yes': 'yellow',
'Check OOC': 'black',
'Ask': 'black',
'No': 'black',
};
export const ZubbersCharacterDirectory = (props, context) => {
const { act, data } = useBackend(context);
const {
personalVisibility,
personalAttraction,
personalGender,
personalErpTag,
personalVoreTag,
personalHypnoTag,
personalNonconTag,
prefsOnly,
} = data;
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
const [overwritePrefs, setOverwritePrefs] = useLocalState(
context,
'overwritePrefs',
prefsOnly
);
return (
<Window width={640} height={480} resizeable>
<Window.Content scrollable>
{(overlay && <ViewCharacter />) || (
<Fragment>
<Section title="Controls">
<LabeledList>
<LabeledList.Item label="Visibility">
<Button
fluid
content={personalVisibility ? 'Shown' : 'Not Shown'}
onClick={() =>
act('setVisible', { overwrite_prefs: overwritePrefs })
}
/>
</LabeledList.Item>
<LabeledList.Item label="Attraction">
<Button fluid content={personalAttraction} />
</LabeledList.Item>
<LabeledList.Item label="Gender">
<Button fluid content={personalGender} />
</LabeledList.Item>
<LabeledList.Item label="ERP">
<Button
fluid
content={personalErpTag}
onClick={() =>
act('setErpTag', { overwrite_prefs: overwritePrefs })
}
/>
</LabeledList.Item>
<LabeledList.Item label="Vore">
<Button
fluid
content={personalVoreTag}
onClick={() =>
act('setTag', { overwrite_prefs: overwritePrefs })
}
/>
</LabeledList.Item>
<LabeledList.Item label="Hypno">
<Button
fluid
content={personalHypnoTag}
onClick={() =>
act('setHypnoTag', { overwrite_prefs: overwritePrefs })
}
/>
</LabeledList.Item>
<LabeledList.Item label="Noncon">
<Button
fluid
content={personalNonconTag}
onClick={() =>
act('setNonconTag', { overwrite_prefs: overwritePrefs })
}
/>
</LabeledList.Item>
</LabeledList>
</Section>
<CharacterDirectoryList />
</Fragment>
)}
</Window.Content>
</Window>
);
};
const ViewCharacter = (props, context) => {
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
return (
<Section
title={overlay.name}
buttons={
<Button
icon="arrow-left"
content="Back"
onClick={() => setOverlay(null)}
/>
}>
<Section level={2} title="Species">
<Box>{overlay.species}</Box>
</Section>
<Section level={2} title="Attraction">
<Box>{overlay.attraction}</Box>
</Section>
<Section level={2} title="Gender">
<Box>{overlay.gender}</Box>
</Section>
<Section level={2} title="ERP">
<Box p={1} backgroundColor={erpTagColor[overlay.erp]}>
{overlay.erp}
</Box>
<Section level={2} title="Vore">
<Box>{overlay.vore}</Box>
</Section>
</Section>
<Section level={2} title="Hypno">
<Box>{overlay.hypno}</Box>
</Section>
<Section level={2} title="Noncon">
<Box>{overlay.noncon}</Box>
</Section>
<Section level={2} title="Character Ad">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.character_ad || 'Unset.'}
</Box>
</Section>
<Section level={2} title="Exploitable">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.exploitable || 'Unset.'}
</Box>
</Section>
<Section level={2} title="OOC Notes">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.ooc_notes || 'Unset.'}
</Box>
</Section>
<Section level={2} title="Flavor Text">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.flavor_text || 'Unset.'}
</Box>
</Section>
</Section>
);
};
const CharacterDirectoryList = (props, context) => {
const { act, data } = useBackend(context);
const { directory, canOrbit } = data;
const [sortId, _setSortId] = useLocalState(context, 'sortId', 'name');
const [sortOrder, _setSortOrder] = useLocalState(
context,
'sortOrder',
'name'
);
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
return (
<Section
title="Directory"
buttons={
<Button icon="sync" content="Refresh" onClick={() => act('refresh')} />
}>
<Table>
<Table.Row bold>
<SortButton id="name">Name</SortButton>
<SortButton id="species">Species</SortButton>
<SortButton id="attraction">Attraction</SortButton>
<SortButton id="gender">Gender</SortButton>
<SortButton id="erp">ERP</SortButton>
<SortButton id="vore">Vore</SortButton>
<SortButton id="hypno">Hypno</SortButton>
<SortButton id="noncon">Noncon</SortButton>
<Table.Cell collapsing textAlign="right">
Advert
</Table.Cell>
</Table.Row>
{directory
.sort((a, b) => {
const i = sortOrder ? 1 : -1;
return a[sortId].localeCompare(b[sortId]) * i;
})
.map((character, i) => (
<Table.Row key={i} backgroundColor={erpTagColor[character.erp]}>
<Table.Cell p={1}>
{canOrbit ? (
<Button
color={erpTagColor[character.erp]}
icon="ghost"
tooltip="Orbit"
content={character.name}
onClick={() => act('orbit', { ref: character.ref })}
/>
) : (
character.name
)}
</Table.Cell>
<Table.Cell>{character.species}</Table.Cell>
<Table.Cell>{character.attraction}</Table.Cell>
<Table.Cell>{character.gender}</Table.Cell>
<Table.Cell>{character.erp}</Table.Cell>
<Table.Cell>{character.vore}</Table.Cell>
<Table.Cell>{character.hypno}</Table.Cell>
<Table.Cell>{character.noncon}</Table.Cell>
<Table.Cell collapsing textAlign="right">
<Button
onClick={() => setOverlay(character)}
color="transparent"
icon="sticky-note"
mr={1}
content="View"
/>
</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
);
};
const SortButton = (props, context) => {
const { act, data } = useBackend(context);
const { id, children } = props;
// Hey, same keys mean same data~
const [sortId, setSortId] = useLocalState(context, 'sortId', 'name');
const [sortOrder, setSortOrder] = useLocalState(context, 'sortOrder', 'name');
return (
<Table.Cell collapsing>
<Button
width="100%"
color={sortId !== id && 'transparent'}
onClick={() => {
if (sortId === id) {
setSortOrder(!sortOrder);
} else {
setSortId(id);
setSortOrder(true);
}
}}>
{children}
{sortId === id && (
<Icon name={sortOrder ? 'sort-up' : 'sort-down'} ml="0.25rem;" />
)}
</Button>
</Table.Cell>
);
};