tgui: Improved Personal Crafting UI (#66933)

This commit is contained in:
Aleksej Komarov
2022-05-21 21:49:31 -07:00
committed by GitHub
parent 261bdda68c
commit b7386a784e
6 changed files with 426 additions and 191 deletions
+1
View File
@@ -376,6 +376,7 @@ rules:
ignoreUrls: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
}]
## Enforce a maximum number of lines per file
# max-lines: error
+3 -6
View File
@@ -248,7 +248,7 @@ type BackendState<TData> = {
shared: Record<string, any>,
suspending: boolean,
suspended: boolean,
}
};
/**
* Selects a backend-related slice of Redux state
@@ -258,12 +258,9 @@ export const selectBackend = <TData>(state: any): BackendState<TData> => (
);
/**
* A React hook (sort of) for getting tgui state and related functions.
* Get data from tgui backend.
*
* This is supposed to be replaced with a real React Hook, which can only
* be used in functional components.
*
* You can make
* Includes the `act` function for performing DM actions.
*/
export const useBackend = <TData>(context: any) => {
const { store } = context;
@@ -1,185 +0,0 @@
import { useBackend, useLocalState } from '../backend';
import { Button, Dimmer, Icon, LabeledList, Section, Stack, Tabs } from '../components';
import { Window } from '../layouts';
export const PersonalCrafting = (props, context) => {
const { act, data } = useBackend(context);
const { busy, display_craftable_only, display_compact } = data;
const crafting_recipes = data.crafting_recipes || {};
// Sort everything into flat categories
const categories = [];
const recipes = [];
for (let category of Object.keys(crafting_recipes)) {
const subcategories = crafting_recipes[category];
if ('has_subcats' in subcategories) {
for (let subcategory of Object.keys(subcategories)) {
if (subcategory === 'has_subcats') {
continue;
}
// Push category
categories.push({
name: subcategory,
category,
subcategory,
});
// Push recipes
const _recipes = subcategories[subcategory];
for (let recipe of _recipes) {
recipes.push({
...recipe,
category: subcategory,
});
}
}
continue;
}
// Push category
categories.push({
name: category,
category,
});
// Push recipes
const _recipes = crafting_recipes[category];
for (let recipe of _recipes) {
recipes.push({
...recipe,
category,
});
}
}
// Sort out the tab state
const [tab, setTab] = useLocalState(context, 'tab', categories[0]?.name);
const shownRecipes = recipes.filter((recipe) => recipe.category === tab);
return (
<Window title="Crafting Menu" width={700} height={700}>
<Window.Content>
<Stack fill>
<Stack.Item grow={1}>
<Section fill scrollable title="Category">
<Tabs vertical>
{categories.map((category) => (
<Tabs.Tab
height={2}
key={category.name}
selected={category.name === tab}
onClick={() => {
setTab(category.name);
act('set_category', {
category: category.category,
subcategory: category.subcategory,
});
}}>
{category.name}
</Tabs.Tab>
))}
</Tabs>
</Section>
</Stack.Item>
<Stack.Item grow={3}>
<Section
fill
title="Recipes"
buttons={
<>
<Button.Checkbox
content="Compact"
checked={display_compact}
onClick={() => act('toggle_compact')}
/>
<Button.Checkbox
content="Craftable Only"
checked={display_craftable_only}
onClick={() => act('toggle_recipes')}
/>
</>
}>
<Section fill scrollable>
{busy ? (
<Dimmer fontSize="32px">
<Icon name="cog" spin={1} />
{' Crafting...'}
</Dimmer>
) : (
<CraftingList craftables={shownRecipes} />
)}
</Section>
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
};
const CraftingList = (props, context) => {
const { craftables = [] } = props;
const { act, data } = useBackend(context);
const { craftability = {}, display_compact, display_craftable_only } = data;
return craftables.map((craftable) => {
if (display_craftable_only && !craftability[craftable.ref]) {
return null;
}
// Compact display
if (display_compact) {
return (
<LabeledList.Item
key={craftable.name}
label={craftable.name}
className="candystripe"
buttons={
<Button
icon="cog"
content="Craft"
disabled={!craftability[craftable.ref]}
tooltip={
craftable.tool_text && 'Tools needed: ' + craftable.tool_text
}
tooltipPosition="left"
onClick={() =>
act('make', {
recipe: craftable.ref,
})}
/>
}>
{craftable.req_text}
</LabeledList.Item>
);
}
// Full display
return (
<Section
key={craftable.name}
title={craftable.name}
level={2}
buttons={
<Button
icon="cog"
content="Craft"
disabled={!craftability[craftable.ref]}
onClick={() =>
act('make', {
recipe: craftable.ref,
})}
/>
}>
<LabeledList>
{!!craftable.req_text && (
<LabeledList.Item label="Required">
{craftable.req_text}
</LabeledList.Item>
)}
{!!craftable.catalyst_text && (
<LabeledList.Item label="Catalyst">
{craftable.catalyst_text}
</LabeledList.Item>
)}
{!!craftable.tool_text && (
<LabeledList.Item label="Tools">
{craftable.tool_text}
</LabeledList.Item>
)}
</LabeledList>
</Section>
);
});
};
@@ -0,0 +1,318 @@
import { filter, sortBy } from 'common/collections';
import { flow } from 'common/fp';
import { BooleanLike, classes } from 'common/react';
import { useBackend } from '../backend';
import { Button, Dimmer, Icon, Section, Stack, Table } from '../components';
import { Window } from '../layouts';
type RawRecipe = {
name: string;
ref: string;
req_text: string;
catalyst_text: string;
tool_text: string;
};
type RawData = {
// Dynamic data
busy: BooleanLike | null;
category: string;
subcategory: string;
display_craftable_only: BooleanLike;
display_compact: BooleanLike;
craftability: Record<string, BooleanLike>;
// Static data
crafting_recipes: Record<
string,
// That's the weirdest piece of type I had to write in my life.
// Yes, that's how subcategories are sent here.
RawRecipe[] | (Record<string, RawRecipe[]> & { has_subcats: 1 })
>;
};
type Recipe = RawRecipe & {
craftable: boolean;
dm_category: string;
dm_subcategory?: string;
};
type Category = {
name: string;
dm_category: string;
dm_subcategory?: string;
};
type Group = {
name: string;
categories: Category[];
};
type Data = RawData & {
dm_category: string;
dm_subcategory: string;
recipes: Recipe[];
groups: Group[];
};
const GENERAL_GROUP = 'General';
const remapData = (rawData: RawData): Data => {
const craftability_by_ref = rawData.craftability;
const dm_categories = rawData.crafting_recipes || {};
const recipes: Recipe[] = [];
const generalGroup: Group = {
name: GENERAL_GROUP,
categories: [],
};
const groups: Group[] = [generalGroup];
for (const dm_category of Object.keys(dm_categories)) {
const dm_categories_item = dm_categories[dm_category];
// This is a nested part of the tree containing subcategories
if ('has_subcats' in dm_categories_item) {
// Create a new group for these categories
const group: Group = {
name: dm_category,
categories: [],
};
groups.push(group);
for (const dm_subcategory of Object.keys(dm_categories_item)) {
// Skip even more nested subcats, they shouldn't exist
if (dm_subcategory === 'has_subcats') {
continue;
}
// Push category
group.categories.push({
name: dm_subcategory,
dm_category,
dm_subcategory,
});
// Push recipes
const dm_recipes = dm_categories_item[dm_subcategory];
for (const dm_recipe of dm_recipes) {
recipes.push({
...dm_recipe,
dm_category,
dm_subcategory,
craftable: Boolean(craftability_by_ref[dm_recipe.ref]),
});
}
}
continue;
}
// This is a flat part of the tree where simple categories are located.
// We group these categories under the "General" group.
if (Array.isArray(dm_categories_item)) {
// Push category
generalGroup.categories.push({
name: dm_category,
dm_category,
});
// Push recipes
for (const dm_recipe of dm_categories_item) {
recipes.push({
...dm_recipe,
dm_category,
craftable: Boolean(craftability_by_ref[dm_recipe.ref]),
});
}
continue;
}
throw new Error(`Invalid entry in 'crafting_recipes', neither '{ has_subcats: 1 }' nor 'Recipe[]'`);
}
return {
...rawData,
dm_category: rawData.category,
dm_subcategory: rawData.subcategory,
recipes,
groups,
};
};
const isCategorySelected = (data: Data, item: Category | Recipe) => (
data.dm_category === item.dm_category
&& (!data.dm_subcategory || data.dm_subcategory === item.dm_subcategory)
);
export const PersonalCrafting = (props, context) => {
const { act, data: rawData } = useBackend<RawData>(context);
const data = remapData(rawData);
const {
busy,
display_craftable_only,
display_compact,
recipes,
groups,
} = data;
const shownRecipes = flow([
filter<Recipe>((recipe) => (
// Show selected category only
isCategorySelected(data, recipe)
// If craftable only is selected, then filter by craftability
&& (!display_craftable_only || recipe.craftable)
)),
sortBy<Recipe>((recipe) => [
-recipe.craftable,
recipe.name,
]),
])(recipes);
return (
<Window title="Crafting Menu" width={700} height={700}>
<Window.Content>
<Stack fill>
<Stack.Item width="150px">
<Section fill scrollable title="Category">
{groups.map((group) => (
<Section key={group.name} title={group.name}>
{group.categories.map((category) => (
<Button
key={category.name}
fluid
color="transparent"
selected={isCategorySelected(data, category)}
onClick={() => act('set_category', {
category: category.dm_category,
subcategory: category.dm_subcategory,
})}>
{category.name}
</Button>
))}
</Section>
))}
</Section>
</Stack.Item>
<Stack.Item grow={3}>
<Section
fill
title="Recipes"
buttons={
<>
<Button.Checkbox
content="Compact"
checked={display_compact}
onClick={() => act('toggle_compact')}
/>
<Button.Checkbox
content="Craftable Only"
checked={display_craftable_only}
onClick={() => act('toggle_recipes')}
/>
</>
}>
<Section fill scrollable>
{busy ? (
<Dimmer fontSize="32px">
<Icon name="cog" spin={1} />
{' Crafting...'}
</Dimmer>
) : (
<CraftingList
recipes={shownRecipes}
compact={Boolean(display_compact)} />
)}
</Section>
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
};
type CraftingListProps = {
recipes: Recipe[];
// eslint-disable-next-line react/no-unused-prop-types
compact?: boolean;
};
const CraftingList = (props: CraftingListProps, context) => {
const { recipes = [], compact } = props;
const { act } = useBackend<RawData>(context);
if (compact) {
return <CompactCraftingList recipes={recipes} />;
}
return recipes.map((recipe) => (
<div
key={recipe.ref}
className={classes([
'PersonalCraftingGridItem',
recipe.craftable && 'PersonalCraftingGridItem--craftable',
])}
onClick={() => {
if (recipe.craftable) {
act('make', {
recipe: recipe.ref,
});
}
}}>
<div className="PersonalCraftingGridItem__content">
<div className="PersonalCraftingGridItem__name">
{recipe.name}
</div>
{!!recipe.req_text && (
recipe.req_text.split(', ').map((req, i) => (
<div key={req + i} className="PersonalCraftingGridItem__prereq">
{req}
</div>
))
)}
{!!recipe.catalyst_text && (
<div className="PersonalCraftingGridItem__extra">
<b>Catalyst:</b> {recipe.catalyst_text}
</div>
)}
{!!recipe.tool_text && (
<div className="PersonalCraftingGridItem__extra">
<b>Tools:</b> {recipe.tool_text}
</div>
)}
<div className="PersonalCraftingGridItem__craftability">
{recipe.craftable ? 'Craft' : 'Uncraftable'}
</div>
</div>
</div>
)) as any;
};
const CompactCraftingList = (props: CraftingListProps, context) => {
const { recipes = [] } = props;
const { act } = useBackend<RawData>(context);
return (
<table>
{recipes.map((recipe) => (
<tr key={recipe.ref} className="candystripe">
<Table.Cell bold maxWidth="250px">
{recipe.name}
</Table.Cell>
<Table.Cell opacity={0.6}>
{recipe.req_text}
</Table.Cell>
<Table.Cell collapsing>
<Button
icon="cog"
content="Craft"
disabled={!recipe.craftable}
tooltip={
recipe.tool_text && 'Tools needed: ' + recipe.tool_text
}
tooltipPosition="left"
onClick={() => act('make', {
recipe: recipe.ref,
})} />
</Table.Cell>
</tr>
))}
</table>
);
};
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2022 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@use 'sass:color';
@use '../base.scss';
@use '../colors.scss';
@use '../functions.scss';
.PersonalCraftingGridItem {
position: relative;
display: inline-block;
width: 33%;
vertical-align: top;
height: 10em;
overflow: hidden;
margin-bottom: 0.25em;
@media only screen and (min-width: 50em) {
width: 25%;
}
@media only screen and (min-width: 75em) {
width: 20%;
}
&:hover {
overflow: visible;
}
}
.PersonalCraftingGridItem--craftable {
cursor: pointer;
}
.PersonalCraftingGridItem__content {
position: relative;
color: #888;
padding: 0.5em 0.5em 2.5em 0.5em;
min-height: 100%;
pointer-events: none;
}
.PersonalCraftingGridItem:hover
.PersonalCraftingGridItem__content {
// This color is already lighter than Section which works well
// for the hover style.
background-color: base.$color-bg;
outline: 2px solid rgba(255, 255, 255, 0.2);
overflow: visible;
z-index: 1;
}
.PersonalCraftingGridItem__craftability {
position: absolute;
bottom: 0;
right: 0;
left: 0;
padding: 0.5em;
color: #fff;
background-color: rgba(255, 0, 0, 0.1);
text-align: center;
visibility: hidden;
}
.PersonalCraftingGridItem--craftable
.PersonalCraftingGridItem__craftability {
background-color: rgba(64, 255, 0, 0.1);
}
.PersonalCraftingGridItem:hover
.PersonalCraftingGridItem__craftability {
visibility: visible;
}
.PersonalCraftingGridItem__name {
position: relative;
overflow: hidden;
font-weight: bold;
margin-bottom: 0.5em;
color: #aaa;
}
.PersonalCraftingGridItem--craftable
.PersonalCraftingGridItem__name {
color: #fff;
}
.PersonalCraftingGridItem__prereq {
color: #777;
overflow: hidden;
white-space: nowrap;
}
.PersonalCraftingGridItem--craftable
.PersonalCraftingGridItem__prereq {
color: #bbb;
}
.PersonalCraftingGridItem__extra {
margin-top: 0.5em;
}
+1
View File
@@ -60,6 +60,7 @@
@include meta.load-css('./interfaces/NuclearBomb.scss');
@include meta.load-css('./interfaces/Mecha.scss');
@include meta.load-css('./interfaces/Paper.scss');
@include meta.load-css('./interfaces/PersonalCrafting.scss');
@include meta.load-css('./interfaces/PreferencesMenu.scss');
@include meta.load-css('./interfaces/Roulette.scss');
@include meta.load-css('./interfaces/Safe.scss');