From 7c78418b8a7308c229c359349f3d169d9be3aeca Mon Sep 17 00:00:00 2001 From: SkyratBot <59378654+SkyratBot@users.noreply.github.com> Date: Sat, 18 May 2024 01:31:31 +0200 Subject: [PATCH] [MIRROR] Updates to orbit ui [READY] (#27742) * Updates to orbit ui [READY] (#83186) ## About The Pull Request Pretty big orbit ui refactor. Check changelog for full list of features.
screenshots In game ![Screenshot 2024-05-15 133935](https://github.com/tgstation/tgstation/assets/42397676/60c2fc95-9fc5-4417-8477-d6fdae589100) Sort by department ![Screenshot 2024-05-15 133951](https://github.com/tgstation/tgstation/assets/42397676/96114884-3c10-4b03-a042-c19b25485bf3) Did you know ninjas had a hud icon that hasn't worked for four years? ![image](https://github.com/tgstation/tgstation/assets/42397676/74f1414e-df57-4586-8cfd-0a154b560b83) Criticals ![image](https://github.com/tgstation/tgstation/assets/42397676/b6ed9b94-bab3-4878-9a18-345efad9b92d) Orbit blade https://github.com/tgstation/tgstation/assets/42397676/99681548-bfb3-4895-9c95-3b650df71107
## Why It's Good For The Game Some QoL for the orbit menu, giving more info on where action is and isn't. Removes more of the uselocalstate hook which is deprecated anyways ## Changelog :cl: fix: Fixed an issue preventing space ninjas from having a hud icon add: ORBIT UI CHANGES: add: AFK players are greyed out. add: Living NPCs now display health. add: Icons displayed are now based on hud icons, which includes icons for player-visible antagonists add: You can now sort by job department (click health icon) add: Round ending "critical" items will be listed at the top. add: Click the settings button to expand for more info add: Your current orbit target is highlighted. /:cl: * Updates to orbit ui [READY] --------- Co-authored-by: Jeremiah <42397676+jlsnow301@users.noreply.github.com> --- code/datums/components/orbiter.dm | 4 + .../antagonists/space_ninja/space_ninja.dm | 2 +- code/modules/mob/dead/observer/observer.dm | 10 + code/modules/mob/dead/observer/orbit.dm | 205 +++++++--- .../tgui/interfaces/Orbit/JobIcon.tsx | 57 +++ .../tgui/interfaces/Orbit/OrbitBlade.tsx | 188 ++++++++++ .../interfaces/Orbit/OrbitCollapsible.tsx | 80 ++++ .../tgui/interfaces/Orbit/OrbitContent.tsx | 99 +++++ .../tgui/interfaces/Orbit/OrbitItem.tsx | 57 +++ .../tgui/interfaces/Orbit/OrbitSearchBar.tsx | 119 ++++++ .../tgui/interfaces/Orbit/OrbitTooltip.tsx | 54 +++ .../tgui/interfaces/Orbit/constants.ts | 64 ++++ .../packages/tgui/interfaces/Orbit/helpers.ts | 169 ++++++--- tgui/packages/tgui/interfaces/Orbit/index.tsx | 354 +++--------------- tgui/packages/tgui/interfaces/Orbit/types.ts | 28 +- .../tgui/styles/interfaces/Orbit.scss | 22 ++ tgui/packages/tgui/styles/main.scss | 1 + 17 files changed, 1110 insertions(+), 403 deletions(-) create mode 100644 tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx create mode 100644 tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx create mode 100644 tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx create mode 100644 tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx create mode 100644 tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx create mode 100644 tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx create mode 100644 tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx create mode 100644 tgui/packages/tgui/styles/interfaces/Orbit.scss diff --git a/code/datums/components/orbiter.dm b/code/datums/components/orbiter.dm index 3ba6ba22c07..71f391e599a 100644 --- a/code/datums/components/orbiter.dm +++ b/code/datums/components/orbiter.dm @@ -120,6 +120,10 @@ orbiter_mob.updating_glide_size = TRUE orbiter_mob.glide_size = 8 + if(isobserver(orbiter)) + var/mob/dead/observer/ghostie = orbiter + ghostie.orbiting_ref = null + REMOVE_TRAIT(orbiter, TRAIT_NO_FLOATING_ANIM, ORBITING_TRAIT) if(!refreshing && !length(orbiter_list) && !QDELING(src)) diff --git a/code/modules/antagonists/space_ninja/space_ninja.dm b/code/modules/antagonists/space_ninja/space_ninja.dm index 730042b57b1..aea2d034729 100644 --- a/code/modules/antagonists/space_ninja/space_ninja.dm +++ b/code/modules/antagonists/space_ninja/space_ninja.dm @@ -2,7 +2,7 @@ name = "\improper Space Ninja" antagpanel_category = ANTAG_GROUP_NINJAS job_rank = ROLE_NINJA - antag_hud_name = "space_ninja" + antag_hud_name = "ninja" hijack_speed = 1 show_name_in_check_antagonists = TRUE show_to_ghosts = TRUE diff --git a/code/modules/mob/dead/observer/observer.dm b/code/modules/mob/dead/observer/observer.dm index f96c875bb11..519220bc754 100644 --- a/code/modules/mob/dead/observer/observer.dm +++ b/code/modules/mob/dead/observer/observer.dm @@ -60,6 +60,9 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER) var/datum/spawners_menu/spawners_menu var/datum/minigames_menu/minigames_menu + /// The POI we're orbiting (orbit menu) + var/orbiting_ref + /mob/dead/observer/Initialize(mapload) set_invisibility(GLOB.observer_default_invisibility) @@ -1098,3 +1101,10 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp if(!prefs || (client?.combo_hud_enabled && prefs.toggles & COMBOHUD_LIGHTING)) return ..() return GLOB.ghost_lighting_options[prefs.read_preference(/datum/preference/choiced/ghost_lighting)] + + +/// Called when we exit the orbiting state +/mob/dead/observer/proc/on_deorbit(datum/source) + SIGNAL_HANDLER + + orbiting_ref = null diff --git a/code/modules/mob/dead/observer/orbit.dm b/code/modules/mob/dead/observer/orbit.dm index db6acc346b1..ee0b4528995 100644 --- a/code/modules/mob/dead/observer/orbit.dm +++ b/code/modules/mob/dead/observer/orbit.dm @@ -36,19 +36,33 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new) var/mob/dead/observer/user = usr user.ManualFollow(poi) user.reset_perspective(null) + user.orbiting_ref = ref if (auto_observe) user.do_observe(poi) return TRUE if ("refresh") - update_static_data(usr, ui) + ui.send_full_update() return TRUE + return FALSE + + +/datum/orbit_menu/ui_data(mob/user) + var/list/data = list() + + if(isobserver(user)) + data["orbiting"] = get_currently_orbiting(user) + + return data + + /datum/orbit_menu/ui_static_data(mob/user) var/list/new_mob_pois = SSpoints_of_interest.get_mob_pois(CALLBACK(src, PROC_REF(validate_mob_poi)), append_dead_role = FALSE) var/list/new_other_pois = SSpoints_of_interest.get_other_pois() var/list/alive = list() var/list/antagonists = list() + var/list/critical = list() var/list/deadchat_controlled = list() var/list/dead = list() var/list/ghosts = list() @@ -57,14 +71,10 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new) for(var/name in new_mob_pois) var/list/serialized = list() - var/mob/mob_poi = new_mob_pois[name] - - var/poi_ref = REF(mob_poi) - var/number_of_orbiters = length(mob_poi.get_all_orbiters()) - serialized["ref"] = poi_ref + serialized["ref"] = REF(mob_poi) serialized["full_name"] = name if(number_of_orbiters) serialized["orbiters"] = number_of_orbiters @@ -81,33 +91,26 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new) continue if(isnull(mob_poi.mind)) + if(isliving(mob_poi)) + var/mob/living/npc = mob_poi + serialized["health"] = FLOOR((npc.health / npc.maxHealth * 100), 1) + npcs += list(serialized) continue - var/datum/mind/mind = mob_poi.mind - var/was_antagonist = FALSE - + serialized["client"] = !!mob_poi.client serialized["name"] = mob_poi.real_name - if(isliving(mob_poi)) // handles edge cases like blob - var/mob/living/player = mob_poi - serialized["health"] = FLOOR((player.health / player.maxHealth * 100), 1) - if(issilicon(player)) - serialized["job"] = player.job - else - var/obj/item/card/id/id_card = player.get_idcard(hand_first = FALSE) - serialized["job"] = id_card?.get_trim_assignment() + if(isliving(mob_poi)) + serialized += get_living_data(mob_poi) - for(var/datum/antagonist/antag_datum as anything in mind.antag_datums) - if (antag_datum.show_to_ghosts) - was_antagonist = TRUE - serialized["antag"] = antag_datum.name - serialized["antag_group"] = antag_datum.antagpanel_category - antagonists += list(serialized) - break + var/list/antag_data = get_antag_data(mob_poi.mind) + if(length(antag_data)) + serialized += antag_data + antagonists += list(serialized) + continue - if(!was_antagonist) - alive += list(serialized) + alive += list(serialized) for(var/name in new_other_pois) var/atom/atom_poi = new_other_pois[name] @@ -122,32 +125,18 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new) )) continue - misc += list(list( - "ref" = REF(atom_poi), - "full_name" = name, - )) + var/list/other_data = get_misc_data(atom_poi) + var/misc_data = list(other_data[1]) - // Display the supermatter crystal integrity - if(istype(atom_poi, /obj/machinery/power/supermatter_crystal)) - var/obj/machinery/power/supermatter_crystal/crystal = atom_poi - misc[length(misc)]["extra"] = "Integrity: [round(crystal.get_integrity_percent())]%" - continue - // Display the nuke timer - if(istype(atom_poi, /obj/machinery/nuclearbomb)) - var/obj/machinery/nuclearbomb/bomb = atom_poi - if(bomb.timing) - misc[length(misc)]["extra"] = "Timer: [bomb.countdown?.displayed_text]s" - continue - // Display the holder if its a nuke disk - if(istype(atom_poi, /obj/item/disk/nuclear)) - var/obj/item/disk/nuclear/disk = atom_poi - var/mob/holder = disk.pulledby || get(disk, /mob) - misc[length(misc)]["extra"] = "Location: [holder?.real_name || "Unsecured"]" - continue + misc += misc_data + + if(other_data[2]) // Critical = TRUE + critical += misc_data return list( "alive" = alive, "antagonists" = antagonists, + "critical" = critical, "deadchat_controlled" = deadchat_controlled, "dead" = dead, "ghosts" = ghosts, @@ -155,10 +144,131 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new) "npcs" = npcs, ) + /// Shows the UI to the specified user. /datum/orbit_menu/proc/show(mob/user) ui_interact(user) + +/// Helper function to get threat type, group, overrides for job and icon +/datum/orbit_menu/proc/get_antag_data(datum/mind/poi_mind) as /list + var/list/serialized = list() + + for(var/datum/antagonist/antag as anything in poi_mind.antag_datums) + if(!antag.show_to_ghosts) + continue + + serialized["antag"] = antag.name + serialized["antag_group"] = antag.antagpanel_category + serialized["job"] = antag.name + serialized["icon"] = antag.antag_hud_name + + return serialized + + +/// Helper to get the current thing we're orbiting (if any) +/datum/orbit_menu/proc/get_currently_orbiting(mob/dead/observer/user) + if(isnull(user.orbiting_ref)) + return + + var/atom/poi = SSpoints_of_interest.get_poi_atom_by_ref(user.orbiting_ref) + if(isnull(poi)) + user.orbiting_ref = null + return + + if((ismob(poi) && !SSpoints_of_interest.is_valid_poi(poi, CALLBACK(src, PROC_REF(validate_mob_poi)))) \ + || !SSpoints_of_interest.is_valid_poi(poi) + ) + user.orbiting_ref = null + return + + var/list/serialized = list() + + if(!ismob(poi)) + var/list/misc_info = get_misc_data(poi) + serialized += misc_info[1] + return serialized + + var/mob/mob_poi = poi + serialized["full_name"] = mob_poi.name + serialized["ref"] = REF(poi) + + if(mob_poi.mind) + serialized["client"] = !!mob_poi.client + serialized["name"] = mob_poi.real_name + + if(isliving(mob_poi)) + serialized += get_living_data(mob_poi) + + return serialized + + +/// Helper function to get job / icon / health data for a living mob +/datum/orbit_menu/proc/get_living_data(mob/living/player) as /list + var/list/serialized = list() + + serialized["health"] = FLOOR((player.health / player.maxHealth * 100), 1) + if(issilicon(player)) + serialized["job"] = player.job + serialized["icon"] = "borg" + else + var/obj/item/card/id/id_card = player.get_idcard(hand_first = FALSE) + serialized["job"] = id_card?.get_trim_assignment() + serialized["icon"] = id_card?.get_trim_sechud_icon_state() + + return serialized + + +/// Gets a list: Misc data and whether it's critical. Handles all snowflakey type cases +/datum/orbit_menu/proc/get_misc_data(atom/movable/atom_poi) as /list + var/list/misc = list() + var/critical = FALSE + + misc["ref"] = REF(atom_poi) + misc["full_name"] = atom_poi.name + + // Display the supermatter crystal integrity + if(istype(atom_poi, /obj/machinery/power/supermatter_crystal)) + var/obj/machinery/power/supermatter_crystal/crystal = atom_poi + var/integrity = round(crystal.get_integrity_percent()) + misc["extra"] = "Integrity: [integrity]%" + + if(integrity < 10) + critical = TRUE + + return list(misc, critical) + + // Display the nuke timer + if(istype(atom_poi, /obj/machinery/nuclearbomb)) + var/obj/machinery/nuclearbomb/bomb = atom_poi + + if(bomb.timing) + misc["extra"] = "Timer: [bomb.countdown?.displayed_text]s" + critical = TRUE + + return list(misc, critical) + + // Display the holder if its a nuke disk + if(istype(atom_poi, /obj/item/disk/nuclear)) + var/obj/item/disk/nuclear/disk = atom_poi + var/mob/holder = disk.pulledby || get(disk, /mob) + misc["extra"] = "Location: [holder?.real_name || "Unsecured"]" + + return list(misc, critical) + + // Display singuloths if they exist + if(istype(atom_poi, /obj/singularity)) + var/obj/singularity/singulo = atom_poi + misc["extra"] = "Energy: [round(singulo.energy)]" + + if(singulo.current_size > 2) + critical = TRUE + + return list(misc, critical) + + return list(misc, critical) + + /** * Helper POI validation function passed as a callback to various SSpoints_of_interest procs. * @@ -181,3 +291,4 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new) return FALSE return potential_poi.validate() + diff --git a/tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx b/tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx new file mode 100644 index 00000000000..70c3ae0f5dc --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/JobIcon.tsx @@ -0,0 +1,57 @@ +import { DmIcon, Icon } from '../../components'; +import { JOB2ICON } from '../common/JobToIcon'; +import { Antagonist, Observable } from './types'; + +type Props = { + item: Observable | Antagonist; +}; + +type IconSettings = { + dmi: string; + transform: string; +}; + +const normalIcon: IconSettings = { + dmi: 'icons/mob/huds/hud.dmi', + transform: 'scale(2.3) translateX(8px) translateY(1px)', +}; + +const antagIcon: IconSettings = { + dmi: 'icons/mob/huds/antag_hud.dmi', + transform: 'scale(1.8) translateX(-16px) translateY(7px)', +}; + +export function JobIcon(props: Props) { + const { item } = props; + + let iconSettings: IconSettings; + if ('antag' in item) { + iconSettings = antagIcon; + } else { + iconSettings = normalIcon; + } + + // We don't need to cast here but typescript isn't smart enough to know that + const { icon = '', job = '' } = item; + + return ( +
+ {icon === 'borg' ? ( + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx new file mode 100644 index 00000000000..07c5a1c08f6 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/OrbitBlade.tsx @@ -0,0 +1,188 @@ +import { capitalizeFirst, toTitleCase } from 'common/string'; +import { useContext } from 'react'; + +import { useBackend } from '../../backend'; +import { + Button, + Icon, + ProgressBar, + Section, + Stack, + Tooltip, +} from '../../components'; +import { OrbitContext } from '.'; +import { HEALTH, VIEWMODE } from './constants'; +import { getDepartmentByJob, getDisplayName } from './helpers'; +import { JobIcon } from './JobIcon'; +import { OrbitData } from './types'; + +/** Slide open menu with more info about the current observable */ +export function OrbitBlade(props) { + const { data } = useBackend(); + const { orbiting } = data; + + const { setBladeOpen } = useContext(OrbitContext); + + return ( + + +
setBladeOpen(false)} + /> + } + color="label" + title="Orbit Settings" + > + Keep in mind: Orbit does not update automatically. You will need to + click the "Refresh" button to see the latest data. +
+
+ + + + {!!orbiting && ( + + + + )} +
+ ); +} + +function ViewModeSelector(props) { + const { viewMode, setViewMode } = useContext(OrbitContext); + + return ( +
+ + + Change the color and sorting scheme of observable items. + + + {Object.entries(VIEWMODE).map(([key, value]) => ( + + ))} + +
+ ); +} + +function OrbitInfo(props) { + const { data } = useBackend(); + + const { orbiting } = data; + if (!orbiting) return; + + const { name, full_name, health, job } = orbiting; + + let department; + if ('job' in orbiting && !!job) { + department = getDepartmentByJob(job); + } + + let showAFK; + if ('client' in orbiting && !orbiting.client) { + showAFK = true; + } + + return ( +
+ + + {toTitleCase(getDisplayName(full_name, name))} + {showAFK && ( + + + + )} + + + {!!job && ( + + + + + + + {job} + + {!!department && ( + + {capitalizeFirst(department)} + + )} + + + )} + {health !== undefined && ( + + + + )} + + + +
+ ); +} + +function HealthDisplay(props: { health: number }) { + const { health } = props; + + let icon = 'heart'; + let howDead; + switch (true) { + case health <= HEALTH.Ruined: + howDead = `Very Dead: ${health}`; + icon = 'skull'; + break; + case health <= HEALTH.Dead: + howDead = `Dead: ${health}`; + icon = 'heart-broken'; + break; + case health <= HEALTH.Crit: + howDead = `Health critical: ${health}`; + icon = 'tired'; + break; + case health <= HEALTH.Bad: + howDead = `Bad: ${health}`; + icon = 'heartbeat'; + break; + } + + return ( + + + + + + {howDead || ( + + )} + + + ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx new file mode 100644 index 00000000000..fa5e78bd1f7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/OrbitCollapsible.tsx @@ -0,0 +1,80 @@ +import { useContext } from 'react'; + +import { Collapsible, Flex, Tooltip } from '../../components'; +import { OrbitContext } from '.'; +import { VIEWMODE } from './constants'; +import { + isJobOrNameMatch, + sortByDepartment, + sortByDisplayName, +} from './helpers'; +import { OrbitItem } from './OrbitItem'; +import { OrbitTooltip } from './OrbitTooltip'; +import { Observable } from './types'; + +type Props = { + color?: string; + section: Observable[]; + title: string; +}; + +/** + * Displays a collapsible with a map of observable items. + * Filters the results if there is a provided search query. + */ +export function OrbitCollapsible(props: Props) { + const { color, section = [], title } = props; + + const { autoObserve, searchQuery, viewMode } = useContext(OrbitContext); + + const filteredSection = section.filter((observable) => + isJobOrNameMatch(observable, searchQuery), + ); + + if (viewMode === VIEWMODE.Department) { + filteredSection.sort(sortByDepartment); + } else { + filteredSection.sort(sortByDisplayName); + } + + if (!filteredSection.length) { + return; + } + + return ( + + + {filteredSection.map((item) => { + const content = ( + + ); + + if (!item.health && !item.extra) { + return content; + } + + return ( + } + key={item.ref} + position="bottom-start" + > + {content} + + ); + })} + + + ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx new file mode 100644 index 00000000000..91502644faa --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/OrbitContent.tsx @@ -0,0 +1,99 @@ +import { toTitleCase } from 'common/string'; + +import { useBackend } from '../../backend'; +import { NoticeBox, Section, Stack, Table, Tooltip } from '../../components'; +import { ANTAG2COLOR } from './constants'; +import { getAntagCategories } from './helpers'; +import { OrbitCollapsible } from './OrbitCollapsible'; +import { AntagGroup, Observable, OrbitData } from './types'; + +type ContentSection = { + content: Observable[]; + title: string; + color?: string; +}; + +/** + * The primary content display for points of interest. + * Renders a scrollable section replete collapsibles for each + * observable group. + */ +export function OrbitContent(props) { + const { act, data } = useBackend(); + const { antagonists = [], critical = [] } = data; + + let antagGroups: AntagGroup[] = []; + if (antagonists.length) { + antagGroups = getAntagCategories(antagonists); + } + + const sections: readonly ContentSection[] = [ + { + color: 'purple', + content: data.deadchat_controlled, + title: 'Deadchat Controlled', + }, + { + color: 'blue', + content: data.alive, + title: 'Alive', + }, + { + content: data.dead, + title: 'Dead', + }, + { + content: data.ghosts, + title: 'Ghosts', + }, + { + content: data.misc, + title: 'Misc', + }, + { + content: data.npcs, + title: 'NPCs', + }, + ]; + + return ( +
+ + {critical.map((crit) => ( + + act('orbit', { ref: crit.ref })} + > + + + {toTitleCase(crit.full_name)} + {crit.extra} + +
+
+
+ ))} + + {antagGroups.map(([title, members]) => ( + + ))} + + {sections.map((section) => ( + + ))} +
+
+ ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx new file mode 100644 index 00000000000..992904988ec --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/OrbitItem.tsx @@ -0,0 +1,57 @@ +import { capitalizeFirst } from 'common/string'; + +import { useBackend } from '../../backend'; +import { Button, Flex, Icon, Stack } from '../../components'; +import { getDisplayColor, getDisplayName } from './helpers'; +import { JobIcon } from './JobIcon'; +import { Antagonist, Observable, OrbitData, ViewMode } from './types'; + +type Props = { + item: Observable | Antagonist; + autoObserve: boolean; + viewMode: ViewMode; + color: string | undefined; +}; + +/** Each button on the observable section */ +export function OrbitItem(props: Props) { + const { item, autoObserve, viewMode, color } = props; + const { full_name, icon, job, name, orbiters, ref } = item; + + const { act, data } = useBackend(); + const { orbiting } = data; + + const selected = ref === orbiting?.ref; + const validIcon = !!job && !!icon && icon !== 'hudunknown'; + + return ( + act('orbit', { auto_observe: autoObserve, ref })} + style={{ + display: 'flex', + }} + > + {validIcon && } + + + + ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx new file mode 100644 index 00000000000..c83512c39c7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/OrbitSearchBar.tsx @@ -0,0 +1,119 @@ +import { useContext } from 'react'; + +import { useBackend } from '../../backend'; +import { Button, Icon, Input, Section, Stack } from '../../components'; +import { OrbitContext } from '.'; +import { VIEWMODE } from './constants'; +import { isJobOrNameMatch, sortByOrbiters } from './helpers'; +import { OrbitData } from './types'; + +/** Search bar for the orbit ui. Has a few buttons to switch between view modes and auto-observe */ +export function OrbitSearchBar(props) { + const { + autoObserve, + bladeOpen, + searchQuery, + viewMode, + setAutoObserve, + setBladeOpen, + setSearchQuery, + setViewMode, + } = useContext(OrbitContext); + + const { act, data } = useBackend(); + + /** Gets a list of Observables, then filters the most relevant to orbit */ + function orbitMostRelevant() { + const mostRelevant = [ + data.alive, + data.antagonists, + data.critical, + data.deadchat_controlled, + data.dead, + data.ghosts, + data.misc, + data.npcs, + ] + .flat() + .filter((observable) => isJobOrNameMatch(observable, searchQuery)) + .sort(sortByOrbiters)[0]; + + if (mostRelevant !== undefined) { + act('orbit', { + ref: mostRelevant.ref, + auto_observe: autoObserve, + }); + } + } + + /** Iterates through the view modes and switches to the next one */ + function swapViewMode() { + const thisIndex = Object.values(VIEWMODE).indexOf(viewMode); + const nextIndex = (thisIndex + 1) % Object.values(VIEWMODE).length; + + setViewMode(Object.values(VIEWMODE)[nextIndex]); + } + + const viewModeTitle = Object.entries(VIEWMODE).find( + ([_key, value]) => value === viewMode, + )?.[0]; + + return ( +
+ + + + + + setSearchQuery(value)} + placeholder="Search..." + value={searchQuery} + /> + + + +
+ ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx b/tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx new file mode 100644 index 00000000000..6c06838be4f --- /dev/null +++ b/tgui/packages/tgui/interfaces/Orbit/OrbitTooltip.tsx @@ -0,0 +1,54 @@ +import { LabeledList, NoticeBox } from '../../components'; +import { Antagonist, Observable } from './types'; + +type Props = { + item: Observable | Antagonist; +}; + +/** Displays some info on the mob as a tooltip. */ +export function OrbitTooltip(props: Props) { + const { item } = props; + const { extra, full_name, health, job } = item; + + let antag; + if ('antag' in item) { + antag = item.antag; + } + + const extraInfo = extra?.split(':'); + const displayHealth = !!health && health >= 0 ? `${health}%` : 'Critical'; + const showAFK = 'client' in item && !item.client; + + return ( + <> + + Last Known Data + + + {extraInfo ? ( + + {extraInfo[1]} + + ) : ( + <> + {!!full_name && ( + {full_name} + )} + {!!job && !antag && ( + {job} + )} + {!!antag && ( + {antag} + )} + {!!health && ( + + {displayHealth} + + )} + + )} + {showAFK && Away} + + + ); +} diff --git a/tgui/packages/tgui/interfaces/Orbit/constants.ts b/tgui/packages/tgui/interfaces/Orbit/constants.ts index 2ef6b605eb6..d683561f382 100644 --- a/tgui/packages/tgui/interfaces/Orbit/constants.ts +++ b/tgui/packages/tgui/interfaces/Orbit/constants.ts @@ -13,6 +13,60 @@ export const ANTAG2COLOR = { 'Invasive Overgrowth': 'green', } as const; +type Department = { + color: string; + trims: string[]; +}; + +export const DEPARTMENT2COLOR: Record = { + cargo: { + color: 'brown', + trims: ['Bitrunner', 'Cargo Technician', 'Shaft Miner', 'Quartermaster'], + }, + command: { + color: 'blue', + trims: ['Captain', 'Head of Personnel'], + }, + engineering: { + color: 'orange', + trims: ['Atmospheric Technician', 'Chief Engineer', 'Station Engineer'], + }, + medical: { + color: 'teal', + trims: [ + 'Chemist', + 'Chief Medical Officer', + 'Coroner', + 'Medical Doctor', + 'Paramedic', + ], + }, + science: { + color: 'pink', + trims: ['Geneticist', 'Research Director', 'Roboticist', 'Scientist'], + }, + security: { + color: 'red', + trims: ['Detective', 'Head of Security', 'Security Officer', 'Warden'], + }, + service: { + color: 'green', + trims: [ + 'Bartender', + 'Botanist', + 'Chaplain', + 'Chef', + 'Clown', + 'Cook', + 'Curator', + 'Janitor', + 'Lawyer', + 'Mime', + 'Psychologist', + ], + }, +}; + export const THREAT = { Low: 1, Medium: 5, @@ -22,4 +76,14 @@ export const THREAT = { export const HEALTH = { Good: 69, // nice Average: 19, + Bad: 0, + Crit: -30, + Dead: -100, + Ruined: -200, +} as const; + +export const VIEWMODE = { + Health: 'heart', + Orbiters: 'ghost', + Department: 'id-badge', } as const; diff --git a/tgui/packages/tgui/interfaces/Orbit/helpers.ts b/tgui/packages/tgui/interfaces/Orbit/helpers.ts index c0668dd02d8..9263ea59961 100644 --- a/tgui/packages/tgui/interfaces/Orbit/helpers.ts +++ b/tgui/packages/tgui/interfaces/Orbit/helpers.ts @@ -1,28 +1,34 @@ -import { filter, sortBy } from 'common/collections'; - -import { HEALTH, THREAT } from './constants'; -import type { AntagGroup, Antagonist, Observable } from './types'; +import { DEPARTMENT2COLOR, HEALTH, THREAT, VIEWMODE } from './constants'; +import { AntagGroup, Antagonist, Observable, ViewMode } from './types'; /** Return a map of strings with each antag in its antag_category */ -export const getAntagCategories = (antagonists: Antagonist[]) => { - const categories: Record = {}; +export function getAntagCategories(antagonists: Antagonist[]): AntagGroup[] { + const categories = new Map(); - antagonists.map((player) => { + for (const player of antagonists) { const { antag_group } = player; - if (!categories[antag_group]) { - categories[antag_group] = []; + if (!categories.has(antag_group)) { + categories.set(antag_group, []); } + categories.get(antag_group)!.push(player); + } - categories[antag_group].push(player); + const sorted = Array.from(categories.entries()).sort((a, b) => { + const lowerA = a[0].toLowerCase(); + const lowerB = b[0].toLowerCase(); + + if (lowerA < lowerB) return -1; + if (lowerA > lowerB) return 1; + return 0; }); - return sortBy(Object.entries(categories), ([key]) => key); -}; + return sorted; +} /** Returns a disguised name in case the person is wearing someone else's ID */ -export const getDisplayName = (full_name: string, name?: string) => { - if (!name) { +export function getDisplayName(full_name: string, nickname?: string): string { + if (!nickname) { return full_name; } @@ -31,34 +37,36 @@ export const getDisplayName = (full_name: string, name?: string) => { full_name.match(/\(as /) || full_name.match(/^Unknown/) ) { - return name; + return nickname; } // return only the name before the first ' [' or ' (' return `"${full_name.split(/ \[| \(/)[0]}"`; -}; +} -export const getMostRelevant = ( - searchQuery: string, - observables: Observable[][], -): Observable => { - const queriedObservables = - // Sorts descending by orbiters - sortBy( - // Filters out anything that doesn't match search - filter( - observables - // Makes a single Observables list for an easy search - .flat(), - (observable) => isJobOrNameMatch(observable, searchQuery), - ), - (observable) => -(observable.orbiters || 0), - ); - return queriedObservables[0]; -}; +/** Returns the department the player is in */ +export function getDepartmentByJob(job: string): string | undefined { + const withoutParenthesis = job.replace(/ \(.*\)/, ''); + + for (const department in DEPARTMENT2COLOR) { + if (DEPARTMENT2COLOR[department].trims.includes(withoutParenthesis)) { + return department; + } + } +} + +/** Gets department color for a job */ +function getDepartmentColor(job: string | undefined): string { + if (!job) return 'grey'; + + const department = getDepartmentByJob(job); + if (!department) return 'grey'; + + return DEPARTMENT2COLOR[department].color; +} /** Returns the display color for certain health percentages */ -const getHealthColor = (health: number) => { +function getHealthColor(health: number): string { switch (true) { case health > HEALTH.Good: return 'good'; @@ -67,10 +75,10 @@ const getHealthColor = (health: number) => { default: return 'bad'; } -}; +} /** Returns the display color based on orbiter numbers */ -const getThreatColor = (orbiters = 0) => { +function getThreatColor(orbiters = 0): string { switch (true) { case orbiters > THREAT.High: return 'violet'; @@ -81,32 +89,43 @@ const getThreatColor = (orbiters = 0) => { default: return 'good'; } -}; +} /** Displays color for buttons based on the health or orbiter count. */ -export const getDisplayColor = ( +export function getDisplayColor( item: Observable, - heatMap: boolean, - color?: string, -) => { - const { health, orbiters } = item; + mode: ViewMode, + override?: string, +): string { + const { job, health, orbiters } = item; + + // Things like blob camera, etc if (typeof health !== 'number') { - return color ? 'good' : 'grey'; + return override ? 'good' : 'grey'; } - if (heatMap) { - return getThreatColor(orbiters); + + // Players that are AFK + if ('client' in item && !item.client) { + return 'grey'; } - return getHealthColor(health); -}; + + switch (mode) { + case VIEWMODE.Orbiters: + return getThreatColor(orbiters); + case VIEWMODE.Department: + return getDepartmentColor(job); + default: + return getHealthColor(health); + } +} /** Checks if a full name or job title matches the search. */ -export const isJobOrNameMatch = ( +export function isJobOrNameMatch( observable: Observable, searchQuery: string, -) => { - if (!searchQuery) { - return true; - } +): boolean { + if (!searchQuery) return true; + const { full_name, job } = observable; return ( @@ -114,4 +133,46 @@ export const isJobOrNameMatch = ( job?.toLowerCase().includes(searchQuery?.toLowerCase()) || false ); -}; +} + +/** Sorts by department */ +export function sortByDepartment(poiA: Observable, poiB: Observable): number { + const departmentA = (poiA.job && getDepartmentByJob(poiA.job)) || 'unknown'; + const departmentB = (poiB.job && getDepartmentByJob(poiB.job)) || 'unknown'; + + if (departmentA < departmentB) return -1; + if (departmentA > departmentB) return 1; + return 0; +} + +/** Sorts based on real name */ +export function sortByDisplayName(poiA: Observable, poiB: Observable): number { + const nameA = getDisplayName(poiA.full_name, poiA.name) + .replace(/^"/, '') + .toLowerCase(); + const nameB = getDisplayName(poiB.full_name, poiB.name) + .replace(/^"/, '') + .toLowerCase(); + + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + return 0; +} + +/** Sorts by most orbiters */ +export function sortByOrbiters(poiA: Observable, poiB: Observable): number { + const orbitersA = poiA.orbiters || 0; + const orbitersB = poiB.orbiters || 0; + + if (orbitersA < orbitersB) { + return -1; + } + if (orbitersA > orbitersB) { + return 1; + } + return 0; +} diff --git a/tgui/packages/tgui/interfaces/Orbit/index.tsx b/tgui/packages/tgui/interfaces/Orbit/index.tsx index c58e0963ff4..d537548107c 100644 --- a/tgui/packages/tgui/interfaces/Orbit/index.tsx +++ b/tgui/packages/tgui/interfaces/Orbit/index.tsx @@ -1,306 +1,68 @@ -import { filter, sortBy } from 'common/collections'; -import { capitalizeFirst } from 'common/string'; -import { useBackend, useLocalState } from 'tgui/backend'; -import { - Button, - Collapsible, - Icon, - Input, - LabeledList, - NoticeBox, - Section, - Stack, -} from 'tgui/components'; +import { createContext, Dispatch, SetStateAction, useState } from 'react'; +import { Stack } from 'tgui/components'; import { Window } from 'tgui/layouts'; -import { JOB2ICON } from '../common/JobToIcon'; -import { ANTAG2COLOR } from './constants'; -import { - getAntagCategories, - getDisplayColor, - getDisplayName, - getMostRelevant, - isJobOrNameMatch, -} from './helpers'; -import type { AntagGroup, Antagonist, Observable, OrbitData } from './types'; +import { VIEWMODE } from './constants'; +import { OrbitBlade } from './OrbitBlade'; +import { OrbitContent } from './OrbitContent'; +import { OrbitSearchBar } from './OrbitSearchBar'; +import { ViewMode } from './types'; -export const Orbit = (props) => { - return ( - - - - - - - -
- -
-
-
-
-
- ); -}; +export function Orbit(props) { + const [autoObserve, setAutoObserve] = useState(false); + const [bladeOpen, setBladeOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState(VIEWMODE.Health); -/** Controls filtering out the list of observables via search */ -const ObservableSearch = (props) => { - const { act, data } = useBackend(); - const { - alive = [], - antagonists = [], - deadchat_controlled = [], - dead = [], - ghosts = [], - misc = [], - npcs = [], - } = data; - - const [autoObserve, setAutoObserve] = useLocalState( - 'autoObserve', - false, - ); - const [heatMap, setHeatMap] = useLocalState('heatMap', false); - const [searchQuery, setSearchQuery] = useLocalState( - 'searchQuery', - '', - ); - - /** Gets a list of Observables, then filters the most relevant to orbit */ - const orbitMostRelevant = (searchQuery: string) => { - const mostRelevant = getMostRelevant(searchQuery, [ - alive, - antagonists, - deadchat_controlled, - dead, - ghosts, - misc, - npcs, - ]); - - if (mostRelevant !== undefined) { - act('orbit', { - ref: mostRelevant.ref, - auto_observe: autoObserve, - }); - } - }; + const dynamicWidth = bladeOpen ? 650 : 400; return ( -
- - - - - - orbitMostRelevant(value)} - onInput={(event, value) => setSearchQuery(value)} - placeholder="Search..." - value={searchQuery} - /> - - - -
- ); -}; - -/** - * The primary content display for points of interest. - * Renders a scrollable section replete with subsections for each - * observable group. - */ -const ObservableContent = (props) => { - const { data } = useBackend(); - const { - alive = [], - antagonists = [], - deadchat_controlled = [], - dead = [], - ghosts = [], - misc = [], - npcs = [], - } = data; - - let collatedAntagonists: AntagGroup[] = []; - - if (antagonists.length) { - collatedAntagonists = getAntagCategories(antagonists); - } - - return ( - - {collatedAntagonists?.map(([title, antagonists]) => { - return ( - - ); - })} - - - - - - - - ); -}; - -/** - * Displays a collapsible with a map of observable items. - * Filters the results if there is a provided search query. - */ -const ObservableSection = (props: { - color?: string; - section: Observable[]; - title: string; -}) => { - const { color, section = [], title } = props; - - if (!section.length) { - return null; - } - - const [searchQuery] = useLocalState('searchQuery', ''); - - const filteredSection = sortBy( - filter(section, (observable) => isJobOrNameMatch(observable, searchQuery)), - (observable) => - getDisplayName(observable.full_name, observable.name) - .replace(/^"/, '') - .toLowerCase(), - ); - - if (!filteredSection.length) { - return null; - } - - return ( - - - {filteredSection.map((poi, index) => { - return ; - })} - - - ); -}; - -/** Renders an observable button that has tooltip info for living Observables*/ -const ObservableItem = (props: { color?: string; item: Observable }) => { - const { act } = useBackend(); - const { color, item } = props; - const { extra, full_name, job, health, name, orbiters, ref } = item; - - const [autoObserve] = useLocalState('autoObserve', false); - const [heatMap] = useLocalState('heatMap', false); - - return ( - + + + + + + + + + + + + + + {bladeOpen && ( + + + + )} + + + + ); +} + +type Context = { + autoObserve: boolean; + setAutoObserve: Dispatch>; + bladeOpen: boolean; + setBladeOpen: Dispatch>; + searchQuery: string; + setSearchQuery: Dispatch>; + viewMode: ViewMode; + setViewMode: Dispatch>; }; -/** Displays some info on the mob as a tooltip. */ -const ObservableTooltip = (props: { item: Observable | Antagonist }) => { - const { item } = props; - const { extra, full_name, health, job } = item; - let antag; - if ('antag' in item) { - antag = item.antag; - } - - const extraInfo = extra?.split(':'); - const displayHealth = !!health && health >= 0 ? `${health}%` : 'Critical'; - - return ( - <> - - Last Known Data - - - {extraInfo ? ( - - {extraInfo[1]} - - ) : ( - <> - {!!full_name && ( - {full_name} - )} - {!!job && !antag && ( - {job} - )} - {!!antag && ( - {antag} - )} - {!!health && ( - - {displayHealth} - - )} - - )} - - - ); -}; +export const OrbitContext = createContext({} as Context); diff --git a/tgui/packages/tgui/interfaces/Orbit/types.ts b/tgui/packages/tgui/interfaces/Orbit/types.ts index dfbcf4afa61..ffeea321a3c 100644 --- a/tgui/packages/tgui/interfaces/Orbit/types.ts +++ b/tgui/packages/tgui/interfaces/Orbit/types.ts @@ -1,3 +1,7 @@ +import { BooleanLike } from 'common/react'; + +import { VIEWMODE } from './constants'; + export type Antagonist = Observable & { antag: string; antag_group: string }; export type AntagGroup = [string, Antagonist[]]; @@ -5,19 +9,33 @@ export type AntagGroup = [string, Antagonist[]]; export type OrbitData = { alive: Observable[]; antagonists: Antagonist[]; + critical: Critical[]; dead: Observable[]; deadchat_controlled: Observable[]; ghosts: Observable[]; misc: Observable[]; npcs: Observable[]; + orbiting: Observable | null; }; export type Observable = { - extra?: string; full_name: string; - health?: number; - job?: string; - name?: string; - orbiters?: number; + ref: string; + // Optionals +} & Partial<{ + client: BooleanLike; + extra: string; + health: number; + icon: string; + job: string; + name: string; + orbiters: number; +}>; + +type Critical = { + extra: string; + full_name: string; ref: string; }; + +export type ViewMode = (typeof VIEWMODE)[keyof typeof VIEWMODE]; diff --git a/tgui/packages/tgui/styles/interfaces/Orbit.scss b/tgui/packages/tgui/styles/interfaces/Orbit.scss new file mode 100644 index 00000000000..2bf2132fa52 --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/Orbit.scss @@ -0,0 +1,22 @@ +.JobIcon { + align-items: center; + background: black; + display: flex; + height: 20px; + justify-content: center; + margin-right: -1px; + padding-left: 2px; + overflow: hidden; +} + +.OrbitItem__selected { + position: absolute; + top: 0; + right: 0; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid white; + transform: translateX(-50%); +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index 87715e2ae30..07e9fec725e 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -66,6 +66,7 @@ @include meta.load-css('./interfaces/NtosMessenger.scss'); @include meta.load-css('./interfaces/NtosNotepad.scss'); @include meta.load-css('./interfaces/NuclearBomb.scss'); +@include meta.load-css('./interfaces/Orbit.scss'); @include meta.load-css('./interfaces/Paper.scss'); @include meta.load-css('./interfaces/PersonalCrafting.scss'); @include meta.load-css('./interfaces/PreferencesMenu.scss');