[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.

<details>
<summary>screenshots</summary>

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

</details>

## 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
🆑
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.
/🆑

* Updates to orbit ui [READY]

---------

Co-authored-by: Jeremiah <42397676+jlsnow301@users.noreply.github.com>
This commit is contained in:
SkyratBot
2024-05-18 01:31:31 +02:00
committed by GitHub
co-authored by Jeremiah
parent 2a391464b2
commit 7c78418b8a
17 changed files with 1110 additions and 403 deletions
+4
View File
@@ -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))
@@ -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
@@ -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
+158 -47
View File
@@ -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()
@@ -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 (
<div className="JobIcon">
{icon === 'borg' ? (
<Icon color="lightblue" name={JOB2ICON[job]} mr={0.5} />
) : (
<div
style={{
height: '17px',
width: '18px',
}}
>
<DmIcon
icon={iconSettings.dmi}
icon_state={icon}
style={{ transform: iconSettings.transform }}
/>
</div>
)}
</div>
);
}
@@ -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<OrbitData>();
const { orbiting } = data;
const { setBladeOpen } = useContext(OrbitContext);
return (
<Stack vertical width="244px">
<Stack.Item>
<Section
buttons={
<Button
color="bad"
icon="times"
onClick={() => setBladeOpen(false)}
/>
}
color="label"
title="Orbit Settings"
>
Keep in mind: Orbit does not update automatically. You will need to
click the &quot;Refresh&quot; button to see the latest data.
</Section>
</Stack.Item>
<Stack.Item>
<ViewModeSelector />
</Stack.Item>
{!!orbiting && (
<Stack.Item>
<OrbitInfo />
</Stack.Item>
)}
</Stack>
);
}
function ViewModeSelector(props) {
const { viewMode, setViewMode } = useContext(OrbitContext);
return (
<Section title="View Mode">
<Stack fill vertical>
<Stack.Item color="label">
Change the color and sorting scheme of observable items.
</Stack.Item>
{Object.entries(VIEWMODE).map(([key, value]) => (
<Button
align="center"
color="transparent"
fluid
icon={value}
key={key}
onClick={() => setViewMode(value)}
selected={value === viewMode}
>
{key}
</Button>
))}
</Stack>
</Section>
);
}
function OrbitInfo(props) {
const { data } = useBackend<OrbitData>();
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 (
<Section title="Orbiting">
<Stack fill vertical>
<Stack.Item>
{toTitleCase(getDisplayName(full_name, name))}
{showAFK && (
<Tooltip content="Away from keyboard" position="bottom-start">
<Icon ml={1} color="grey" name="bed" />
</Tooltip>
)}
</Stack.Item>
{!!job && (
<Stack.Item>
<Stack>
<Stack.Item>
<JobIcon item={orbiting} />
</Stack.Item>
<Stack.Item color="label" grow>
{job}
</Stack.Item>
{!!department && (
<Stack.Item color="grey">
{capitalizeFirst(department)}
</Stack.Item>
)}
</Stack>
</Stack.Item>
)}
{health !== undefined && (
<Stack.Item>
<HealthDisplay health={health} />
</Stack.Item>
)}
<Stack.Item />
</Stack>
</Section>
);
}
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 (
<Stack align="center">
<Stack.Item>
<Icon color="grey" name={icon} />
</Stack.Item>
<Stack.Item color={howDead && 'bad'} grow>
{howDead || (
<ProgressBar
maxValue={100}
minValue={0}
ranges={{
good: [70, Infinity],
average: [20, HEALTH.Good],
bad: [0, HEALTH.Average],
}}
value={health}
/>
)}
</Stack.Item>
</Stack>
);
}
@@ -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 (
<Collapsible
bold
color={color || 'grey'}
open={!!color}
title={title + ` - (${filteredSection.length})`}
>
<Flex wrap>
{filteredSection.map((item) => {
const content = (
<OrbitItem
autoObserve={autoObserve}
color={color}
item={item}
key={item.ref}
viewMode={viewMode}
/>
);
if (!item.health && !item.extra) {
return content;
}
return (
<Tooltip
content={<OrbitTooltip item={item} />}
key={item.ref}
position="bottom-start"
>
{content}
</Tooltip>
);
})}
</Flex>
</Collapsible>
);
}
@@ -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<OrbitData>();
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 (
<Section fill scrollable>
<Stack vertical>
{critical.map((crit) => (
<Tooltip content="Click to orbit" key={crit.ref}>
<NoticeBox
verticalAlign
color="purple"
onClick={() => act('orbit', { ref: crit.ref })}
>
<Table>
<Table.Row>
<Table.Cell>{toTitleCase(crit.full_name)}</Table.Cell>
<Table.Cell collapsing>{crit.extra}</Table.Cell>
</Table.Row>
</Table>
</NoticeBox>
</Tooltip>
))}
{antagGroups.map(([title, members]) => (
<OrbitCollapsible
color={ANTAG2COLOR[title] || 'bad'}
key={title}
section={members}
title={title}
/>
))}
{sections.map((section) => (
<OrbitCollapsible
color={section.color}
key={section.title}
section={section.content}
title={section.title}
/>
))}
</Stack>
</Section>
);
}
@@ -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<OrbitData>();
const { orbiting } = data;
const selected = ref === orbiting?.ref;
const validIcon = !!job && !!icon && icon !== 'hudunknown';
return (
<Flex.Item
mb={0.5}
mr={0.5}
onClick={() => act('orbit', { auto_observe: autoObserve, ref })}
style={{
display: 'flex',
}}
>
{validIcon && <JobIcon item={item} />}
<Button
color={getDisplayColor(item, viewMode, color)}
pl={validIcon && 0.5}
>
<Stack>
<Stack.Item>
{capitalizeFirst(getDisplayName(full_name, name))}
</Stack.Item>
{!!orbiters && (
<Stack.Item>
<Icon name="ghost" />
{orbiters}
</Stack.Item>
)}
</Stack>
{selected && <div className="OrbitItem__selected" />}
</Button>
</Flex.Item>
);
}
@@ -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<OrbitData>();
/** 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 (
<Section>
<Stack>
<Stack.Item>
<Icon name="search" />
</Stack.Item>
<Stack.Item grow>
<Input
autoFocus
fluid
onEnter={orbitMostRelevant}
onInput={(event, value) => setSearchQuery(value)}
placeholder="Search..."
value={searchQuery}
/>
</Stack.Item>
<Stack.Divider />
<Stack.Item>
<Button
color="transparent"
icon={viewMode}
onClick={swapViewMode}
tooltip={`Color scheme: ${viewModeTitle}`}
tooltipPosition="bottom-start"
/>
</Stack.Item>
<Stack.Item>
<Button
color={autoObserve ? 'good' : 'transparent'}
icon={autoObserve ? 'toggle-on' : 'toggle-off'}
onClick={() => setAutoObserve(!autoObserve)}
tooltip={`Toggle Auto-Observe. When active, you'll
see the UI / full inventory of whoever you're orbiting. Neat!`}
tooltipPosition="bottom-start"
/>
</Stack.Item>
<Stack.Item>
<Button
color="transparent"
icon="sync-alt"
onClick={() => act('refresh')}
tooltip="Refresh"
tooltipPosition="bottom-start"
/>
</Stack.Item>
<Stack.Item>
<Button
color="transparent"
icon="sliders-h"
onClick={() => setBladeOpen(!bladeOpen)}
selected={bladeOpen}
tooltip="Toggle settings blade"
tooltipPosition="left-end"
/>
</Stack.Item>
</Stack>
</Section>
);
}
@@ -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 (
<>
<NoticeBox textAlign="center" nowrap info={showAFK}>
Last Known Data
</NoticeBox>
<LabeledList>
{extraInfo ? (
<LabeledList.Item label={extraInfo[0]}>
{extraInfo[1]}
</LabeledList.Item>
) : (
<>
{!!full_name && (
<LabeledList.Item label="Real ID">{full_name}</LabeledList.Item>
)}
{!!job && !antag && (
<LabeledList.Item label="Job">{job}</LabeledList.Item>
)}
{!!antag && (
<LabeledList.Item label="Threat">{antag}</LabeledList.Item>
)}
{!!health && (
<LabeledList.Item label="Health">
{displayHealth}
</LabeledList.Item>
)}
</>
)}
{showAFK && <LabeledList.Item label="Status">Away</LabeledList.Item>}
</LabeledList>
</>
);
}
@@ -13,6 +13,60 @@ export const ANTAG2COLOR = {
'Invasive Overgrowth': 'green',
} as const;
type Department = {
color: string;
trims: string[];
};
export const DEPARTMENT2COLOR: Record<string, Department> = {
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;
+115 -54
View File
@@ -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<string, Antagonist[]> = {};
export function getAntagCategories(antagonists: Antagonist[]): AntagGroup[] {
const categories = new Map<string, Antagonist[]>();
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<AntagGroup>(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;
}
+58 -296
View File
@@ -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 (
<Window title="Orbit" width={400} height={550}>
<Window.Content scrollable>
<Stack fill vertical>
<Stack.Item>
<ObservableSearch />
</Stack.Item>
<Stack.Item mt={0.2} grow>
<Section fill>
<ObservableContent />
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
};
export function Orbit(props) {
const [autoObserve, setAutoObserve] = useState(false);
const [bladeOpen, setBladeOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>(VIEWMODE.Health);
/** Controls filtering out the list of observables via search */
const ObservableSearch = (props) => {
const { act, data } = useBackend<OrbitData>();
const {
alive = [],
antagonists = [],
deadchat_controlled = [],
dead = [],
ghosts = [],
misc = [],
npcs = [],
} = data;
const [autoObserve, setAutoObserve] = useLocalState<boolean>(
'autoObserve',
false,
);
const [heatMap, setHeatMap] = useLocalState<boolean>('heatMap', false);
const [searchQuery, setSearchQuery] = useLocalState<string>(
'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 (
<Section>
<Stack>
<Stack.Item>
<Icon name="search" />
</Stack.Item>
<Stack.Item grow>
<Input
autoFocus
fluid
onEnter={(event, value) => orbitMostRelevant(value)}
onInput={(event, value) => setSearchQuery(value)}
placeholder="Search..."
value={searchQuery}
/>
</Stack.Item>
<Stack.Divider />
<Stack.Item>
<Button
color="transparent"
icon={!heatMap ? 'heart' : 'ghost'}
onClick={() => setHeatMap(!heatMap)}
tooltip={`Toggles between highlighting health or
orbiters.`}
tooltipPosition="bottom-start"
/>
</Stack.Item>
<Stack.Item>
<Button
color={autoObserve ? 'good' : 'transparent'}
icon={autoObserve ? 'toggle-on' : 'toggle-off'}
onClick={() => setAutoObserve(!autoObserve)}
tooltip={`Toggle Auto-Observe. When active, you'll
see the UI / full inventory of whoever you're orbiting. Neat!`}
tooltipPosition="bottom-start"
/>
</Stack.Item>
<Stack.Item>
<Button
color="transparent"
icon="sync-alt"
onClick={() => act('refresh')}
tooltip="Refresh"
tooltipPosition="bottom-start"
/>
</Stack.Item>
</Stack>
</Section>
);
};
/**
* 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<OrbitData>();
const {
alive = [],
antagonists = [],
deadchat_controlled = [],
dead = [],
ghosts = [],
misc = [],
npcs = [],
} = data;
let collatedAntagonists: AntagGroup[] = [];
if (antagonists.length) {
collatedAntagonists = getAntagCategories(antagonists);
}
return (
<Stack vertical>
{collatedAntagonists?.map(([title, antagonists]) => {
return (
<ObservableSection
color={ANTAG2COLOR[title] || 'bad'}
key={title}
section={antagonists}
title={title}
/>
);
})}
<ObservableSection
color="purple"
section={deadchat_controlled}
title="Deadchat Controlled"
/>
<ObservableSection color="blue" section={alive} title="Alive" />
<ObservableSection section={dead} title="Dead" />
<ObservableSection section={ghosts} title="Ghosts" />
<ObservableSection section={misc} title="Misc" />
<ObservableSection section={npcs} title="NPCs" />
</Stack>
);
};
/**
* 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<string>('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 (
<Stack.Item>
<Collapsible
bold
color={color ?? 'grey'}
open={!!color}
title={title + ` - (${filteredSection.length})`}
>
{filteredSection.map((poi, index) => {
return <ObservableItem color={color} item={poi} key={index} />;
})}
</Collapsible>
</Stack.Item>
);
};
/** Renders an observable button that has tooltip info for living Observables*/
const ObservableItem = (props: { color?: string; item: Observable }) => {
const { act } = useBackend<OrbitData>();
const { color, item } = props;
const { extra, full_name, job, health, name, orbiters, ref } = item;
const [autoObserve] = useLocalState<boolean>('autoObserve', false);
const [heatMap] = useLocalState<boolean>('heatMap', false);
return (
<Button
color={getDisplayColor(item, heatMap, color)}
icon={(job && JOB2ICON[job]) || null}
onClick={() => act('orbit', { auto_observe: autoObserve, ref: ref })}
tooltip={(!!health || !!extra) && <ObservableTooltip item={item} />}
tooltipPosition="bottom-start"
<OrbitContext.Provider
value={{
autoObserve,
setAutoObserve,
bladeOpen,
setBladeOpen,
searchQuery,
setSearchQuery,
viewMode,
setViewMode,
}}
>
{capitalizeFirst(getDisplayName(full_name, name))}
{!!orbiters && (
<>
{' '}
<Icon mr={0} name={'ghost'} />
{orbiters}
</>
)}
</Button>
<Window title="Orbit" width={dynamicWidth} height={550}>
<Window.Content>
<Stack fill>
<Stack.Item grow>
<Stack fill vertical>
<Stack.Item>
<OrbitSearchBar />
</Stack.Item>
<Stack.Item grow>
<OrbitContent />
</Stack.Item>
</Stack>
</Stack.Item>
{bladeOpen && (
<Stack.Item>
<OrbitBlade />
</Stack.Item>
)}
</Stack>
</Window.Content>
</Window>
</OrbitContext.Provider>
);
}
type Context = {
autoObserve: boolean;
setAutoObserve: Dispatch<SetStateAction<boolean>>;
bladeOpen: boolean;
setBladeOpen: Dispatch<SetStateAction<boolean>>;
searchQuery: string;
setSearchQuery: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
};
/** 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 (
<>
<NoticeBox textAlign="center" nowrap>
Last Known Data
</NoticeBox>
<LabeledList>
{extraInfo ? (
<LabeledList.Item label={extraInfo[0]}>
{extraInfo[1]}
</LabeledList.Item>
) : (
<>
{!!full_name && (
<LabeledList.Item label="Real ID">{full_name}</LabeledList.Item>
)}
{!!job && !antag && (
<LabeledList.Item label="Job">{job}</LabeledList.Item>
)}
{!!antag && (
<LabeledList.Item label="Threat">{antag}</LabeledList.Item>
)}
{!!health && (
<LabeledList.Item label="Health">
{displayHealth}
</LabeledList.Item>
)}
</>
)}
</LabeledList>
</>
);
};
export const OrbitContext = createContext({} as Context);
+23 -5
View File
@@ -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];
@@ -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%);
}
+1
View File
@@ -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');