mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-29 16:18:01 +01:00
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:
@@ -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)}>
|
||||
×{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;
|
||||
};
|
||||
@@ -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}
|
||||
×{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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user