tgui for techfabs 1.5: the parts I should've done last week edition (#69665)

* Set minimum width for window

* Adjust window height

* Material cost is always on

* QOL the hell out of techfabs

* Smash techfab into more components

* All designs; not all recipes

* Use `type` instead of `interface`

* Drop access modifiers on `AnimatedQuantityLabel`
This commit is contained in:
scriptis
2022-09-05 14:26:48 +03:00
committed by GitHub
parent e8a8c644f1
commit 1405a2e98f
9 changed files with 681 additions and 199 deletions
@@ -0,0 +1,115 @@
import { Component, createRef } from 'inferno';
import { formatSiUnit } from '../../format';
/**
* The properties of an animated quantity label.
*/
export type AnimatedQuantityLabelProps = {
/**
* The target value to approach.
*/
targetValue: number;
};
/**
* Quantity labels are animated at roughly 60 frames per second.
*/
const SIXTY_HZ = 1_000.0 / 60.0;
/**
* An animated quantity label. Shows an SI-encoded number, and animates it
* towards towards the provided target value.
*/
export class AnimatedQuantityLabel extends Component<AnimatedQuantityLabelProps> {
/**
* The inner `<span/>` being updated sixty times per second.
*/
ref = createRef<HTMLSpanElement>();
/**
* The interval being used to update the inner span.
*/
interval?: NodeJS.Timeout;
/**
* The current value. This values approaches the target value.
*/
currentValue: number = 0;
constructor(props: AnimatedQuantityLabelProps) {
super(props);
this.currentValue = props.targetValue;
}
componentWillUnmount() {
// Stop animating when the component is unmounted.
this.stopTicking();
}
shouldComponentUpdate(newProps: AnimatedQuantityLabelProps) {
if (newProps.targetValue !== this.props.targetValue) {
// The target value has been adjusted; start animating if we aren't
// already.
this.startTicking();
}
// Never re-render this component; we handle it manually. Inferno is too
// slow to handle 60 frames per second in IE.
return false;
}
/**
* Starts animating the inner span. If the inner span is already animating,
* this is a no-op.
*/
startTicking() {
if (this.interval !== undefined) {
return;
}
this.interval = setInterval(() => this.tick(), SIXTY_HZ);
}
/**
* Stops animating the inner span.
*/
stopTicking() {
if (this.interval !== undefined) {
clearInterval(this.interval);
this.interval = undefined;
}
}
/**
* Steps forward one frame.
*/
tick() {
const { currentValue } = this;
const { targetValue } = this.props;
this.currentValue += (targetValue - currentValue) / SIXTY_HZ;
if (Math.abs(targetValue - currentValue) < 1) {
this.stopTicking();
}
if (this.ref.current) {
this.ref.current.innerText = this.getText();
}
}
/**
* Returns the inner text of the span.
*/
getText() {
return formatSiUnit(this.currentValue, 0);
}
render() {
// Only executes for the first render; afterwards, we directly animate
// the inner contents using the ref.
return <span ref={this.ref}>{this.getText()}</span>;
}
}
@@ -0,0 +1,80 @@
import { sortBy } from 'common/collections';
import { Stack, Section, Tabs } from '../../components';
/**
* A single category in a category tab list.
*/
export type Category = {
/**
* The human-readable name of this category.
*/
displayName?: string;
/**
* The key used to identify this category.
*/
key: string;
};
/**
* The properties of a category tab list.
*/
export type CategoryTabsProps = {
/**
* The current key of the selected category.
*/
currentCategory: string;
/**
* The categories in this list.
*/
categories: Category[];
/**
* Invoked with a category's key when a new category is selected.
*/
onCategorySelected?: (newCategory: string) => void;
};
/**
* A tab list used for category selection.
*/
export const CategoryTabs = (props: CategoryTabsProps, context) => {
const {
currentCategory,
categories,
onCategorySelected: setCategory,
} = props;
return (
<Section fill>
<Stack vertical fill>
<Stack.Item>
<Section title="Categories" fitted />
</Stack.Item>
<Stack.Item grow>
<Section fill style={{ 'overflow': 'auto' }}>
<Tabs vertical>
{sortBy((c: Category) => c.displayName || c.key)(categories).map(
(descriptor) => (
<Tabs.Tab
key={descriptor.key}
selected={currentCategory === descriptor.key}
onClick={() => {
currentCategory !== descriptor.key &&
setCategory &&
setCategory(descriptor.key);
}}
fluid
color="transparent">
{descriptor.displayName || descriptor.key}
</Tabs.Tab>
)
)}
</Tabs>
</Section>
</Stack.Item>
</Stack>
</Section>
);
};
@@ -0,0 +1,77 @@
import { CategoryTabs } from './CategoryTabs';
import { Design } from './Types';
/**
* A dummy category that, when selected, renders ALL recipes to the UI.
*/
export const ALL_CATEGORY = '__ALL';
/**
* Categories present in this object are not rendered to the final fabricator
* UI.
*/
const BLACKLISTED_CATEGORIES: Record<string, boolean> = {
'initial': true,
'core': true,
'hacked': true,
};
export type DesignCategoryTabsProps = {
/**
* The designs to generate categories from.
*/
designs: Design[];
/**
* The currently selected category.
*/
currentCategory: string;
/**
* Invoked when the user selects a new category.
*/
onCategorySelected?: (newCategory: string) => void;
};
/**
* A list of tabs generated on the fly from a number of techfab designs.
*/
export const DesignCategoryTabs = (props: DesignCategoryTabsProps, context) => {
const { designs, currentCategory, onCategorySelected } = props;
// Find the number of items in each unique category, and the sum total of all
// printable items.
const categoryCounts: Record<string, number> = {};
let totalRecipes = 0;
for (const design of designs) {
totalRecipes += 1;
for (const category of design.categories ?? []) {
categoryCounts[category] = (categoryCounts[category] ?? 0) + 1;
}
}
// Strip blacklisted categories from the output.
for (const blacklistedCategory in BLACKLISTED_CATEGORIES) {
delete categoryCounts[blacklistedCategory];
}
const categories = Object.entries(categoryCounts).map(([name, count]) => ({
key: name,
displayName: `${name} (${count})`,
}));
categories.unshift({
key: ALL_CATEGORY,
displayName: `All Designs (${totalRecipes})`,
});
return (
<CategoryTabs
currentCategory={currentCategory}
onCategorySelected={onCategorySelected}
categories={categories}
/>
);
};
@@ -0,0 +1,50 @@
import { Design, MaterialMap } from './Types';
import { Stack } from '../../components';
import { MaterialAmount, MATERIAL_KEYS, MaterialFormatting } from '../common/Materials';
export type DesignCostListProps = {
/**
* The design being printed.
*/
design: Design;
/**
* The amount of times to print.
*/
amount: number;
/**
* The materials available to complete the job.
*/
available: MaterialMap;
};
/**
* A horizontal sequence of material costs, indicating the effect of queueing
* a print job. Orange labels indicate that the job can only be completed once,
* and red labels indicate the job can't be completed at all.
*/
export const DesignCostList = (props: DesignCostListProps, context) => {
const { design, amount, available } = props;
return (
<Stack wrap justify="space-around">
{Object.entries(design.cost).map(([material, cost]) => (
<Stack.Item key={material}>
<MaterialAmount
name={material as keyof typeof MATERIAL_KEYS}
amount={cost * amount}
formatting={MaterialFormatting.SIUnits}
color={
cost * amount > available[material]
? 'bad'
: cost * amount * 2 > available[material]
? 'average'
: 'normal'
}
/>
</Stack.Item>
))}
</Stack>
);
};
@@ -0,0 +1,149 @@
import { sortBy } from 'common/collections';
import { useLocalState } from '../../backend';
import { Flex, Button, Stack } from '../../components';
import { Material, MaterialIcon } from '../common/Materials';
import { AnimatedQuantityLabel } from './AnimatedQuantityLabel';
import { MaterialName } from './Types';
// by popular demand of discord people (who are always right and never wrong)
// this is completely made up
const MINERAL_RARITY: Record<MaterialName, number> = {
'iron': 1,
'glass': 0,
'silver': 5,
'gold': 6,
'diamond': 8,
'plasma': 4,
'uranium': 7,
'bananium': 10,
'titanium': 3,
'bluespace crystal': 9,
'plastic': 2,
};
export type MineralAccessBarProps = {
/**
* All materials currently available to the user.
*/
availableMaterials: Material[];
/**
* Invoked when the user requests that a material be ejected.
*/
onEjectRequested?: (material: Material, quantity: number) => void;
};
/**
* A bottom-docked bar for viewing and ejecting materials from local storage or
* the ore silo. Has pop-out docks for each material type for ejecting up to
* fifty sheets.
*/
export const MineralAccessBar = (props: MineralAccessBarProps, context) => {
const { availableMaterials, onEjectRequested } = props;
return (
<Flex wrap>
{sortBy((m: Material) => MINERAL_RARITY[m.name])(availableMaterials).map(
(material) => (
<Flex.Item key={material.name} grow={1} shrink={1}>
<MineralCounter
material={material}
onEjectRequested={(quantity) =>
onEjectRequested && onEjectRequested(material, quantity)
}
/>
</Flex.Item>
)
)}
</Flex>
);
};
type MineralCounterProps = {
material: Material;
onEjectRequested: (quantity: number) => void;
};
const MineralCounter = (props: MineralCounterProps, context) => {
const { material, onEjectRequested } = props;
const [hovering, setHovering] = useLocalState(
context,
`MaterialCounter__${material.name}`,
false
);
return (
<div
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
className={`MaterialDock ${hovering ? 'MaterialDock--active' : ''}`}>
<Stack vertial direction={'column-reverse'}>
<Flex
direction="column"
textAlign="center"
onClick={() => onEjectRequested(1)}>
<Flex.Item>
<MaterialIcon material={material.name} />
</Flex.Item>
<Flex.Item>
<AnimatedQuantityLabel targetValue={material.amount} />
</Flex.Item>
</Flex>
{hovering && (
<div className={'MaterialDock__Dock'}>
<Flex vertical direction={'column-reverse'}>
<EjectButton
material={material}
available={material.amount}
amount={5}
onEject={onEjectRequested}
/>
<EjectButton
material={material}
available={material.amount}
amount={10}
onEject={onEjectRequested}
/>
<EjectButton
material={material}
available={material.amount}
amount={25}
onEject={onEjectRequested}
/>
<EjectButton
material={material}
available={material.amount}
amount={50}
onEject={onEjectRequested}
/>
</Flex>
</div>
)}
</Stack>
</div>
);
};
type EjectButtonProps = {
material: Material;
available: number;
amount: number;
onEject: (quantity: number) => void;
};
const EjectButton = (props: EjectButtonProps, context) => {
const { amount, available, material, onEject } = props;
return (
<Button
fluid
color={'transparent'}
className={`Fabricator__PrintAmount ${
amount * 2_000 > available ? 'Fabricator__PrintAmount--disabled' : ''
}`}
onClick={() => onEject(amount)}>
&times;{amount}
</Button>
);
};
@@ -0,0 +1,44 @@
import { Stack, Input, Icon } from '../../components';
/**
* The properties of a search bar.
*/
export type SearchBarProps = {
/**
* The hint displayed in the search bar when it is empty.
*/
hint?: string;
/**
* The currently set search text.
*/
searchText: string;
/**
* Invoked whenever the search text is changed by the user.
*/
onSearchTextChanged: (newSearchText: string) => void;
};
/**
* A simple, stylized search bar.
*/
export const SearchBar = (props: SearchBarProps, context) => {
const { searchText, onSearchTextChanged, hint } = props;
return (
<Stack align="baseline">
<Stack.Item>
<Icon name="search" />
</Stack.Item>
<Stack.Item grow>
<Input
fluid
placeholder={hint ? hint : 'Search for...'}
onInput={(_e: unknown, v: string) => onSearchTextChanged(v)}
value={searchText}
/>
</Stack.Item>
</Stack>
);
};
@@ -0,0 +1,80 @@
import { Material, MATERIAL_KEYS } from '../common/Materials';
/**
* A named material.
*/
export type MaterialName = keyof typeof MATERIAL_KEYS;
/**
* A map of keyed materials to a quantity.
*/
export type MaterialMap = Partial<Record<MaterialName, number>>;
/**
* A single design that the fabricator can print.
*/
export type Design = {
/**
* The name of the design.
*/
name: string;
/**
* A human-readable description of the design.
*/
desc: string;
/**
* The individual material cost to print the design, adjusted for the
* fabricator's part efficiency.
*/
cost: MaterialMap;
/**
* A reference to the design's design datum.
*/
id: string;
/**
* The categories the design should be present in.
*/
categories?: string[];
};
/**
* The static and dynamic data made available to a fabricator UI.
*/
export type FabricatorData = {
/**
* The materials available to the fabricator, via ore silo or local storage.
*/
materials: Material[];
/**
* The name of the fabricator, as displayed on the title bar.
*/
fab_name: string;
/**
* Whether mineral access is disabled from the ore silo (contact the
* quartermaster).
*/
on_hold: boolean;
/**
* The set of designs that this fabricator can print, indexed by their ID.
*/
designs: Record<string, Design>;
/**
* Whether the fabricator is currently printing an item.
*/
busy: boolean;
/**
* If nonzero, the maximum quantity of material that the fabricator can hold.
* Typically present with local storage is enabled (e.g, disconnected from
* the ore silo).
*/
materialMaximum: number;
};
+41 -199
View File
@@ -1,80 +1,12 @@
import { useBackend, useSharedState } from '../backend';
import { Stack, Section, Button, Input, Icon, Tabs, Dimmer } from '../components';
import { Stack, Section, Button, Icon, Dimmer } from '../components';
import { Window } from '../layouts';
import { Material, MaterialAmount, MaterialFormatting, Materials, MATERIAL_KEYS } from './common/Materials';
import { Fragment } from 'inferno';
import { Material, MaterialAmount, MaterialFormatting, MATERIAL_KEYS } from './common/Materials';
import { sortBy } from 'common/collections';
type MaterialMap = Partial<Record<keyof typeof MATERIAL_KEYS, number>>;
/**
* A single design that the fabricator can print.
*/
type Design = {
/**
* The name of the design.
*/
name: string;
/**
* A human-readable description of the design.
*/
desc: string;
/**
* The individual material cost to print the design, adjusted for the
* fabricator's part efficiency.
*/
cost: MaterialMap;
/**
* A reference to the design's design datum.
*/
id: string;
/**
* The categories the design should be present in.
*/
categories?: string[];
};
type FabricatorData = {
/**
* The materials available to the fabricator, via ore silo or local storage.
*/
materials: Material[];
/**
* The name of the fabricator, as displayed on the title bar.
*/
fab_name: string;
/**
* Whether mineral access is disabled from the ore silo (contact the
* quartermaster).
*/
on_hold: boolean;
/**
* The set of designs that this fabricator can print, ordered by their ID.
*/
designs: Record<string, Design>;
/**
* Whether the fabricator is currently printing an item.
*/
busy: boolean;
};
/**
* Categories present in this object are not rendered to the final fabricator
* UI.
*/
const BLACKLISTED_CATEGORIES: Record<string, boolean> = {
'initial': true,
'core': true,
'hacked': true,
};
import { MineralAccessBar } from './Fabrication/MineralAccessBar';
import { SearchBar } from './Fabrication/SearchBar';
import { DesignCategoryTabs } from './Fabrication/DesignCategoryTabs';
import { FabricatorData, Design, MaterialMap } from './Fabrication/Types';
/**
* A dummy category that, when selected, renders ALL recipes to the UI.
@@ -83,7 +15,7 @@ const ALL_CATEGORY = '__ALL';
export const Fabricator = (props, context) => {
const { act, data } = useBackend<FabricatorData>(context);
const { materials, fab_name, on_hold, designs, busy } = data;
const { fab_name, on_hold, designs, busy } = data;
const [selectedCategory, setSelectedCategory] = useSharedState(
context,
@@ -95,35 +27,12 @@ export const Fabricator = (props, context) => {
'search_text',
''
);
const [displayMatCost, setDisplayMatCost] = useSharedState(
context,
'display_material_cost',
true
);
// Sort the designs by name.
const sortedDesigns = sortBy((design: Design) => design.name)(
Object.values(designs)
);
// Find the number of items in each unique category, and the sum total of all
// printable items.
const categoryCounts: Record<string, number> = {};
let totalRecipes = 0;
for (const design of sortedDesigns) {
totalRecipes += 1;
for (const category of design.categories ?? []) {
categoryCounts[category] = (categoryCounts[category] ?? 0) + 1;
}
}
// Strip blacklisted categories from the output.
for (const blacklistedCategory in BLACKLISTED_CATEGORIES) {
delete categoryCounts[blacklistedCategory];
}
// Reduce the material count array to a map of actually available materials.
const availableMaterials: MaterialMap = {};
@@ -131,73 +40,49 @@ export const Fabricator = (props, context) => {
availableMaterials[material.name] = material.amount;
}
// Render all categories with items, sorted by name.
const namedCategories = Object.keys(categoryCounts).sort();
return (
<Window title={data.fab_name} width={670} height={768}>
<Window title={fab_name} width={670} height={600}>
<Window.Content>
<Stack vertical fill>
<Stack.Item grow>
<Stack fill>
<Stack.Item>
<Section fill title="Categories">
<Tabs vertical>
<Tabs.Tab
fluid
selected={selectedCategory === ALL_CATEGORY}
onClick={() => {
setSelectedCategory(ALL_CATEGORY);
setSearchText('');
}}
color="transparent">
All Designs ({totalRecipes})
</Tabs.Tab>
{namedCategories.map((categoryName) => (
<Tabs.Tab
key={categoryName}
selected={selectedCategory === categoryName}
onClick={() => {
setSelectedCategory(categoryName);
setSearchText('');
}}
fluid
color="transparent">
{categoryName} ({categoryCounts[categoryName]})
</Tabs.Tab>
))}
</Tabs>
</Section>
<Stack.Item width={'200px'}>
<DesignCategoryTabs
currentCategory={selectedCategory}
onCategorySelected={(category) => {
setSelectedCategory(category);
setSearchText('');
}}
designs={sortedDesigns}
/>
</Stack.Item>
<Stack.Item grow>
<Section
title="Designs"
title={
selectedCategory === ALL_CATEGORY
? 'All Designs'
: selectedCategory
}
fill
buttons={
<Fragment>
<Button.Checkbox
onClick={() => setDisplayMatCost(!displayMatCost)}
checked={displayMatCost}>
Display Material Costs
</Button.Checkbox>
<Button
content="R&D Sync"
onClick={() => act('sync_rnd')}
/>
</Fragment>
<Button
color={'transparent'}
onClick={() => act('sync_rnd')}>
<Icon name="refresh" /> Sync Research
</Button>
}>
<Stack vertical fill>
<Stack.Item>
<Section>
<SearchBar
searchText={searchText}
setSearchText={setSearchText}
onSearchTextChanged={setSearchText}
hint={'Search this category...'}
/>
</Section>
</Stack.Item>
<Stack.Item grow>
<Section fill scrollable>
<Section fill style={{ 'overflow': 'auto' }}>
{sortedDesigns
.filter(
(design) =>
@@ -206,7 +91,9 @@ export const Fabricator = (props, context) => {
-1
)
.filter((design) =>
design.name.toLowerCase().includes(searchText)
design.name
.toLowerCase()
.includes(searchText.toLowerCase())
)
.map((design) => (
<Recipe
@@ -240,11 +127,13 @@ export const Fabricator = (props, context) => {
</Stack.Item>
<Stack.Item>
<Section>
<Materials
materials={sortBy((a: Material) => a.name)(
<MineralAccessBar
availableMaterials={sortBy((a: Material) => a.name)(
data.materials ?? []
)}
onEject={(ref, amount) => act('remove_mat', { ref, amount })}
onEjectRequested={(material, amount) =>
act('remove_mat', { ref: material.ref, amount })
}
/>
</Section>
</Stack.Item>
@@ -254,31 +143,6 @@ export const Fabricator = (props, context) => {
);
};
type SearchBarProps = {
searchText: string;
setSearchText: (text: string) => void;
};
const SearchBar = (props: SearchBarProps, context) => {
const { searchText, setSearchText } = props;
return (
<Stack align="baseline">
<Stack.Item>
<Icon name="search" />
</Stack.Item>
<Stack.Item grow>
<Input
fluid
placeholder="Search for..."
onInput={(_e: unknown, v: string) => setSearchText(v.toLowerCase())}
value={searchText}
/>
</Stack.Item>
</Stack>
);
};
type MaterialCostProps = {
design: Design;
amount: number;
@@ -320,11 +184,6 @@ const PrintButton = (props: PrintButtonProps, context) => {
const { act, data } = useBackend<FabricatorData>(context);
const { design, quantity, available } = props;
const [displayMatCost] = useSharedState(
context,
'display_material_cost',
true
);
const canPrint = !Object.entries(design.cost).some(
([material, amount]) =>
!available[material] || amount * quantity > (available[material] ?? 0)
@@ -336,17 +195,11 @@ const PrintButton = (props: PrintButtonProps, context) => {
!canPrint ? 'Fabricator__PrintAmount--disabled' : ''
}`}
tooltip={
displayMatCost && (
<MaterialCost
design={design}
amount={quantity}
available={available}
/>
)
<MaterialCost design={design} amount={quantity} available={available} />
}
color={'transparent'}
onClick={() => act('build', { ref: design.id, amount: quantity })}>
x{quantity}
&times;{quantity}
</Button>
);
};
@@ -355,11 +208,6 @@ const Recipe = (props: { design: Design; available: MaterialMap }, context) => {
const { act, data } = useBackend<FabricatorData>(context);
const { design, available } = props;
const [displayMatCost] = useSharedState(
context,
'display_material_cost',
true
);
const canPrint = !Object.entries(design.cost).some(
([material, amount]) =>
!available[material] || amount > (available[material] ?? 0)
@@ -384,13 +232,7 @@ const Recipe = (props: { design: Design; available: MaterialMap }, context) => {
}`}
fluid
tooltip={
displayMatCost && (
<MaterialCost
design={design}
amount={1}
available={available}
/>
)
<MaterialCost design={design} amount={1} available={available} />
}
onClick={() => act('build', { ref: design.id, amount: 1 })}>
{design.name}
@@ -20,3 +20,48 @@ Fabricator__Icon {
.Fabricator__PrintAmount--disabled {
text-decoration: line-through;
}
.MaterialDock {
position: relative;
padding: 0.5em;
border-radius: 0 0 0.25em 0.25em;
}
.MaterialDock--active {
background-color: #111;
transition: background-color 0.125s ease-out;
}
.MaterialDock__Dock {
position: absolute;
background-color: #111;
bottom: 100%;
left: 0;
width: 100%;
padding: 1em;
border-radius: 0.25em 0.25em 0 0;
text-align: center;
box-shadow: 0 0 3px #000;
}
.MaterialDock--active .MaterialDock__Dock {
@keyframes materialdock-open {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
animation: materialdock-open 0.125s ease-out;
}
.MaterialDock__Button {
width: 100%;
height: 0;
padding-bottom: 100%;
position: relative;
box-shadow: 0 0 3px #000;
}