Port DmIcon & Image components from TG (#26623)

* Port DmIcon & Image components from TG

* Documentation

* Uh oh

* I hate it

* I will bang you
This commit is contained in:
Aylong
2024-09-24 00:04:37 +03:00
committed by GitHub
parent f830f8c70c
commit 9d968e6ba9
27 changed files with 526 additions and 244 deletions
+1
View File
@@ -64,6 +64,7 @@
#define INIT_ORDER_JOBS 11
#define INIT_ORDER_TICKER 10
#define INIT_ORDER_MAPPING 9
#define INIT_ORDER_EARLY_ASSETS 8
#define INIT_ORDER_ATOMS 7
#define INIT_ORDER_MACHINES 5
#define INIT_ORDER_HOLIDAY 4
@@ -36,4 +36,4 @@ SUBSYSTEM_DEF(assets)
if(initial(asset_to_load._abstract))
continue
get_asset_datum(type)
load_asset_datum(type)
@@ -0,0 +1,18 @@
/// Initializes any assets that need to be loaded ASAP.
SUBSYSTEM_DEF(early_assets)
name = "Early Assets"
init_order = INIT_ORDER_EARLY_ASSETS
flags = SS_NO_FIRE
/datum/controller/subsystem/early_assets/Initialize()
for(var/datum/asset/asset_type as anything in subtypesof(/datum/asset))
if(asset_type::_abstract == asset_type)
continue
if(!asset_type::early)
continue
if(!load_asset_datum(asset_type))
stack_trace("Could not initialize early asset [asset_type]!")
CHECK_TICK
+7 -1
View File
@@ -271,12 +271,18 @@
/obj/item/stack/proc/build_recipe_data(datum/stack_recipe/recipe)
var/list/data = list()
var/obj/result = recipe.result_type
data["uid"] = recipe.UID()
data["required_amount"] = recipe.req_amount
data["result_amount"] = recipe.res_amount
data["max_result_amount"] = recipe.max_res_amount
data["image"] = recipe.image
data["icon"] = result.icon
data["icon_state"] = result.icon_state
// DmIcon cannot paint images. So, if we have grayscale sprite, we need ready base64 image.
if(recipe.result_image)
data["image"] = recipe.result_image
return data
@@ -4,10 +4,10 @@
/datum/stack_recipe
/// Visible title of recipe
var/title = "ERROR"
/// Cached recipe result base64 image
var/image
/// Resulting typepath of crafted atom
var/result_type
/// Generated base64 image. Used only if result has color
var/result_image
/// Required stack amount to make
var/req_amount = 1
/// Amount of atoms made
@@ -52,15 +52,14 @@
src.window_checks = window_checks
src.cult_structure = cult_structure
// We create base64 image only if item have color. Otherwise use icon_ref for TGUI
var/obj/item/result = result_type
var/icon/result_icon = icon(result::icon, result::icon_state, SOUTH, 1)
var/paint = result::color
result_icon.Scale(32, 32)
if(!isnull(paint) && paint != COLOR_WHITE)
var/icon/result_icon = icon(result::icon, result::icon_state, SOUTH, 1)
result_icon.Scale(32, 32)
result_icon.Blend(paint, ICON_MULTIPLY)
src.image = "[icon2base64(result_icon)]"
src.result_image = "[icon2base64(result_icon)]"
/// Returns TRUE if the recipe can be built, otherwise returns FALSE. This proc is only meant as a series of tests to check if construction is possible; the actual creation of the resulting atom should be handled in do_build()
/datum/stack_recipe/proc/try_build(mob/user, obj/item/stack/material, multiplier)
+3 -6
View File
@@ -30,11 +30,6 @@
ui.set_autoupdate(FALSE)
ui.open()
/obj/machinery/prize_counter/ui_assets(mob/user)
return list(
get_asset_datum(/datum/asset/spritesheet/prize_counter)
)
/obj/machinery/prize_counter/ui_data(mob/user)
var/list/data = list()
data["tickets"] = tickets
@@ -45,12 +40,14 @@
var/list/prizes = list()
for(var/datum/prize_item/prize in GLOB.global_prizes.prizes)
var/obj/prize_item = prize.typepath
prizes += list(list(
"name" = initial(prize.name),
"desc" = initial(prize.desc),
"cost" = prize.cost,
"icon" = prize_item.icon,
"icon_state" = prize_item.icon_state,
"itemID" = GLOB.global_prizes.prizes.Find(prize),
"imageID" = replacetext(replacetext("[prize.typepath]", "/obj/item/", ""), "/", "-"),
))
static_data["prizes"] = prizes
+41 -3
View File
@@ -5,18 +5,31 @@
GLOBAL_LIST_EMPTY(asset_datums)
//get an assetdatum or make a new one
/proc/get_asset_datum(type)
//does NOT ensure it's filled, if you want that use get_asset_datum()
/proc/load_asset_datum(type)
return GLOB.asset_datums[type] || new type()
/proc/get_asset_datum(type)
var/datum/asset/loaded_asset = GLOB.asset_datums[type] || new type()
return loaded_asset.ensure_ready()
/datum/asset
var/_abstract = /datum/asset
var/cached_serialized_url_mappings
var/cached_serialized_url_mappings_transport_type
/// Whether or not this asset should be loaded in the "early assets" SS
var/early = FALSE
/datum/asset/New()
GLOB.asset_datums[type] = src
register()
/// Stub that allows us to react to something trying to get us
/// Not useful here, more handy for sprite sheets
/datum/asset/proc/ensure_ready()
return src
/datum/asset/proc/get_url_mappings()
return list()
@@ -26,7 +39,6 @@ GLOBAL_LIST_EMPTY(asset_datums)
/datum/asset/proc/send(client)
return
/// If you don't need anything complicated.
/datum/asset/simple
_abstract = /datum/asset/simple
@@ -68,7 +80,7 @@ GLOBAL_LIST_EMPTY(asset_datums)
/datum/asset/group/register()
for(var/type in children)
get_asset_datum(type)
load_asset_datum(type)
/datum/asset/group/send(client/C)
for(var/type in children)
@@ -370,6 +382,32 @@ GLOBAL_LIST_EMPTY(asset_datums)
assets = sorted_assets
..()
/// A subtype to generate a JSON file from a list
/datum/asset/json
_abstract = /datum/asset/json
/// The filename, will be suffixed with ".json"
var/name
/datum/asset/json/send(client)
return SSassets.transport.send_assets(client, "[name].json")
/datum/asset/json/get_url_mappings()
return list(
"[name].json" = SSassets.transport.get_asset_url("[name].json"),
)
/datum/asset/json/register()
var/filename = "data/[name].json"
fdel(filename)
text2file(json_encode(generate()), filename)
SSassets.transport.register_asset("[name].json", fcopy_rsc(filename))
fdel(filename)
/// Returns the data that will be JSON encoded
/datum/asset/json/proc/generate()
SHOULD_CALL_PARENT(FALSE)
CRASH("generate() not implemented for [type]!")
/*
* Get a html string that will load a html asset.
* Needed because byond doesn't allow you to browse() to a url.
@@ -0,0 +1,28 @@
/// Maps icon names to ref values
/datum/asset/json/icon_ref_map
name = "icon_ref_map"
early = TRUE
/datum/asset/json/icon_ref_map/generate()
var/list/data = list() //"icons/obj/drinks.dmi" => "[0xc000020]"
//var/start = "0xc000000"
var/value = 0
while(TRUE)
value += 1
var/ref = "\[0xc[num2text(value,6,16)]\]"
var/mystery_meat = locate(ref)
if(isicon(mystery_meat))
if(!isfile(mystery_meat)) // Ignore the runtime icons for now
continue
var/path = get_icon_dmi_path(mystery_meat) //Try to get the icon path
if(path)
data[path] = ref
else if(mystery_meat)
continue; //Some other non-icon resource, ogg/json/whatever
else //Out of resources end this, could also try to end this earlier as soon as runtime generated icons appear but eh
break;
return data
@@ -1,13 +0,0 @@
/datum/asset/spritesheet/prize_counter
name = "prize_counter"
/datum/asset/spritesheet/prize_counter/create_spritesheets()
for(var/datum/prize_item/prize in GLOB.global_prizes.prizes)
var/obj/item/prize_item = prize.typepath
var/prize_icon = icon(icon = initial(prize_item.icon), icon_state = initial(prize_item.icon_state))
var/imgid = replacetext(replacetext("[prize_item]", "/obj/item/", ""), "/", "-")
Insert(imgid, prize_icon)
/datum/asset/spritesheet/prize_counter/ModifyInserted(icon/pre_asset)
pre_asset.Scale(64, 64)
return pre_asset
+2
View File
@@ -103,6 +103,8 @@
/datum/tgui/proc/send_assets()
var/flushqueue = window.send_asset(get_asset_datum(
/datum/asset/simple/namespaced/fontawesome))
flushqueue |= window.send_asset(get_asset_datum(
/datum/asset/json/icon_ref_map))
for(var/datum/asset/asset in src_object.ui_assets(user))
flushqueue |= window.send_asset(asset)
if(flushqueue)
+2 -1
View File
@@ -343,6 +343,7 @@
#include "code\controllers\subsystem\non_firing\SSassets.dm"
#include "code\controllers\subsystem\non_firing\SSatoms.dm"
#include "code\controllers\subsystem\non_firing\SSchangelog.dm"
#include "code\controllers\subsystem\non_firing\SSearly_assets.dm"
#include "code\controllers\subsystem\non_firing\SSholiday.dm"
#include "code\controllers\subsystem\non_firing\SSlate_mapping.dm"
#include "code\controllers\subsystem\non_firing\SSmapping.dm"
@@ -1569,13 +1570,13 @@
#include "code\modules\asset_cache\assets\asset_cloning.dm"
#include "code\modules\asset_cache\assets\asset_common.dm"
#include "code\modules\asset_cache\assets\asset_emoji.dm"
#include "code\modules\asset_cache\assets\asset_icon_ref_map.dm"
#include "code\modules\asset_cache\assets\asset_jquery.dm"
#include "code\modules\asset_cache\assets\asset_materials.dm"
#include "code\modules\asset_cache\assets\asset_nanomap.dm"
#include "code\modules\asset_cache\assets\asset_orbit_icons.dm"
#include "code\modules\asset_cache\assets\asset_panels.dm"
#include "code\modules\asset_cache\assets\asset_paper.dm"
#include "code\modules\asset_cache\assets\asset_prize_counter.dm"
#include "code\modules\asset_cache\assets\asset_rpd.dm"
#include "code\modules\asset_cache\assets\asset_safe.dm"
#include "code\modules\asset_cache\assets\asset_strip.dm"
+4
View File
@@ -570,6 +570,10 @@ A Robust button is specifically for sticking a picture in it.
- `disabled: boolean` - Makes button disabled and dark red if true.
Also disables onClick & onRightClick.
- `selected: boolean` - Makes button selected and green if true.
- `dmFallback: any` - Optional. Adds a "stub" when loading DmIcon.
- `dmIcon: string` - Parameter `icon` of component `DmIcon`.
- `dmIconState: string` - Parameter `icon_state` of component `DmIcon`.
For proper work of `DmIcon` it is necessary that both parameters are filled in!
- `fluid: boolean` - Changes the layout of the button, making it fill the entire horizontally available space.
Allows the use of `title`
- `imageSize: number` - Parameter responsible for the size of the image, component and standard "stubs".
+6 -1
View File
@@ -26,7 +26,7 @@ namespace JSX {
marquee: any;
blink: any;
}
Element
Element;
}
type TguiMessage = {
@@ -169,6 +169,11 @@ type ByondType = {
* Loads a script into the document.
*/
loadJs(url: string): void;
/**
* Maps icons to their ref
*/
iconRefMap: Record<string, string>;
};
/**
+88
View File
@@ -0,0 +1,88 @@
import { Component, InfernoNode } from 'inferno';
import { resolveAsset } from '../assets';
import { fetchRetry } from '../http';
import { BoxProps } from './Box';
import { Image } from './Image';
enum Direction {
NORTH = 1,
SOUTH = 2,
EAST = 4,
WEST = 8,
NORTHEAST = NORTH | EAST,
NORTHWEST = NORTH | WEST,
SOUTHEAST = SOUTH | EAST,
SOUTHWEST = SOUTH | WEST,
}
type Props = {
/** Required: The path of the icon */
icon: string;
/** Required: The state of the icon */
icon_state: string;
} & Partial<{
/** Facing direction. See direction enum. Default is South */
direction: Direction;
/** Fallback icon. */
fallback: InfernoNode;
/** Frame number. Default is 1 */
frame: number;
/** Movement state. Default is false */
movement: any;
}> &
BoxProps;
let refMap: Record<string, string> | undefined;
export class DmIcon extends Component<Props, { iconRef: string }> {
constructor(props: Props) {
super(props);
this.state = {
iconRef: '',
};
}
async fetchRefMap() {
const response = await fetchRetry(resolveAsset('icon_ref_map.json'));
const data = await response.json();
refMap = data;
this.setState({ iconRef: data[this.props.icon] });
}
componentDidMount() {
if (!refMap) {
this.fetchRefMap();
} else {
this.setState({ iconRef: refMap[this.props.icon] });
}
}
componentDidUpdate(prevProps: Props) {
if (prevProps.icon !== this.props.icon) {
if (refMap) {
this.setState({ iconRef: refMap[this.props.icon] });
} else {
this.fetchRefMap();
}
}
}
render() {
const {
className,
direction = Direction.SOUTH,
fallback,
frame = 1,
icon_state,
movement = false,
...rest
} = this.props;
const { iconRef } = this.state;
const query = `${iconRef}?state=${icon_state}&dir=${direction}&movement=${!!movement}&frame=${frame}`;
if (!iconRef) return fallback || null;
return <Image fixErrors src={query} {...rest} />;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Component } from 'inferno';
import { BoxProps, computeBoxProps } from './Box';
type Props = Partial<{
/** True is default, this fixes an ie thing */
fixBlur: boolean;
/** False by default. Good if you're fetching images on UIs that do not auto update. This will attempt to fix the 'x' icon 5 times. */
fixErrors: boolean;
/** Fill is default. */
objectFit: 'contain' | 'cover';
}> &
IconUnion &
BoxProps;
// at least one of these is required
type IconUnion =
| {
className?: string;
src: string;
}
| {
className: string;
src?: string;
};
const maxAttempts = 5;
/** Image component. Use this instead of Box as="img". */
export class Image extends Component<Props> {
attempts: number = 0;
handleError = (event) => {
const { fixErrors, src } = this.props;
if (fixErrors && this.attempts < maxAttempts) {
const imgElement = event.currentTarget;
setTimeout(() => {
imgElement.src = `${src}?attempt=${this.attempts}`;
this.attempts++;
}, 1000);
}
};
render() {
const { fixBlur = true, fixErrors = false, objectFit = 'fill', src, ...rest } = this.props;
/* Remove -ms-interpolation-mode with Byond 516. -webkit-optimize-contrast is better than pixelated */
const computedProps = computeBoxProps({
style: {
'-ms-interpolation-mode': `${fixBlur ? 'nearest-neighbor' : 'auto'}`,
'image-rendering': `${fixBlur ? '-webkit-optimize-contrast' : 'auto'}`,
'object-fit': `${objectFit}`,
},
...rest,
});
/* Use div instead img if used asset, cause img with class leaves white border on 516 */
if (computedProps.className) {
return <div onError={this.handleError} {...computedProps} />;
}
return <img onError={this.handleError} src={src} {...computedProps} />;
}
}
+22 -5
View File
@@ -10,6 +10,8 @@ import { InfernoNode } from 'inferno';
import { BooleanLike, classes } from 'common/react';
import { BoxProps, computeBoxProps } from './Box';
import { Icon } from './Icon';
import { Image } from './Image';
import { DmIcon } from './DmIcon';
import { Stack } from './Stack';
import { Tooltip } from './Tooltip';
@@ -35,6 +37,12 @@ type Props = Partial<{
color: string;
/** Makes button disabled and dark red if true. Also disables onClick. */
disabled: BooleanLike;
/** Optional. Adds a "stub" when loading DmIcon. */
dmFallback: InfernoNode;
/** Parameter `icon` of component `DmIcon`. */
dmIcon: string | null;
/** Parameter `icon_state` of component `DmIcon`. */
dmIconState: string | null;
/**
* Changes the layout of the button, making it fill the entire horizontally available space.
* Allows the use of `title`
@@ -69,6 +77,9 @@ export const ImageButton = (props: Props) => {
className,
color,
disabled,
dmFallback,
dmIcon,
dmIconState,
fluid,
imageSize = 64,
imageSrc,
@@ -116,15 +127,21 @@ export const ImageButton = (props: Props) => {
style={{ width: !fluid ? `calc(${imageSize}px + 0.5em + 2px)` : 'auto' }}
>
<div className={classes(['image'])}>
{(base64 || imageSrc) && !asset ? (
<img
{base64 || asset || imageSrc ? (
<Image
className={classes((!base64 && !imageSrc && asset) || [])}
src={base64 ? `data:image/jpeg;base64,${base64}` : imageSrc}
height={`${imageSize}px`}
width={`${imageSize}px`}
/>
) : asset ? (
/* Not a <img> cause assets made some shit with it on Byond 516 */
<div className={classes(asset)} />
) : dmIcon && dmIconState ? (
<DmIcon
icon={dmIcon}
icon_state={dmIconState}
fallback={dmFallback ? dmFallback : getFallback('spinner', true)}
height={`${imageSize}px`}
width={`${imageSize}px`}
/>
) : (
getFallback('question', false)
)}
+2
View File
@@ -21,6 +21,8 @@ export { DraggableControl } from './DraggableControl';
export { Dropdown } from './Dropdown';
export { Flex } from './Flex';
export { Grid } from './Grid';
export { Image } from './Image';
export { DmIcon } from './DmIcon';
export { Icon } from './Icon';
export { ImageButton } from './ImageButton';
export { Input } from './Input';
+12
View File
@@ -0,0 +1,12 @@
/**
* An equivalent to `fetch`, except will automatically retry.
*/
export const fetchRetry = (url: string, options?: RequestInit, retryTimer: number = 1000): Promise<Response> => {
return fetch(url, options).catch(() => {
return new Promise((resolve) => {
setTimeout(() => {
fetchRetry(url, options, retryTimer).then(resolve);
}, retryTimer);
});
});
};
+14
View File
@@ -0,0 +1,14 @@
import { resolveAsset } from './assets';
import { fetchRetry } from './http';
import { logger } from './logging';
export const loadIconRefMap = function () {
if (Object.keys(Byond.iconRefMap).length > 0) {
return;
}
fetchRetry(resolveAsset('icon_ref_map.json'))
.then((res) => res.json())
.then((data) => (Byond.iconRefMap = data))
.catch((error) => logger.log(error));
};
+3
View File
@@ -25,6 +25,7 @@ import './styles/themes/noticeboard.scss';
import { perf } from 'common/perf';
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
import { setupHotKeys } from './hotkeys';
import { loadIconRefMap } from './icons';
import { captureExternalLinks } from './links';
import { createRenderer } from './renderer';
import { configureStore, StoreProvider } from './store';
@@ -36,6 +37,8 @@ perf.mark('init');
const store = configureStore();
const renderApp = createRenderer(() => {
loadIconRefMap();
const { getRoutedComponent } = require('./routes');
const Component = getRoutedComponent(store);
return (
+7 -11
View File
@@ -1,4 +1,3 @@
import { classes } from 'common/react';
import { useBackend, useLocalState } from '../backend';
import { Button, Section, Stack, ImageButton, Input, Icon } from '../components';
import { Window } from '../layouts';
@@ -6,9 +5,10 @@ import { Window } from '../layouts';
type Prize = {
name: string;
desc: string;
icon: string;
icon_state: string;
cost: number;
itemID: number;
imageID: string;
};
type PrizeData = {
@@ -45,14 +45,9 @@ export const PrizeCounter = (props, context) => {
</Stack.Item>
)}
<Stack.Item>
<Button
fluid
iconRight
icon="ticket"
disabled={!tickets}
content={<>Tickets: {<b>{tickets}</b>}</>}
onClick={() => act('eject')}
/>
<Button fluid iconRight icon="ticket" disabled={!tickets} onClick={() => act('eject')}>
Tickets: <b>{tickets}</b>
</Button>
</Stack.Item>
<Stack.Item>
<Button
@@ -72,8 +67,9 @@ export const PrizeCounter = (props, context) => {
<ImageButton
fluid
key={prize.name}
asset={['prize_counter64x64', prize.imageID]}
title={prize.name}
dmIcon={prize.icon}
dmIconState={prize.icon_state}
buttonsAlt
buttons={
<Button
+4 -2
View File
@@ -200,7 +200,7 @@ const RecipeBox = (props, context) => {
const { act, data } = useBackend(context);
const { amount } = data;
const { title, recipe } = props;
const { result_amount, required_amount, max_result_amount, uid, image } = recipe;
const { result_amount, required_amount, max_result_amount, uid, icon, icon_state, image } = recipe;
const resAmountLabel = result_amount > 1 ? `${result_amount}x ` : '';
const sheetSuffix = required_amount > 1 ? 's' : '';
@@ -212,7 +212,9 @@ const RecipeBox = (props, context) => {
return (
<ImageButton
fluid
base64={image}
base64={image} /* Use base64 image if we have it. DmIcon cannot paint grayscale images yet */
dmIcon={icon}
dmIconState={icon_state}
imageSize={32}
disabled={!max_possible_multiplier}
tooltip={tooltipContent}
@@ -125,11 +125,6 @@ $bg-map: colors.$bg-map !default;
line-height: 0;
padding: 0.25em;
border-radius: 0.33em;
* {
image-rendering: pixelated;
-ms-interpolation-mode: nearest-neighbor; // Remove with 516
}
}
.buttonsContainer {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -369,6 +369,9 @@
Byond.loadCss = function (url, sync) {
loadAsset({ url: url, sync: sync, type: 'css' });
};
// Icon cache
Byond.iconRefMap = {};
})();
// Error handling