mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-29 08:08:49 +01:00
[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  Sort by department  Did you know ninjas had a hud icon that hasn't worked for four years?  Criticals  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:
@@ -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
|
||||
|
||||
@@ -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 "Refresh" 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user