mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-07-19 20:15:47 +01:00
[MIRROR] Completely rewrites the orbit screen [MDB IGNORE] (#14715)
* Completely rewrites the orbit screen (#68054) I set about refactoring Orbit.js when I just decided to scrap it and maybe add in some different functionality. This PR: - rewrites orbit from the ground up in typescript - color coded list + scrollable + wider - adds some styling and effects to gauge "threat": icons change, buttons change color - fixes button text overflow - fixes issue where scrolling hides the search header - fixes similar antags being grouped separately (nukie and nukie leader, etc) * Completely rewrites the orbit screen Co-authored-by: Jeremiah <42397676+jlsnow301@users.noreply.github.com>
This commit is contained in:
@@ -39,11 +39,6 @@ GLOBAL_DATUM_INIT(orbit_menu, /datum/orbit_menu, new)
|
||||
update_static_data(usr, ui)
|
||||
return TRUE
|
||||
|
||||
/datum/orbit_menu/ui_assets()
|
||||
return list(
|
||||
get_asset_datum(/datum/asset/simple/orbit),
|
||||
)
|
||||
|
||||
/datum/orbit_menu/ui_static_data(mob/user)
|
||||
var/list/new_mob_pois = SSpoints_of_interest.get_mob_pois(CALLBACK(src, .proc/validate_mob_poi), append_dead_role = FALSE)
|
||||
var/list/new_other_pois = SSpoints_of_interest.get_other_pois()
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { createSearch } from 'common/string';
|
||||
import { multiline } from 'common/string';
|
||||
import { resolveAsset } from '../assets';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Divider, Flex, Icon, Input, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const PATTERN_NUMBER = / \(([0-9]+)\)$/;
|
||||
|
||||
const searchFor = (searchText) =>
|
||||
createSearch(searchText, (thing) => thing.name);
|
||||
|
||||
const compareString = (a, b) => (a < b ? -1 : a > b);
|
||||
|
||||
const compareNumberedText = (a, b) => {
|
||||
const aName = a.name;
|
||||
const bName = b.name;
|
||||
|
||||
// Check if aName and bName are the same except for a number at the end
|
||||
// e.g. Medibot (2) and Medibot (3)
|
||||
const aNumberMatch = aName.match(PATTERN_NUMBER);
|
||||
const bNumberMatch = bName.match(PATTERN_NUMBER);
|
||||
|
||||
if (
|
||||
aNumberMatch &&
|
||||
bNumberMatch &&
|
||||
aName.replace(PATTERN_NUMBER, '') === bName.replace(PATTERN_NUMBER, '')
|
||||
) {
|
||||
const aNumber = parseInt(aNumberMatch[1], 10);
|
||||
const bNumber = parseInt(bNumberMatch[1], 10);
|
||||
|
||||
return aNumber - bNumber;
|
||||
}
|
||||
|
||||
return compareString(aName, bName);
|
||||
};
|
||||
|
||||
const BasicSection = (props, context) => {
|
||||
const { act } = useBackend(context);
|
||||
const { searchText, source, title, autoObserve } = props;
|
||||
|
||||
const things = source.filter(searchFor(searchText));
|
||||
things.sort(compareNumberedText);
|
||||
return (
|
||||
source.length > 0 && (
|
||||
<Section title={`${title} - (${source.length})`}>
|
||||
{things.map((thing) => (
|
||||
<Button
|
||||
key={thing.name}
|
||||
content={thing.name}
|
||||
onClick={() =>
|
||||
act('orbit', {
|
||||
ref: thing.ref,
|
||||
auto_observe: autoObserve,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const OrbitedButton = (props, context) => {
|
||||
const { act } = useBackend(context);
|
||||
const { color, thing, autoObserve } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
onClick={() =>
|
||||
act('orbit', {
|
||||
ref: thing.ref,
|
||||
auto_observe: autoObserve,
|
||||
})
|
||||
}>
|
||||
{thing.name}
|
||||
{thing.orbiters && (
|
||||
<Box inline ml={1}>
|
||||
{'('}
|
||||
{thing.orbiters}{' '}
|
||||
<Box as="img" src={resolveAsset('ghost.png')} opacity={0.7} />
|
||||
{')'}
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const Orbit = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { alive, antagonists, dead, ghosts, misc, npcs } = data;
|
||||
|
||||
const [searchText, setSearchText] = useLocalState(context, 'searchText', '');
|
||||
const [autoObserve, setAutoObserve] = useLocalState(
|
||||
context,
|
||||
'autoObserve',
|
||||
false
|
||||
);
|
||||
|
||||
const collatedAntagonists = {};
|
||||
for (const antagonist of antagonists) {
|
||||
if (collatedAntagonists[antagonist.antag] === undefined) {
|
||||
collatedAntagonists[antagonist.antag] = [];
|
||||
}
|
||||
collatedAntagonists[antagonist.antag].push(antagonist);
|
||||
}
|
||||
|
||||
const sortedAntagonists = Object.entries(collatedAntagonists);
|
||||
sortedAntagonists.sort((a, b) => {
|
||||
return compareString(a[0], b[0]);
|
||||
});
|
||||
|
||||
const orbitMostRelevant = (searchText) => {
|
||||
for (const source of [
|
||||
sortedAntagonists.map(([_, antags]) => antags),
|
||||
alive,
|
||||
ghosts,
|
||||
dead,
|
||||
npcs,
|
||||
misc,
|
||||
]) {
|
||||
const member = source
|
||||
.filter(searchFor(searchText))
|
||||
.sort(compareNumberedText)[0];
|
||||
if (member !== undefined) {
|
||||
act('orbit', {
|
||||
ref: member.ref,
|
||||
auto_observe: autoObserve,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title="Orbit" width={350} height={700}>
|
||||
<Window.Content scrollable>
|
||||
<Section>
|
||||
<Flex>
|
||||
<Flex.Item>
|
||||
<Icon name="search" mr={1} />
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1}>
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
fluid
|
||||
value={searchText}
|
||||
onInput={(_, value) => setSearchText(value)}
|
||||
onEnter={(_, value) => orbitMostRelevant(value)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<Divider vertical />
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<Button
|
||||
inline
|
||||
color="transparent"
|
||||
tooltip={multiline`Toggle Auto-Observe. When active, you'll
|
||||
see the UI / full inventory of whoever you're orbiting. Neat!`}
|
||||
tooltipPosition="bottom-start"
|
||||
selected={autoObserve}
|
||||
icon={autoObserve ? 'toggle-on' : 'toggle-off'}
|
||||
onClick={() => setAutoObserve(!autoObserve)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<Button
|
||||
inline
|
||||
color="transparent"
|
||||
tooltip="Refresh"
|
||||
tooltipPosition="bottom-start"
|
||||
icon="sync-alt"
|
||||
onClick={() => act('refresh')}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Section>
|
||||
{antagonists.length > 0 && (
|
||||
<Section title="Ghost-Visible Antagonists">
|
||||
{sortedAntagonists.map(([name, antags]) => (
|
||||
<Section key={name} title={name} level={2}>
|
||||
{antags
|
||||
.filter(searchFor(searchText))
|
||||
.sort(compareNumberedText)
|
||||
.map((antag) => (
|
||||
<OrbitedButton
|
||||
key={antag.name}
|
||||
color="bad"
|
||||
thing={antag}
|
||||
autoObserve={autoObserve}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title={`Alive - (${alive.length})`}>
|
||||
{alive
|
||||
.filter(searchFor(searchText))
|
||||
.sort(compareNumberedText)
|
||||
.map((thing) => (
|
||||
<OrbitedButton
|
||||
key={thing.name}
|
||||
color="good"
|
||||
thing={thing}
|
||||
autoObserve={autoObserve}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={`Ghosts - (${ghosts.length})`}>
|
||||
{ghosts
|
||||
.filter(searchFor(searchText))
|
||||
.sort(compareNumberedText)
|
||||
.map((thing) => (
|
||||
<OrbitedButton
|
||||
key={thing.name}
|
||||
color="grey"
|
||||
thing={thing}
|
||||
autoObserve={autoObserve}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<BasicSection
|
||||
title="Dead"
|
||||
source={dead}
|
||||
searchText={searchText}
|
||||
autoObserve={autoObserve}
|
||||
/>
|
||||
|
||||
<BasicSection title="NPCs" source={npcs} searchText={searchText} />
|
||||
|
||||
<BasicSection title="Misc" source={misc} searchText={searchText} />
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { filter, sortBy } from 'common/collections';
|
||||
import { multiline } from 'common/string';
|
||||
import { Button, Collapsible, Icon, Input, Section, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { flow } from 'common/fp';
|
||||
import { logger } from '../logging';
|
||||
|
||||
type AntagGroup = [string, Observable[]];
|
||||
|
||||
type Data = {
|
||||
alive: Observable[];
|
||||
antagonists: Observable[];
|
||||
dead: Observable[];
|
||||
ghosts: Observable[];
|
||||
misc: Observable[];
|
||||
npcs: Observable[];
|
||||
};
|
||||
|
||||
type Observable = {
|
||||
ref: string;
|
||||
antag?: string;
|
||||
name: string;
|
||||
orbiters?: number;
|
||||
};
|
||||
|
||||
type SectionProps = {
|
||||
color?: string;
|
||||
section: Observable[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
const ANTAG2COLOR = {
|
||||
'Abductors': 'pink',
|
||||
'Ash Walkers': 'olive',
|
||||
'Biohazards': 'brown',
|
||||
} as const;
|
||||
|
||||
const ANTAG2GROUP = {
|
||||
'Abductor Agent': 'Abductors',
|
||||
'Abductor Scientist': 'Abductors',
|
||||
'Ash Walker': 'Ash Walkers',
|
||||
'Blob': 'Biohazards',
|
||||
'Sentient Disease': 'Biohazards',
|
||||
'Clown Operative': 'Clown Operatives',
|
||||
'Clown Operative Leader': 'Clown Operatives',
|
||||
'Nuclear Operative': 'Nuclear Operatives',
|
||||
'Nuclear Operative Leader': 'Nuclear Operatives',
|
||||
'Space Wizard': 'Wizard Federation',
|
||||
'Wizard Apprentice': 'Wizard Federation',
|
||||
'Wizard Minion': 'Wizard Federation',
|
||||
} as const;
|
||||
|
||||
enum THREAT {
|
||||
None,
|
||||
Small = 'teal',
|
||||
Medium = 'blue',
|
||||
Large = 'violet',
|
||||
}
|
||||
|
||||
export const Orbit = (props, context) => {
|
||||
return (
|
||||
<Window title="Orbit" width={400} height={550}>
|
||||
<Window.Content>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item mt={0}>
|
||||
<ObservableSearch />
|
||||
</Stack.Item>
|
||||
<Stack.Item mt={0.2} grow>
|
||||
<Section fill scrollable>
|
||||
<ObservableContent />
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
/** Controls filtering out the list of observables via search */
|
||||
const ObservableSearch = (props, context) => {
|
||||
const { act, data } = useBackend<Data>(context);
|
||||
const {
|
||||
alive = [],
|
||||
antagonists = [],
|
||||
dead = [],
|
||||
ghosts = [],
|
||||
misc = [],
|
||||
npcs = [],
|
||||
} = data;
|
||||
const [autoObserve, setAutoObserve] = useLocalState<boolean>(
|
||||
context,
|
||||
'autoObserve',
|
||||
false
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useLocalState<string>(
|
||||
context,
|
||||
'searchQuery',
|
||||
''
|
||||
);
|
||||
/** Gets a list of Observable[], then filters the most relevant to orbit */
|
||||
const orbitMostRelevant = (searchQuery: string): void => {
|
||||
/** Returns the most orbited observable that matches the search. */
|
||||
const mostRelevant: Observable = flow([
|
||||
// Filters out anything that doesn't match search
|
||||
filter<Observable>((observable) =>
|
||||
observable.name?.toLowerCase().includes(searchQuery?.toLowerCase())
|
||||
),
|
||||
// Sorts descending by orbiters
|
||||
sortBy<Observable>((poi) => -(poi.orbiters || 0)),
|
||||
// Makes a single Observable[] list for an easy search
|
||||
])([alive, antagonists, dead, ghosts, misc, npcs].flat())[0];
|
||||
logger.log(mostRelevant);
|
||||
if (mostRelevant !== undefined) {
|
||||
act('orbit', {
|
||||
ref: mostRelevant.ref,
|
||||
auto_observe: autoObserve,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<Stack>
|
||||
<Stack.Item>
|
||||
<Icon name="search" />
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<Input
|
||||
autoFocus
|
||||
fluid
|
||||
onEnter={(e, value) => orbitMostRelevant(value)}
|
||||
onInput={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Divider />
|
||||
<Stack.Item>
|
||||
<Button
|
||||
color={autoObserve ? 'good' : 'transparent'}
|
||||
icon={autoObserve ? 'toggle-on' : 'toggle-off'}
|
||||
onClick={() => setAutoObserve(!autoObserve)}
|
||||
tooltip={multiline`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
|
||||
inline
|
||||
color="transparent"
|
||||
tooltip="Refresh"
|
||||
tooltipPosition="bottom-start"
|
||||
icon="sync-alt"
|
||||
onClick={() => act('refresh')}
|
||||
/>
|
||||
</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, context) => {
|
||||
const { data } = useBackend<Data>(context);
|
||||
const {
|
||||
alive = [],
|
||||
antagonists = [],
|
||||
dead = [],
|
||||
ghosts = [],
|
||||
misc = [],
|
||||
npcs = [],
|
||||
} = data;
|
||||
let collatedAntagonists: AntagGroup[] = [];
|
||||
if (antagonists.length) {
|
||||
collatedAntagonists = collateAntagonists(antagonists);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack vertical>
|
||||
{collatedAntagonists?.map(([name, antag]) => {
|
||||
return (
|
||||
<ObservableSection
|
||||
color={ANTAG2COLOR[name] || 'bad'}
|
||||
key={name}
|
||||
section={antag}
|
||||
title={name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ObservableSection color="good" 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: SectionProps, context) => {
|
||||
const { color = 'grey', section = [], title } = props;
|
||||
if (!section.length) {
|
||||
return null;
|
||||
}
|
||||
const [searchQuery, setSearchQuery] = useLocalState<string>(
|
||||
context,
|
||||
'searchQuery',
|
||||
''
|
||||
);
|
||||
const filteredSection: Observable[] = flow([
|
||||
filter<Observable>((poi) =>
|
||||
poi.name?.toLowerCase().includes(searchQuery?.toLowerCase())
|
||||
),
|
||||
sortBy<Observable>((poi) => poi.name.toLowerCase()),
|
||||
])(section);
|
||||
if (!filteredSection.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Item>
|
||||
<Collapsible
|
||||
bold
|
||||
color={color}
|
||||
open={color !== 'grey'}
|
||||
title={title + ` - (${filteredSection.length})`}>
|
||||
{filteredSection.map((poi, index) => {
|
||||
return <ObservableItem color={color} item={poi} key={index} />;
|
||||
})}
|
||||
</Collapsible>
|
||||
</Stack.Item>
|
||||
);
|
||||
};
|
||||
|
||||
/** Renders an observable button */
|
||||
const ObservableItem = (
|
||||
props: { color: string; item: Observable },
|
||||
context
|
||||
) => {
|
||||
const { act } = useBackend<Data>(context);
|
||||
const {
|
||||
color,
|
||||
item: { name, orbiters, ref },
|
||||
} = props;
|
||||
const [autoObserve, setAutoObserve] = useLocalState<boolean>(
|
||||
context,
|
||||
'autoObserve',
|
||||
false
|
||||
);
|
||||
const threat = getThreat(orbiters || 0);
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={threat || color}
|
||||
onClick={() => act('orbit', { auto_observe: autoObserve, ref: ref })}>
|
||||
{nameToUpper(name).slice(0, 44) /** prevents it from overflowing */}
|
||||
{!!orbiters && (
|
||||
<>
|
||||
{' '}
|
||||
({orbiters?.toString()}{' '}
|
||||
<Icon mr={0} name={threat === THREAT.Large ? 'skull' : 'ghost'} />)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collates antagonist groups into their own separate sections.
|
||||
* Some antags are grouped together lest they be listed separately,
|
||||
* ie: Nuclear Operatives. See: ANTAG_GROUPS.
|
||||
*/
|
||||
const collateAntagonists = (antagonists: Observable[]): AntagGroup[] => {
|
||||
const collatedAntagonists = {};
|
||||
for (const antagonist of antagonists) {
|
||||
const { antag } = antagonist;
|
||||
const resolvedName = ANTAG2GROUP[antag!] || antag;
|
||||
if (collatedAntagonists[resolvedName] === undefined) {
|
||||
collatedAntagonists[resolvedName] = [];
|
||||
}
|
||||
collatedAntagonists[resolvedName].push(antagonist);
|
||||
}
|
||||
const sortedAntagonists = sortBy<AntagGroup>((antagonist) => antagonist[0])(
|
||||
Object.entries(collatedAntagonists)
|
||||
);
|
||||
|
||||
return sortedAntagonists;
|
||||
};
|
||||
|
||||
/** Takes the amount of orbiters and returns some style options */
|
||||
const getThreat = (orbiters: number): THREAT => {
|
||||
if (!orbiters || orbiters <= 2) {
|
||||
return THREAT.None;
|
||||
} else if (orbiters === 3) {
|
||||
return THREAT.Small;
|
||||
} else if (orbiters <= 6) {
|
||||
return THREAT.Medium;
|
||||
} else {
|
||||
return THREAT.Large;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a string with the first letter in uppercase.
|
||||
* Unlike capitalize(), has no effect on the other letters
|
||||
*/
|
||||
const nameToUpper = (name: string): string =>
|
||||
name.replace(/^\w/, (c) => c.toUpperCase());
|
||||
@@ -19,7 +19,7 @@ type Wire = {
|
||||
export const Wires = (props, context) => {
|
||||
const { data } = useBackend<Data>(context);
|
||||
const { proper_name, status = [], wires = [] } = data;
|
||||
const dynamicHeight = 150 + wires.length * 28 + (proper_name ? 30 : 0);
|
||||
const dynamicHeight = 150 + wires.length * 30 + (proper_name ? 30 : 0);
|
||||
|
||||
return (
|
||||
<Window width={350} height={dynamicHeight}>
|
||||
|
||||
Reference in New Issue
Block a user