diff --git a/code/modules/asset_cache/assets/circuits.dm b/code/modules/asset_cache/assets/circuits.dm new file mode 100644 index 0000000000..c783fcf8b2 --- /dev/null +++ b/code/modules/asset_cache/assets/circuits.dm @@ -0,0 +1,4 @@ +/datum/asset/simple/circuit_assets + assets = list( + "grid_background.png" = 'icons/UI_Icons/tgui/grid_background.png', + ) diff --git a/code/modules/integrated_electronics/core/assemblies.dm b/code/modules/integrated_electronics/core/assemblies.dm index 15ffcb6673..2bf148dbf1 100644 --- a/code/modules/integrated_electronics/core/assemblies.dm +++ b/code/modules/integrated_electronics/core/assemblies.dm @@ -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 diff --git a/code/modules/integrated_electronics/core/integrated_circuit.dm b/code/modules/integrated_electronics/core/integrated_circuit.dm index b9e67b7607..ba3dbc709c 100644 --- a/code/modules/integrated_electronics/core/integrated_circuit.dm +++ b/code/modules/integrated_electronics/core/integrated_circuit.dm @@ -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) diff --git a/icons/UI_Icons/tgui/grid_background.png b/icons/UI_Icons/tgui/grid_background.png new file mode 100644 index 0000000000..228d373456 Binary files /dev/null and b/icons/UI_Icons/tgui/grid_background.png differ diff --git a/tgui/packages/tgui/components/InfinitePlane.tsx b/tgui/packages/tgui/components/InfinitePlane.tsx new file mode 100644 index 0000000000..ca53e538a1 --- /dev/null +++ b/tgui/packages/tgui/components/InfinitePlane.tsx @@ -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) => { + 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) => { + 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 ( +
+
+
+ {children} +
+ + + +
+ ); + } +} diff --git a/tgui/packages/tgui/components/index.ts b/tgui/packages/tgui/components/index.ts index 95c2dd9260..6c17662086 100644 --- a/tgui/packages/tgui/components/index.ts +++ b/tgui/packages/tgui/components/index.ts @@ -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'; diff --git a/tgui/packages/tgui/interfaces/ICAssembly.tsx b/tgui/packages/tgui/interfaces/ICAssembly.tsx deleted file mode 100644 index 8672bbf175..0000000000 --- a/tgui/packages/tgui/interfaces/ICAssembly.tsx +++ /dev/null @@ -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(); - - const { - total_parts, - max_components, - total_complexity, - max_complexity, - battery_charge, - battery_max, - net_power, - unremovable_circuits, - removable_circuits, - } = data; - - return ( - - -
- - - - {total_parts + - ' / ' + - max_components + - ' (' + - toFixed((total_parts / max_components) * 100, 1) + - '%)'} - - - - - {total_complexity + - ' / ' + - max_complexity + - ' (' + - toFixed((total_complexity / max_complexity) * 100, 1) + - '%)'} - - - - {(battery_charge && ( - - {battery_charge + - ' / ' + - battery_max + - ' (' + - toFixed((battery_charge / battery_max) * 100, 1) + - '%)'} - - )) || No cell detected.} - - - {(net_power === 0 && '0 W/s') || ( - '-' + formatPower(Math.abs(val)) + '/s'} - /> - )} - - -
- {(unremovable_circuits.length && ( - - )) || - null} - {(removable_circuits.length && ( - - )) || - null} -
-
- ); -}; - -const ICAssemblyCircuits = (props: { title: string; circuits: circuit[] }) => { - const { act } = useBackend(); - - const { title, circuits } = props; - - return ( -
- - {circuits.map((circuit) => ( - - - - - - - - ))} - -
- ); -}; diff --git a/tgui/packages/tgui/interfaces/ICAssembly/CircuitComponent.tsx b/tgui/packages/tgui/interfaces/ICAssembly/CircuitComponent.tsx new file mode 100644 index 0000000000..c5d255e301 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ICAssembly/CircuitComponent.tsx @@ -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 { + 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) => { + 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 | 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 ( + + + + {name} + +