From 1405a2e98f6e7741cfdb46f0fdc6306d3505d1bc Mon Sep 17 00:00:00 2001 From: scriptis Date: Mon, 5 Sep 2022 06:26:48 -0500 Subject: [PATCH] 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` --- .../Fabrication/AnimatedQuantityLabel.tsx | 115 +++++++++ .../interfaces/Fabrication/CategoryTabs.tsx | 80 ++++++ .../Fabrication/DesignCategoryTabs.tsx | 77 ++++++ .../interfaces/Fabrication/DesignCostList.tsx | 50 ++++ .../Fabrication/MineralAccessBar.tsx | 149 +++++++++++ .../tgui/interfaces/Fabrication/SearchBar.tsx | 44 ++++ .../tgui/interfaces/Fabrication/Types.ts | 80 ++++++ tgui/packages/tgui/interfaces/Fabricator.tsx | 240 +++--------------- .../tgui/styles/interfaces/Fabricator.scss | 45 ++++ 9 files changed, 681 insertions(+), 199 deletions(-) create mode 100644 tgui/packages/tgui/interfaces/Fabrication/AnimatedQuantityLabel.tsx create mode 100644 tgui/packages/tgui/interfaces/Fabrication/CategoryTabs.tsx create mode 100644 tgui/packages/tgui/interfaces/Fabrication/DesignCategoryTabs.tsx create mode 100644 tgui/packages/tgui/interfaces/Fabrication/DesignCostList.tsx create mode 100644 tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx create mode 100644 tgui/packages/tgui/interfaces/Fabrication/SearchBar.tsx create mode 100644 tgui/packages/tgui/interfaces/Fabrication/Types.ts diff --git a/tgui/packages/tgui/interfaces/Fabrication/AnimatedQuantityLabel.tsx b/tgui/packages/tgui/interfaces/Fabrication/AnimatedQuantityLabel.tsx new file mode 100644 index 00000000000..453740fad68 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/AnimatedQuantityLabel.tsx @@ -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 { + /** + * The inner `` being updated sixty times per second. + */ + ref = createRef(); + + /** + * 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 {this.getText()}; + } +} diff --git a/tgui/packages/tgui/interfaces/Fabrication/CategoryTabs.tsx b/tgui/packages/tgui/interfaces/Fabrication/CategoryTabs.tsx new file mode 100644 index 00000000000..7af7041d1b6 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/CategoryTabs.tsx @@ -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 ( +
+ + +
+ + +
+ + {sortBy((c: Category) => c.displayName || c.key)(categories).map( + (descriptor) => ( + { + currentCategory !== descriptor.key && + setCategory && + setCategory(descriptor.key); + }} + fluid + color="transparent"> + {descriptor.displayName || descriptor.key} + + ) + )} + +
+
+ +
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/Fabrication/DesignCategoryTabs.tsx b/tgui/packages/tgui/interfaces/Fabrication/DesignCategoryTabs.tsx new file mode 100644 index 00000000000..8174532314d --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/DesignCategoryTabs.tsx @@ -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 = { + '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 = {}; + 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 ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/Fabrication/DesignCostList.tsx b/tgui/packages/tgui/interfaces/Fabrication/DesignCostList.tsx new file mode 100644 index 00000000000..edd51fe7d38 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/DesignCostList.tsx @@ -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 ( + + {Object.entries(design.cost).map(([material, cost]) => ( + + available[material] + ? 'bad' + : cost * amount * 2 > available[material] + ? 'average' + : 'normal' + } + /> + + ))} + + ); +}; diff --git a/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx b/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx new file mode 100644 index 00000000000..a81f646b2a5 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx @@ -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 = { + '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 ( + + {sortBy((m: Material) => MINERAL_RARITY[m.name])(availableMaterials).map( + (material) => ( + + + onEjectRequested && onEjectRequested(material, quantity) + } + /> + + ) + )} + + ); +}; + +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 ( +
setHovering(true)} + onMouseLeave={() => setHovering(false)} + className={`MaterialDock ${hovering ? 'MaterialDock--active' : ''}`}> + + onEjectRequested(1)}> + + + + + + + + {hovering && ( +
+ + + + + + +
+ )} +
+
+ ); +}; + +type EjectButtonProps = { + material: Material; + available: number; + amount: number; + onEject: (quantity: number) => void; +}; + +const EjectButton = (props: EjectButtonProps, context) => { + const { amount, available, material, onEject } = props; + + return ( + + ); +}; diff --git a/tgui/packages/tgui/interfaces/Fabrication/SearchBar.tsx b/tgui/packages/tgui/interfaces/Fabrication/SearchBar.tsx new file mode 100644 index 00000000000..c0a2b1558e1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/SearchBar.tsx @@ -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 ( + + + + + + onSearchTextChanged(v)} + value={searchText} + /> + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/Fabrication/Types.ts b/tgui/packages/tgui/interfaces/Fabrication/Types.ts new file mode 100644 index 00000000000..21fed80ccc5 --- /dev/null +++ b/tgui/packages/tgui/interfaces/Fabrication/Types.ts @@ -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>; + +/** + * 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; + + /** + * 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; +}; diff --git a/tgui/packages/tgui/interfaces/Fabricator.tsx b/tgui/packages/tgui/interfaces/Fabricator.tsx index d3fa5daf77f..47152d82303 100644 --- a/tgui/packages/tgui/interfaces/Fabricator.tsx +++ b/tgui/packages/tgui/interfaces/Fabricator.tsx @@ -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>; - -/** - * 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; - - /** - * 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 = { - '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(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 = {}; - 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 ( - + - -
- - { - setSelectedCategory(ALL_CATEGORY); - setSearchText(''); - }} - color="transparent"> - All Designs ({totalRecipes}) - - - {namedCategories.map((categoryName) => ( - { - setSelectedCategory(categoryName); - setSearchText(''); - }} - fluid - color="transparent"> - {categoryName} ({categoryCounts[categoryName]}) - - ))} - -
+ + { + setSelectedCategory(category); + setSearchText(''); + }} + designs={sortedDesigns} + />
- setDisplayMatCost(!displayMatCost)} - checked={displayMatCost}> - Display Material Costs - - }>
-
+
{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) => ( {
- a.name)( + a.name)( data.materials ?? [] )} - onEject={(ref, amount) => act('remove_mat', { ref, amount })} + onEjectRequested={(material, amount) => + act('remove_mat', { ref: material.ref, amount }) + } />
@@ -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 ( - - - - - - setSearchText(v.toLowerCase())} - value={searchText} - /> - - - ); -}; - type MaterialCostProps = { design: Design; amount: number; @@ -320,11 +184,6 @@ const PrintButton = (props: PrintButtonProps, context) => { const { act, data } = useBackend(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 && ( - - ) + } color={'transparent'} onClick={() => act('build', { ref: design.id, amount: quantity })}> - x{quantity} + ×{quantity} ); }; @@ -355,11 +208,6 @@ const Recipe = (props: { design: Design; available: MaterialMap }, context) => { const { act, data } = useBackend(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 && ( - - ) + } onClick={() => act('build', { ref: design.id, amount: 1 })}> {design.name} diff --git a/tgui/packages/tgui/styles/interfaces/Fabricator.scss b/tgui/packages/tgui/styles/interfaces/Fabricator.scss index 651d406b16e..ee1dda0a8d4 100644 --- a/tgui/packages/tgui/styles/interfaces/Fabricator.scss +++ b/tgui/packages/tgui/styles/interfaces/Fabricator.scss @@ -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; +}