[MIRROR] Switch circuits to a grid visual coding system (#9173)

Co-authored-by: Heroman3003 <31296024+Heroman3003@users.noreply.github.com>
Co-authored-by: CHOMPStation2 <chompsation2@gmail.com>
This commit is contained in:
CHOMPStation2
2024-10-06 07:18:41 -07:00
committed by GitHub
parent 14f0302bef
commit c0228d937b
15 changed files with 1379 additions and 239 deletions

View File

@@ -0,0 +1,4 @@
/datum/asset/simple/circuit_assets
assets = list(
"grid_background.png" = 'icons/UI_Icons/tgui/grid_background.png',
)

View File

@@ -60,6 +60,11 @@
ui = new(user, src, "ICAssembly", name, parent_ui)
ui.open()
/obj/item/electronic_assembly/ui_assets(mob/user)
return list(
get_asset_datum(/datum/asset/simple/circuit_assets)
)
/obj/item/electronic_assembly/tgui_data(mob/user, datum/tgui/ui, datum/tgui_state/state)
var/list/data = ..()
@@ -78,19 +83,10 @@
data["battery_max"] = round(battery?.maxcharge, 0.1)
data["net_power"] = net_power / CELLRATE
// This works because lists are always passed by reference in BYOND, so modifying unremovable_circuits
// after setting data["unremovable_circuits"] = unremovable_circuits also modifies data["unremovable_circuits"]
// Same for the removable one
var/list/unremovable_circuits = list()
data["unremovable_circuits"] = unremovable_circuits
var/list/removable_circuits = list()
data["removable_circuits"] = removable_circuits
var/list/circuits = list()
for(var/obj/item/integrated_circuit/circuit in contents)
var/list/target = circuit.removable ? removable_circuits : unremovable_circuits
target.Add(list(list(
"name" = circuit.displayed_name,
"ref" = REF(circuit),
)))
UNTYPED_LIST_ADD(circuits, circuit.tgui_data(user, ui, state))
data["circuits"] = circuits
return data
@@ -98,8 +94,6 @@
if(..())
return TRUE
var/obj/held_item = usr.get_active_hand()
switch(action)
// Actual assembly actions
if("rename")
@@ -118,6 +112,48 @@
return TRUE
// Circuit actions
if("wire_internal")
var/datum/integrated_io/pin1 = locate(params["pin1"])
if(!istype(pin1))
return
var/datum/integrated_io/pin2 = locate(params["pin2"])
if(!istype(pin2))
return
var/obj/item/integrated_circuit/holder1 = pin1.holder
if(!istype(holder1) || holder1.loc != src || holder1.assembly != src)
return
var/obj/item/integrated_circuit/holder2 = pin2.holder
if(!istype(holder2) || holder2.loc != src || holder2.assembly != src)
return
// Wiring the same pin will unwire it
if(pin2 in pin1.linked)
pin1.linked -= pin2
pin2.linked -= pin1
else
pin1.linked |= pin2
pin2.linked |= pin1
return TRUE
if("remove_all_wires")
var/datum/integrated_io/pin1 = locate(params["pin"])
if(!istype(pin1))
return
var/obj/item/integrated_circuit/holder1 = pin1.holder
if(!istype(holder1) || holder1.loc != src || holder1.assembly != src)
return
for(var/datum/integrated_io/other as anything in pin1.linked)
other.linked -= pin1
pin1.linked.Cut()
return TRUE
if("open_circuit")
var/obj/item/integrated_circuit/C = locate(params["ref"]) in contents
if(!istype(C))
@@ -125,27 +161,6 @@
C.tgui_interact(usr, null, ui)
return TRUE
if("rename_circuit")
var/obj/item/integrated_circuit/C = locate(params["ref"]) in contents
if(!istype(C))
return
C.rename_component(usr)
return TRUE
if("scan_circuit")
var/obj/item/integrated_circuit/C = locate(params["ref"]) in contents
if(!istype(C))
return
if(istype(held_item, /obj/item/integrated_electronics/debugger))
var/obj/item/integrated_electronics/debugger/D = held_item
if(D.accepting_refs)
D.afterattack(C, usr, TRUE)
else
to_chat(usr, span_warning("The Debugger's 'ref scanner' needs to be on."))
else
to_chat(usr, span_warning("You need a multitool/debugger set to 'ref' mode to do that."))
return TRUE
if("remove_circuit")
var/obj/item/integrated_circuit/C = locate(params["ref"]) in contents
if(!istype(C))
@@ -153,14 +168,6 @@
C.remove(usr)
return TRUE
if("bottom_circuit")
var/obj/item/integrated_circuit/C = locate(params["ref"]) in contents
if(!istype(C))
return
// Puts it at the bottom of our contents
// Note, this intentionally does *not* use forceMove, because forceMove will stop if it detects the same loc
C.loc = null
C.loc = src
return FALSE
// End TGUI

View File

@@ -92,6 +92,7 @@ a creative player the means to solve many problems. Circuits are held inside an
data["name"] = name
data["desc"] = desc
data["ref"] = REF(src)
data["displayed_name"] = displayed_name
data["removable"] = removable
@@ -110,21 +111,7 @@ a creative player the means to solve many problems. Circuits are held inside an
outputs_list.Add(list(tgui_pin_data(io)))
for(var/datum/integrated_io/io in activators)
var/list/list/activator = list(
"ref" = REF(io),
"name" = io.name,
"pulse_out" = io.data,
"linked" = list()
)
for(var/datum/integrated_io/linked in io.linked)
activator["linked"].Add(list(list(
"ref" = REF(linked),
"name" = linked.name,
"holder_ref" = REF(linked.holder),
"holder_name" = linked.holder.displayed_name,
)))
activators_list.Add(list(activator))
activators_list.Add(list(tgui_pin_data(io)))
data["inputs"] = inputs_list
data["outputs"] = outputs_list
@@ -139,6 +126,7 @@ a creative player the means to solve many problems. Circuits are held inside an
pindata["type"] = io.display_pin_type()
pindata["name"] = io.name
pindata["data"] = io.display_data(io.data)
pindata["rawdata"] = io.data
pindata["ref"] = REF(io)
var/list/linked_list = list()
for(var/datum/integrated_io/linked in io.linked)

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,222 @@
// TODO: Replace when tgui-core is fixed https://github.com/tgstation/tgui-core/issues/25
import { round } from 'common/math';
import { Component, PropsWithChildren } from 'react';
import { Button, ProgressBar, Stack } from 'tgui-core/components';
import { BoxProps, computeBoxProps } from './Box';
const ZOOM_MIN_VAL = 0.5;
const ZOOM_MAX_VAL = 1.5;
const ZOOM_INCREMENT = 0.1;
export type InfinitePlaneProps = PropsWithChildren<
{
onZoomChange?: (newZoomValue: number) => void;
onBackgroundMoved?: (newX: number, newY: number) => void;
initialLeft?: number;
initialTop?: number;
backgroundImage?: string;
imageWidth: number;
} & BoxProps
>;
type InfinitePlaneState = {
mouseDown: boolean;
left: number;
top: number;
lastLeft: number;
lastTop: number;
zoom: number;
};
export type MouseEventExtension = {
screenZoomX: number;
screenZoomY: number;
};
export class InfinitePlane extends Component<
InfinitePlaneProps,
InfinitePlaneState
> {
constructor(props: InfinitePlaneProps) {
super(props);
this.state = {
mouseDown: false,
left: 0,
top: 0,
lastLeft: 0,
lastTop: 0,
zoom: 1,
};
}
componentDidMount() {
window.addEventListener('mouseup', this.onMouseUp);
window.addEventListener('mousedown', this.doOffsetMouse);
window.addEventListener('mousemove', this.doOffsetMouse);
window.addEventListener('mouseup', this.doOffsetMouse);
}
componentWillUnmount() {
window.removeEventListener('mouseup', this.onMouseUp);
window.removeEventListener('mousedown', this.doOffsetMouse);
window.removeEventListener('mousemove', this.doOffsetMouse);
window.removeEventListener('mouseup', this.doOffsetMouse);
}
// This is really, REALLY cursed and basically overrides a built-in browser event via propagation rules
doOffsetMouse = (event: MouseEvent & MouseEventExtension) => {
const { zoom } = this.state;
event.screenZoomX = event.screenX * Math.pow(zoom, -1);
event.screenZoomY = event.screenY * Math.pow(zoom, -1);
};
handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
this.setState((state) => {
return {
mouseDown: true,
lastLeft: event.clientX - state.left,
lastTop: event.clientY - state.top,
};
});
};
onMouseUp = () => {
this.setState({
mouseDown: false,
});
};
handleZoomIncrease = (event: any) => {
const { onZoomChange } = this.props;
const { zoom } = this.state;
const newZoomValue = round(
Math.min(zoom + ZOOM_INCREMENT, ZOOM_MAX_VAL),
1,
);
this.setState({
zoom: newZoomValue,
});
if (onZoomChange) {
onZoomChange(newZoomValue);
}
};
handleZoomDecrease = (event: any) => {
const { onZoomChange } = this.props;
const { zoom } = this.state;
const newZoomValue = round(
Math.max(zoom - ZOOM_INCREMENT, ZOOM_MIN_VAL),
1,
);
this.setState({
zoom: newZoomValue,
});
if (onZoomChange) {
onZoomChange(newZoomValue);
}
};
handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
const { onBackgroundMoved, initialLeft = 0, initialTop = 0 } = this.props;
if (this.state.mouseDown) {
let newX, newY;
this.setState((state) => {
newX = event.clientX - state.lastLeft;
newY = event.clientY - state.lastTop;
if (onBackgroundMoved) {
onBackgroundMoved(newX + initialLeft, newY + initialTop);
}
return {
left: newX,
top: newY,
};
});
}
};
render() {
const {
children,
backgroundImage,
imageWidth,
initialLeft = 0,
initialTop = 0,
...rest
} = this.props;
const { left, top, zoom } = this.state;
const finalLeft = initialLeft + left;
const finalTop = initialTop + top;
return (
<div
{...computeBoxProps({
...rest,
style: {
...rest.style,
overflow: 'hidden',
position: 'relative',
},
})}
>
<div
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
style={{
position: 'fixed',
height: '100%',
width: '100%',
backgroundImage: `url("${backgroundImage}")`,
backgroundPosition: `${finalLeft}px ${finalTop}px`,
backgroundRepeat: 'repeat',
backgroundSize: `${zoom * imageWidth}px`,
}}
/>
<div
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
style={{
position: 'fixed',
transform: `translate(${finalLeft}px, ${finalTop}px) scale(${zoom})`,
transformOrigin: 'top left',
height: '100%',
width: '100%',
}}
>
{children}
</div>
<Stack position="absolute" width="100%">
<Stack.Item>
<Button icon="minus" onClick={this.handleZoomDecrease} />
</Stack.Item>
<Stack.Item grow={1}>
<ProgressBar
minValue={ZOOM_MIN_VAL}
value={zoom}
maxValue={ZOOM_MAX_VAL}
>
{zoom}x
</ProgressBar>
</Stack.Item>
<Stack.Item>
<Button icon="plus" onClick={this.handleZoomIncrease} />
</Stack.Item>
</Stack>
</div>
);
}
}

View File

@@ -19,6 +19,7 @@ export { Dropdown } from './Dropdown';
export { Flex } from './Flex';
export { Icon } from './Icon';
export { Image } from './Image';
export { InfinitePlane } from './InfinitePlane';
export { Input } from './Input';
export { Knob } from './Knob';
export { LabeledControls } from './LabeledControls';

View File

@@ -1,179 +0,0 @@
import { toFixed } from 'common/math';
import { useBackend } from '../backend';
import {
AnimatedNumber,
Box,
Button,
LabeledList,
ProgressBar,
Section,
} from '../components';
import { formatPower } from '../format';
import { Window } from '../layouts';
type Data = {
total_parts: number;
max_components: number;
total_complexity: number;
max_complexity: number;
battery_charge: number;
battery_max: number;
net_power: number;
unremovable_circuits: circuit[];
removable_circuits: circuit[];
};
type circuit = { name: string; ref: string };
export const ICAssembly = (props) => {
const { data } = useBackend<Data>();
const {
total_parts,
max_components,
total_complexity,
max_complexity,
battery_charge,
battery_max,
net_power,
unremovable_circuits,
removable_circuits,
} = data;
return (
<Window width={600} height={380}>
<Window.Content scrollable>
<Section title="Status">
<LabeledList>
<LabeledList.Item label="Space in Assembly">
<ProgressBar
ranges={{
good: [0, 0.25],
average: [0.5, 0.75],
bad: [0.75, 1],
}}
value={total_parts / max_components}
maxValue={1}
>
{total_parts +
' / ' +
max_components +
' (' +
toFixed((total_parts / max_components) * 100, 1) +
'%)'}
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Complexity">
<ProgressBar
ranges={{
good: [0, 0.25],
average: [0.5, 0.75],
bad: [0.75, 1],
}}
value={total_complexity / max_complexity}
maxValue={1}
>
{total_complexity +
' / ' +
max_complexity +
' (' +
toFixed((total_complexity / max_complexity) * 100, 1) +
'%)'}
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Cell Charge">
{(battery_charge && (
<ProgressBar
ranges={{
bad: [0, 0.25],
average: [0.5, 0.75],
good: [0.75, 1],
}}
value={battery_charge / battery_max}
maxValue={1}
>
{battery_charge +
' / ' +
battery_max +
' (' +
toFixed((battery_charge / battery_max) * 100, 1) +
'%)'}
</ProgressBar>
)) || <Box color="bad">No cell detected.</Box>}
</LabeledList.Item>
<LabeledList.Item label="Net Energy">
{(net_power === 0 && '0 W/s') || (
<AnimatedNumber
value={net_power}
format={(val) => '-' + formatPower(Math.abs(val)) + '/s'}
/>
)}
</LabeledList.Item>
</LabeledList>
</Section>
{(unremovable_circuits.length && (
<ICAssemblyCircuits
title="Built-in Components"
circuits={unremovable_circuits}
/>
)) ||
null}
{(removable_circuits.length && (
<ICAssemblyCircuits
title="Removable Components"
circuits={removable_circuits}
/>
)) ||
null}
</Window.Content>
</Window>
);
};
const ICAssemblyCircuits = (props: { title: string; circuits: circuit[] }) => {
const { act } = useBackend();
const { title, circuits } = props;
return (
<Section title={title}>
<LabeledList>
{circuits.map((circuit) => (
<LabeledList.Item key={circuit.ref} label={circuit.name}>
<Button
icon="eye"
onClick={() => act('open_circuit', { ref: circuit.ref })}
>
View
</Button>
<Button
icon="eye"
onClick={() => act('rename_circuit', { ref: circuit.ref })}
>
Rename
</Button>
<Button
icon="eye"
onClick={() => act('scan_circuit', { ref: circuit.ref })}
>
Debugger Scan
</Button>
<Button
icon="eye"
onClick={() => act('remove_circuit', { ref: circuit.ref })}
>
Remove
</Button>
<Button
icon="eye"
onClick={() => act('bottom_circuit', { ref: circuit.ref })}
>
Move to Bottom
</Button>
</LabeledList.Item>
))}
</LabeledList>
</Section>
);
};

View File

@@ -0,0 +1,318 @@
import { decodeHtmlEntities } from 'common/string';
import { Component } from 'react';
import { useBackend } from 'tgui/backend';
import { Box } from 'tgui/components';
import { BoxProps } from 'tgui/components/Box';
import { Button, Icon, Stack } from 'tgui-core/components';
import { shallowDiffers } from 'tgui-core/react';
import { Port, PortProps } from './Port';
import { CircuitData, PortTypesToColor as PORT_TYPES_TO_COLOR } from './types';
export type CircuitProps = {
x: number;
y: number;
circuit: CircuitData;
color?: string;
gridMode?: boolean;
onComponentMoved?: (newPos: { x: number; y: number }) => void;
} & BoxProps &
Pick<
PortProps,
| 'onPortUpdated'
| 'onPortLoaded'
| 'onPortMouseDown'
| 'onPortMouseUp'
| 'onPortRightClick'
>;
export type CircuitState = {
lastMousePos: { x: number; y: number } | null;
isDragging: boolean;
dragPos: { x: number; y: number } | null;
startPos: { x: number; y: number } | null;
};
// This has to be a class component to manage window state unfortunately
export class CircuitComponent extends Component<CircuitProps, CircuitState> {
constructor(props: CircuitProps) {
super(props);
this.state = {
isDragging: false,
dragPos: null,
startPos: null,
lastMousePos: null,
};
}
// THIS IS IMPORTANT:
// This reduces the amount of unnecessary updates, which reduces the amount of work that has to be done
// by the `Plane` component to keep track of where ports are located.
shouldComponentUpdate = (nextProps, nextState) => {
const { inputs, outputs, activators } = this.props.circuit;
return (
shallowDiffers(this.props, nextProps) ||
shallowDiffers(this.state, nextState) ||
shallowDiffers(inputs, nextProps.inputs) ||
shallowDiffers(outputs, nextProps.outputs) ||
shallowDiffers(activators, nextProps.activators)
);
};
handleStartDrag = (e: React.MouseEvent<HTMLDivElement>) => {
const { x, y } = this.props;
e.stopPropagation();
this.setState({
lastMousePos: null,
isDragging: true,
dragPos: { x, y },
startPos: { x, y },
});
window.addEventListener('mousemove', this.handleDrag);
window.addEventListener('mouseup', this.handleStopDrag);
};
handleStopDrag = (e: React.MouseEvent<HTMLDivElement> | MouseEvent) => {
const { onComponentMoved } = this.props;
const { dragPos } = this.state;
if (dragPos && onComponentMoved) {
onComponentMoved({
x: this.roundToGrid(dragPos.x),
y: this.roundToGrid(dragPos.y),
});
}
window.removeEventListener('mousemove', this.handleDrag);
window.removeEventListener('mouseup', this.handleStopDrag);
this.setState({ isDragging: false });
};
handleDrag = (e: any) => {
const { dragPos, isDragging, lastMousePos } = this.state;
if (!dragPos || !isDragging) {
return;
}
e.preventDefault();
const { screenZoomX, screenZoomY, screenX, screenY } = e;
let xPos = screenZoomX || screenX;
let yPos = screenZoomY || screenY;
if (lastMousePos) {
this.setState({
dragPos: {
x: dragPos.x - (lastMousePos.x - xPos),
y: dragPos.y - (lastMousePos.y - yPos),
},
});
}
this.setState({
lastMousePos: { x: xPos, y: yPos },
});
};
// Round the units to the grid (bypass if grid mode is off)
roundToGrid = (input_value) => {
if (!this.props.gridMode) return input_value;
return Math.round(input_value / 10) * 10;
};
render() {
const {
x,
y,
circuit,
color = 'blue',
onPortUpdated,
onPortLoaded,
onPortMouseDown,
onPortMouseUp,
onPortRightClick,
...rest
} = this.props;
const { name, ref, inputs, outputs, activators } = circuit;
const { startPos, dragPos } = this.state;
const { act } = useBackend();
let [x_pos, y_pos] = [x, y];
if (dragPos && startPos && startPos.x === x_pos && startPos.y === y_pos) {
x_pos = this.roundToGrid(dragPos.x);
y_pos = this.roundToGrid(dragPos.y);
}
return (
<Box
position="absolute"
left={x_pos + 'px'}
top={y_pos + 'px'}
onMouseDown={this.handleStartDrag}
onMouseUp={this.handleStopDrag}
{...rest}
>
<Box
backgroundColor={color}
py={1}
px={1}
className="ObjectComponent__Titlebar"
>
<Stack align="center" justify="space-between">
<Stack.Item>{name}</Stack.Item>
<Stack.Item>
<Button
icon="external-link-alt"
color="transparent"
compact
tooltip="View Component UI"
tooltipPosition="bottom-end"
onClick={() => act('open_circuit', { ref })}
/>
<Button
icon="info"
color="transparent"
compact
tooltip={
<Box>
<Box mb={1}>{circuit.extended_desc}</Box>
{!!circuit.power_draw_idle && (
<Box
backgroundColor="orange"
style={{ borderRadius: '4px' }}
px={1}
>
<Icon name="bolt" mr={1} />
Power Draw (Passive): {circuit.power_draw_idle}
</Box>
)}
{!!circuit.power_draw_per_use && (
<Box
backgroundColor="orange"
style={{ borderRadius: '4px' }}
px={1}
>
<Icon name="bolt" mr={1} />
Power Draw (Active): {circuit.power_draw_per_use}
</Box>
)}
</Box>
}
tooltipPosition="bottom-end"
/>
{!!circuit.removable && (
<Button
icon="times"
color="transparent"
compact
tooltip="Remove Circuit"
tooltipPosition="bottom-end"
onClick={() => act('remove_circuit', { ref })}
/>
)}
</Stack.Item>
</Stack>
</Box>
<Box className="ObjectComponent__Content" p={1}>
<Stack vertical>
<Stack.Item>
<Stack align="flex-start" justify="space-between">
<Stack.Item>
<Stack vertical fill>
{inputs.map((port) => (
<Stack.Item key={port.ref}>
<Port
color={
PORT_TYPES_TO_COLOR[decodeHtmlEntities(port.type)]
}
port={port}
onPortUpdated={onPortUpdated}
onPortLoaded={onPortLoaded}
onPortMouseDown={onPortMouseDown}
onPortMouseUp={onPortMouseUp}
onPortRightClick={onPortRightClick}
/>
</Stack.Item>
))}
</Stack>
</Stack.Item>
<Stack.Item ml={5}>
<Stack vertical>
{outputs.map((port) => (
<Stack.Item key={port.ref}>
<Port
color={
PORT_TYPES_TO_COLOR[decodeHtmlEntities(port.type)]
}
output
port={port}
onPortUpdated={onPortUpdated}
onPortLoaded={onPortLoaded}
onPortMouseDown={onPortMouseDown}
onPortMouseUp={onPortMouseUp}
onPortRightClick={onPortRightClick}
/>
</Stack.Item>
))}
</Stack>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item mt={inputs.length || outputs.length ? 5 : 0}>
<Stack align="flex-start" justify="space-between">
<Stack.Item>
<Stack vertical>
{activators
.filter((port) => !port.rawdata)
.map((port) => (
<Stack.Item key={port.ref}>
<Port
color={
PORT_TYPES_TO_COLOR[decodeHtmlEntities(port.type)]
}
output={!!port.rawdata}
port={port}
onPortUpdated={onPortUpdated}
onPortLoaded={onPortLoaded}
onPortMouseDown={onPortMouseDown}
onPortMouseUp={onPortMouseUp}
onPortRightClick={onPortRightClick}
/>
</Stack.Item>
))}
</Stack>
</Stack.Item>
<Stack.Item>
<Stack vertical>
{activators
.filter((port) => !!port.rawdata)
.map((port) => (
<Stack.Item key={port.ref}>
<Port
color={
PORT_TYPES_TO_COLOR[decodeHtmlEntities(port.type)]
}
output={!!port.rawdata}
port={port}
onPortUpdated={onPortUpdated}
onPortLoaded={onPortLoaded}
onPortMouseDown={onPortMouseDown}
onPortMouseUp={onPortMouseUp}
onPortRightClick={onPortRightClick}
/>
</Stack.Item>
))}
</Stack>
</Stack.Item>
</Stack>
</Stack.Item>
</Stack>
</Box>
</Box>
);
}
}

View File

@@ -0,0 +1,299 @@
import { decodeHtmlEntities } from 'common/string';
import { Component } from 'react';
import { resolveAsset } from 'tgui/assets';
import { useBackend, useSharedState } from 'tgui/backend';
// TODO: Replace when tgui-core is fixed https://github.com/tgstation/tgui-core/issues/25
import { InfinitePlane } from 'tgui/components';
import { Connection, Connections } from '../common/Connections';
import { CircuitComponent } from './CircuitComponent';
import { PortProps } from './Port';
import {
ABSOLUTE_Y_OFFSET,
CircuitData,
Data,
MOUSE_BUTTON_LEFT,
PortData,
PortTypesToColor,
TIME_UNTIL_PORT_RELEASE_WORKS,
} from './types';
export type PlaneProps = {};
type PlaneState = {
locations: Record<string, { x: number; y: number }>;
selectedPort: PortData | null;
timeUntilPortReleaseTimesOut: number;
backgroundX: number;
backgroundY: number;
zoom: number;
mouseX: number;
mouseY: number;
};
// eslint-disable-next-line react/prefer-stateless-function
export class Plane extends Component<PlaneProps, PlaneState> {
constructor(props) {
super(props);
this.state = {
locations: {},
selectedPort: null,
timeUntilPortReleaseTimesOut: 0,
backgroundX: 50,
backgroundY: 50,
zoom: 1,
mouseX: 0,
mouseY: 0,
};
}
getPosition = (dom: HTMLElement | null) => {
let xPos = 0;
let yPos = 0;
while (dom) {
xPos += dom.offsetLeft;
yPos += dom.offsetTop;
dom = dom.offsetParent as HTMLElement;
}
return {
x: xPos,
y: yPos + ABSOLUTE_Y_OFFSET,
};
};
handlePortLocation = (port: PortData, dom: HTMLSpanElement) => {
const { locations } = this.state;
if (!dom) {
return;
}
const lastPosition = locations[port.ref];
const position = this.getPosition(dom);
if (
isNaN(position.x) ||
isNaN(position.y) ||
(lastPosition &&
lastPosition.x === position.x &&
lastPosition.y === position.y)
) {
return;
}
locations[port.ref] = position;
this.setState({ locations });
};
handleZoomChange = (newZoom) => {
this.setState({
zoom: newZoom,
});
};
handleBackgroundMoved = (newX, newY) => {
this.setState({
backgroundX: newX,
backgroundY: newY,
});
};
handleDragging = (event: MouseEvent) => {
this.setState((state) => ({
mouseX: event.clientX - state.backgroundX,
mouseY: event.clientY - state.backgroundY,
}));
};
handlePortUp = (port: PortData, ref: HTMLDivElement, event: MouseEvent) => {
const { act } = useBackend();
const { selectedPort } = this.state;
if (!selectedPort || selectedPort.ref === port.ref) {
return;
}
this.setState({
selectedPort: null,
});
act('wire_internal', { pin1: selectedPort.ref, pin2: port.ref });
};
handlePortRelease = (event: MouseEvent) => {
window.removeEventListener('mouseup', this.handlePortRelease);
// This will let players release their mouse when dragging
// to stop connecting the port, whilst letting players
// click on the port to click and connect.
if (this.state.timeUntilPortReleaseTimesOut > Date.now()) {
return;
}
this.setState({
selectedPort: null,
});
window.removeEventListener('mousemove', this.handleDragging);
};
handlePortClick = (
port: PortData,
ref: HTMLDivElement,
event: MouseEvent,
) => {
if (this.state.selectedPort) {
this.handlePortUp(port, ref, event);
}
if (event.button !== MOUSE_BUTTON_LEFT) {
return;
}
event.stopPropagation();
this.setState({
selectedPort: port,
});
this.handleDragging(event);
this.setState({
timeUntilPortReleaseTimesOut: Date.now() + TIME_UNTIL_PORT_RELEASE_WORKS,
});
window.addEventListener('mousemove', this.handleDragging);
window.addEventListener('mouseup', this.handlePortRelease);
};
handlePortRightClick = (
port: PortData,
ref: HTMLDivElement,
event: MouseEvent,
) => {
const { act } = useBackend();
event.preventDefault();
act('remove_all_wires', {
pin: port.ref,
});
};
render() {
const { act, data } = useBackend<Data>();
const { locations, selectedPort, mouseX, mouseY } = this.state;
const connections: Connection[] = [];
for (const circuit of data.circuits) {
for (const input of circuit.inputs) {
for (const output of input.linked) {
const output_port = locations[output.ref];
connections.push({
color: PortTypesToColor[decodeHtmlEntities(input.type)] || 'blue',
from: output_port,
to: locations[input.ref],
});
}
}
for (const activator of circuit.activators) {
if (activator.rawdata) {
// input
for (const output of activator.linked) {
const output_port = locations[output.ref];
connections.push({
color:
PortTypesToColor[decodeHtmlEntities(activator.type)] || 'blue',
to: output_port,
from: locations[activator.ref],
});
}
}
}
}
if (selectedPort) {
const { zoom } = this.state;
const portLocation = locations[selectedPort.ref];
const mouseCoords = {
x: mouseX * Math.pow(zoom, -1),
y: (mouseY + ABSOLUTE_Y_OFFSET) * Math.pow(zoom, -1),
};
connections.push({
color:
PortTypesToColor[decodeHtmlEntities(selectedPort.type)] || 'blue',
from: portLocation,
to: mouseCoords,
});
}
return (
<InfinitePlane
width="100%"
height="100%"
backgroundImage={resolveAsset('grid_background.png')}
imageWidth={900}
onZoomChange={this.handleZoomChange}
onBackgroundMoved={this.handleBackgroundMoved}
initialLeft={50}
initialTop={50}
>
{data.circuits.map((circuit) => (
<Circuit
circuit={circuit}
key={circuit.ref}
onPortLoaded={this.handlePortLocation}
onPortUpdated={this.handlePortLocation}
onPortMouseDown={this.handlePortClick}
onPortMouseUp={this.handlePortUp}
onPortRightClick={this.handlePortRightClick}
/>
))}
<Connections connections={connections} />
</InfinitePlane>
);
}
}
const Circuit = (
props: { circuit: CircuitData } & Pick<
PortProps,
| 'onPortUpdated'
| 'onPortLoaded'
| 'onPortMouseDown'
| 'onPortMouseUp'
| 'onPortRightClick'
>,
) => {
const {
circuit,
onPortUpdated,
onPortLoaded,
onPortMouseDown,
onPortMouseUp,
onPortRightClick,
} = props;
const [pos, setPos] = useSharedState('component-pos-' + circuit.ref, {
x: 0,
y: 0,
});
return (
<CircuitComponent
circuit={circuit}
gridMode
x={pos.x}
y={pos.y}
onComponentMoved={(val) => setPos(val)}
onPortUpdated={onPortUpdated}
onPortLoaded={onPortLoaded}
onPortMouseDown={onPortMouseDown}
onPortMouseUp={onPortMouseUp}
onPortRightClick={onPortRightClick}
/>
);
};

View File

@@ -0,0 +1,117 @@
import { decodeHtmlEntities } from 'common/string';
import { Component, createRef } from 'react';
import { Box, Stack, Tooltip } from 'tgui-core/components';
import { PortData } from './types';
export type PortProps = {
port: PortData;
onPortUpdated?: (port: PortData, iconRef: HTMLSpanElement | null) => void;
onPortLoaded?: (port: PortData, iconRef: HTMLSpanElement | null) => void;
onPortMouseDown?: (
port: PortData,
iconRef: HTMLSpanElement | null,
e: MouseEvent,
) => void;
onPortMouseUp?: (
port: PortData,
iconRef: HTMLSpanElement | null,
e: MouseEvent,
) => void;
onPortRightClick?: (
port: PortData,
iconRef: HTMLSpanElement | null,
e: MouseEvent,
) => void;
color?: string;
output?: boolean;
};
export type PortState = {
portRef: React.RefObject<HTMLSpanElement>;
};
// eslint-disable-next-line react/prefer-stateless-function
export class Port extends Component<PortProps, PortState> {
constructor(props) {
super(props);
this.state = {
portRef: createRef(),
};
}
handlePortMouseDown = (e) => {
const { port, onPortMouseDown = () => {} } = this.props;
onPortMouseDown(port, this.state.portRef.current, e);
};
handlePortMouseUp = (e) => {
const { port, onPortMouseUp = () => {} } = this.props;
onPortMouseUp(port, this.state.portRef.current, e);
};
handlePortRightClick = (e) => {
const { port, onPortRightClick = () => {} } = this.props;
onPortRightClick(port, this.state.portRef.current, e);
};
componentDidUpdate = () => {
const { port, onPortUpdated } = this.props;
if (onPortUpdated) {
onPortUpdated(port, this.state.portRef.current);
}
};
componentDidMount = () => {
const { port, onPortLoaded } = this.props;
if (onPortLoaded) {
onPortLoaded(port, this.state.portRef.current);
}
};
renderDisplayName = () => {
const { port } = this.props;
return <Stack.Item>{port.name}</Stack.Item>;
};
render() {
const { portRef: iconRef } = this.state;
const { port, color, output } = this.props;
return (
<Tooltip
content={decodeHtmlEntities(port.type)}
position={'bottom-start'}
>
<Stack align="center" justify={output ? 'flex-end' : 'flex-start'}>
{!!output && this.renderDisplayName()}
<Stack.Item>
<Box
className="ObjectComponent__Port"
onMouseDown={this.handlePortMouseDown}
onContextMenu={this.handlePortRightClick}
onMouseUp={this.handlePortMouseUp}
textAlign="center"
>
<svg
style={{ width: '100%', height: '100%', position: 'absolute' }}
viewBox="0, 0, 100, 100"
>
<circle
cx="50"
cy="50"
r="50"
className={`color-fill-${color}`}
/>
</svg>
<span ref={iconRef} className="ObjectComponent__PortPos" />
</Box>
</Stack.Item>
{!output && this.renderDisplayName()}
</Stack>
</Tooltip>
);
}
}

View File

@@ -0,0 +1,161 @@
import { toFixed } from 'common/math';
import { useState } from 'react';
import { useBackend } from 'tgui/backend';
import { LabeledList } from 'tgui/components';
import { formatPower } from 'tgui/format';
import { Window } from 'tgui/layouts';
import {
AnimatedNumber,
Box,
Button,
ProgressBar,
Section,
} from 'tgui-core/components';
import { Plane } from './Plane';
import { Data } from './types';
export const ICAssembly = (props) => {
const [showInfo, setShowInfo] = useState(false);
const { act } = useBackend();
return (
<Window
buttons={
<>
<Button
color="transparent"
width={2.5}
height={2}
textAlign="center"
icon="pencil"
tooltip="Edit Name"
tooltipPosition="bottom-start"
onClick={() => act('rename')}
/>
<Button
color="transparent"
width={2.5}
height={2}
textAlign="center"
icon="info"
tooltip="Circuit Info"
tooltipPosition="bottom-start"
selected={showInfo}
onClick={() => setShowInfo(!showInfo)}
/>
</>
}
width={1280}
height={800}
>
<Window.Content style={{ background: 'none' }}>
<Plane />
{showInfo && <CircuitInfo />}
</Window.Content>
</Window>
);
};
const CircuitInfo = (props) => {
const { act, data } = useBackend<Data>();
const {
total_parts,
max_components,
total_complexity,
max_complexity,
battery_charge,
battery_max,
net_power,
} = data;
return (
<Section
position="absolute"
top={4}
right={2}
backgroundColor="rgba(0, 0, 0, 0.7)"
style={{ borderRadius: '16px' }}
width={40}
p={2}
title="Circuit Info"
>
<LabeledList>
<LabeledList.Item label="Space in Assembly">
<ProgressBar
ranges={{
good: [0, 0.25],
average: [0.5, 0.75],
bad: [0.75, 1],
}}
value={total_parts / max_components}
maxValue={1}
>
{total_parts +
' / ' +
max_components +
' (' +
toFixed((total_parts / max_components) * 100, 1) +
'%)'}
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Complexity">
<ProgressBar
ranges={{
good: [0, 0.25],
average: [0.5, 0.75],
bad: [0.75, 1],
}}
value={total_complexity / max_complexity}
maxValue={1}
>
{total_complexity +
' / ' +
max_complexity +
' (' +
toFixed((total_complexity / max_complexity) * 100, 1) +
'%)'}
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item
label="Cell Charge"
buttons={
<Button
icon="eject"
onClick={() => act('remove_cell')}
tooltip="Remove Cell"
/>
}
>
{(battery_charge && (
<ProgressBar
ranges={{
bad: [0, 0.25],
average: [0.5, 0.75],
good: [0.75, 1],
}}
value={battery_charge / battery_max}
maxValue={1}
>
{battery_charge +
' / ' +
battery_max +
' (' +
toFixed((battery_charge / battery_max) * 100, 1) +
'%)'}
</ProgressBar>
)) || <Box color="bad">No cell detected.</Box>}
</LabeledList.Item>
<LabeledList.Item label="Net Energy">
{(net_power === 0 && '0 W/s') || (
<AnimatedNumber
value={net_power}
format={(val) => '-' + formatPower(Math.abs(val)) + '/s'}
/>
)}
</LabeledList.Item>
</LabeledList>
</Section>
);
};

View File

@@ -0,0 +1,70 @@
export type Data = {
total_parts: number;
max_components: number;
total_complexity: number;
max_complexity: number;
battery_charge: number;
battery_max: number;
net_power: number;
circuits: CircuitData[];
};
export type CircuitData = {
name: string;
desc: string;
ref: string;
removable: boolean;
complexity: number;
power_draw_idle: number;
power_draw_per_use: number;
extended_desc: string;
inputs: PortData[];
outputs: PortData[];
activators: PortData[];
};
export type PortData = {
type: string;
name: string;
data: string;
rawdata: string;
ref: string;
linked: LinkedPortData[];
};
export type LinkedPortData = {
ref: string;
name: string;
holder_ref: string;
holder_name: string;
};
export enum PortTypes {
IC_FORMAT_ANY = '<ANY>',
IC_FORMAT_STRING = '<TEXT>',
IC_FORMAT_CHAR = '<CHAR>',
IC_FORMAT_COLOR = '<COLOR>',
IC_FORMAT_NUMBER = '<NUM>',
IC_FORMAT_DIR = '<DIR>',
IC_FORMAT_BOOLEAN = '<BOOL>',
IC_FORMAT_REF = '<REF>',
IC_FORMAT_LIST = '<LIST>',
IC_FORMAT_PULSE = '<PULSE>',
}
export const PortTypesToColor = {
[PortTypes.IC_FORMAT_ANY]: 'olive',
[PortTypes.IC_FORMAT_STRING]: 'green',
[PortTypes.IC_FORMAT_CHAR]: 'darkyellow',
[PortTypes.IC_FORMAT_COLOR]: 'pink',
[PortTypes.IC_FORMAT_NUMBER]: 'blue',
[PortTypes.IC_FORMAT_DIR]: 'darkred',
[PortTypes.IC_FORMAT_BOOLEAN]: 'red',
[PortTypes.IC_FORMAT_REF]: 'darkblue',
[PortTypes.IC_FORMAT_LIST]: 'darkgreen',
[PortTypes.IC_FORMAT_PULSE]: 'yellow',
};
export const ABSOLUTE_Y_OFFSET = -32;
export const MOUSE_BUTTON_LEFT = 0;
export const TIME_UNTIL_PORT_RELEASE_WORKS = 100;

View File

@@ -0,0 +1,95 @@
import { classes } from 'common/react';
import { CSS_COLORS } from 'tgui/constants';
const SVG_CURVE_INTENSITY = 64;
enum ConnectionStyle {
CURVE = 'curve',
SUBWAY = 'subway',
}
export type Position = {
x: number;
y: number;
};
export type Connection = {
// X, Y starting point
from: Position;
// X, Y ending point
to: Position;
// Color of the line, defaults to blue
color?: string;
// Type of line - Curvy or Straight / angled, defaults to curvy
style?: ConnectionStyle;
// Optional: the ref of what element this connection is sourced
ref?: string;
};
export const Connections = (props: {
connections: Connection[];
zLayer?: number;
lineWidth?: number;
}) => {
const { connections, zLayer = -1, lineWidth = '2px' } = props;
const isColorClass = (str) => {
if (typeof str === 'string') {
return CSS_COLORS.includes(str as any);
}
};
return (
<svg
width="100%"
height="100%"
style={{
position: 'absolute',
pointerEvents: 'none',
zIndex: zLayer,
}}
>
{connections.map((val, index) => {
const from = val.from;
const to = val.to;
if (!to || !from) {
return;
}
val.color = val.color || 'blue';
val.style = val.style || ConnectionStyle.CURVE;
// Starting point
let path = `M ${from.x} ${from.y}`;
switch (val.style) {
case ConnectionStyle.CURVE: {
path += `C ${from.x + SVG_CURVE_INTENSITY}, ${from.y},`;
path += `${to.x - SVG_CURVE_INTENSITY}, ${to.y},`;
path += `${to.x}, ${to.y}`;
break;
}
case ConnectionStyle.SUBWAY: {
const yDiff = Math.abs(from.y - (to.y - 16));
path += `L ${to.x - yDiff} ${from.y}`;
path += `L ${to.x - 16} ${to.y}`;
path += `L ${to.x} ${to.y}`;
break;
}
}
return (
<path
className={classes([
isColorClass(val.color) && `color-stroke-${val.color}`,
])}
key={index}
d={path}
fill="transparent"
stroke-width={lineWidth}
/>
);
})}
</svg>
);
};

View File

@@ -1,7 +1,16 @@
@use '../colors.scss';
@use '../base.scss';
$bg-map: colors.$bg-map !default;
$fg-map: colors.$fg-map !default;
.CircuitInfo__Examined {
background-color: rgba(0, 0, 0, 1);
padding: 8px;
border-radius: 5px;
user-select: none;
pointer-events: none;
}
.ObjectComponent__Titlebar {
border-top-left-radius: 12px;
@@ -21,6 +30,20 @@ $bg-map: colors.$bg-map !default;
user-select: none;
}
.ObjectComponent__Greyed_Content {
white-space: nowrap;
background-color: rgba(19, 19, 19, 0.5);
-ms-user-select: none;
user-select: none;
}
.ObjectComponent__Port {
position: relative;
width: 13px;
height: 13px;
}
.ObjectComponent__PortPos {
position: absolute;
top: 0;
@@ -29,8 +52,21 @@ $bg-map: colors.$bg-map !default;
bottom: 0;
}
@each $color-name, $color-value in $bg-map {
@each $color-name, $color-value in $fg-map {
.color-stroke-#{$color-name} {
stroke: $color-value !important;
}
.color-fill-#{$color-name} {
fill: $color-value !important;
}
}
$border-color: #88bfff !default;
$border-radius: base.$border-radius !default;
.IntegratedCircuit__BlueBorder {
border: base.em(1px) solid $border-color;
border: base.em(1px) solid rgba($border-color, 0.75);
border-radius: $border-radius;
}

View File

@@ -2037,6 +2037,7 @@
#include "code\modules\asset_cache\asset_list.dm"
#include "code\modules\asset_cache\asset_list_items.dm"
#include "code\modules\asset_cache\assets\chat.dm"
#include "code\modules\asset_cache\assets\circuits.dm"
#include "code\modules\asset_cache\assets\fontawesome.dm"
#include "code\modules\asset_cache\assets\icon_ref_map.dm"
#include "code\modules\asset_cache\assets\jquery.dm"