From 65bd5e581a47593a48ee05026b7cfa96fef89707 Mon Sep 17 00:00:00 2001 From: MrMelbert <51863163+MrMelbert@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:20:15 -0500 Subject: [PATCH] Beaker panel TGUI / At least makes it functional (#93398) ## About The Pull Request Makes Beaker Panel a TGUI so it actually works image There's a lot more I want to do to this UI, namely UX and more features (like changing grenade types) but I figured I should PR it now that it's bare minimum functional so I don't get eaten by scope creep Fixes #89954 ## Changelog :cl: Melbert admin: Spawn-reagent-container works again, and it's a TGUI /:cl: --- code/modules/admin/topic.dm | 3 - code/modules/admin/verbs/beakerpanel.dm | 395 ++++-------------- .../antagonists/blob/blobstrains/_reagent.dm | 2 - .../reagents/drinks/drink_reagents.dm | 2 +- tgui/packages/tgui/interfaces/BeakerPanel.tsx | 321 ++++++++++++++ 5 files changed, 406 insertions(+), 317 deletions(-) create mode 100644 tgui/packages/tgui/interfaces/BeakerPanel.tsx diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index 054a507b6a2..90eaecb2124 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -1272,9 +1272,6 @@ var/ban_id = href_list["unbanlog"] ban_log(ban_id) - else if(href_list["beakerpanel"]) - beaker_panel_act(href_list) - else if(href_list["reloadpolls"]) GLOB.polls.Cut() GLOB.poll_options.Cut() diff --git a/code/modules/admin/verbs/beakerpanel.dm b/code/modules/admin/verbs/beakerpanel.dm index 7088fba92f1..94dcb7fb171 100644 --- a/code/modules/admin/verbs/beakerpanel.dm +++ b/code/modules/admin/verbs/beakerpanel.dm @@ -1,320 +1,93 @@ -/proc/reagentsforbeakers() - . = list() - for(var/t in subtypesof(/datum/reagent)) - var/datum/reagent/R = t - . += list(list("id" = t, "text" = initial(R.name))) +/datum/beaker_panel - . = json_encode(.) +/datum/beaker_panel/ui_state(mob/user) + return ADMIN_STATE(R_ADMIN) -/proc/beakersforbeakers() - . = list() - for(var/t in subtypesof(/obj/item/reagent_containers)) - var/obj/item/reagent_containers/C = t - . += list(list("id" = t, "text" = initial(C.name), "volume" = initial(C.volume))) +/datum/beaker_panel/ui_close() + qdel(src) - . = json_encode(.) +/datum/beaker_panel/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "BeakerPanel") + ui.open() -/datum/admins/proc/beaker_panel_act(list/href_list) - switch (href_list["beakerpanel"]) - if ("spawncontainer") - var/containerdata = json_decode(href_list["container"]) - var/obj/item/reagent_containers/container = beaker_panel_create_container(containerdata, get_turf(usr)) - usr.log_message("spawned a [container] containing [pretty_string_from_reagent_list(container.reagents.reagent_list)]", LOG_GAME) - if ("spawngrenade") - var/obj/item/grenade/chem_grenade/grenade = new(get_turf(usr)) - var/containersdata = json_decode(href_list["containers"]) - var/reagent_string - for (var/i in 1 to 2) - grenade.beakers += beaker_panel_create_container(containersdata[i], grenade) - reagent_string += " ([grenade.beakers[i].name] [i] : " + pretty_string_from_reagent_list(grenade.beakers[i].reagents.reagent_list) + ");" - grenade.stage_change(GRENADE_READY) - var/grenadedata = json_decode(href_list["grenadedata"]) - switch (href_list["grenadetype"]) - if ("normal") // Regular cable coil-timed grenade - var/det_time = text2num(grenadedata["grenade-timer"]) - if (det_time) - grenade.det_time = det_time - usr.log_message("spawned a [grenade] containing: [reagent_string]", LOG_GAME) +/datum/beaker_panel/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return -/datum/admins/proc/beaker_panel_prep_assembly(obj/item/assembly/towrap, grenade) - var/obj/item/assembly/igniter/igniter = new - igniter.secured = FALSE - var/obj/item/assembly_holder/assholder = new(grenade) - towrap.forceMove(assholder) - igniter.forceMove(assholder) - assholder.assemble(igniter, towrap, usr) - assholder.master = grenade - return assholder + var/mob/user = ui.user + switch(action) + if("spawn") + var/obj/created = spawn_container_from_data(user, params["spawn_info"]) + user.log_message("spawned a [created] containing [pretty_string_from_reagent_list(created.reagents.reagent_list)]", LOG_ADMIN) + return TRUE + if("spawngrenade") + var/obj/item/grenade/chem_grenade/grenade = spawn_grenade_from_data(user, params["spawn_info"], params["grenade_info"]) + var/log_string = list() + for(var/obj/beaker as anything in grenade.beakers) + log_string += pretty_string_from_reagent_list(beaker.reagents.reagent_list) + user.log_message("spawned a [grenade] containing [english_list(log_string)]", LOG_ADMIN) + return TRUE -/datum/admins/proc/beaker_panel_create_container(list/containerdata, location) - var/containertype = text2path(containerdata["container"]) - var/obj/item/reagent_containers/container = new containertype(location) - var/datum/reagents/reagents = container.reagents - for(var/datum/reagent/R in reagents.reagent_list) // clear the container of reagents - reagents.remove_reagent(R.type,R.volume) - for (var/list/item in containerdata["reagents"]) - var/datum/reagent/reagenttype = text2path(item["reagent"]) - var/amount = text2num(item["volume"]) - if ((reagents.total_volume + amount) > reagents.maximum_volume) - reagents.maximum_volume = reagents.total_volume + amount - reagents.add_reagent(reagenttype, amount) +/datum/beaker_panel/ui_static_data(mob/user) + var/list/data = list() + + data["reagents"] = list() + data["containers"] = list() + + for(var/datum/reagent/reagent_type as anything in subtypesof(/datum/reagent)) + if(!reagent_type::name) + continue + data["reagents"] += list(list("id" = reagent_type, "text" = reagent_type::name)) + + for(var/obj/item/reagent_containers/container_type as anything in subtypesof(/obj/item/reagent_containers)) + if(!container_type::name) + continue + data["containers"] += list(list("id" = container_type, "text" = container_type::name, "volume" = container_type::volume)) + + return data + +/datum/beaker_panel/proc/spawn_container_from_data(mob/user, list/spawn_info) + var/container_type = text2path(spawn_info["container"]) + var/list/container_reagents = list() + for(var/reagent_string, reagent_amount in spawn_info["reagents"]) + container_reagents[text2path(reagent_string)] = text2num(reagent_amount) + + return spawn_container(user, container_type, container_reagents) + +/datum/beaker_panel/proc/spawn_container(mob/user, container_type, list/container_reagents) + var/obj/item/reagent_containers/container = new container_type(user.drop_location()) + container.reagents.maximum_volume = INFINITY + container.reagents.clear_reagents() + container.reagents.add_reagent_list(container_reagents) + container.reagents.maximum_volume = max(container.reagents.total_volume, initial(container.volume)) return container +/datum/beaker_panel/proc/spawn_grenade_from_data(mob/user, list/all_spawn_info, list/grenade_info) + var/list/containers = list() + for(var/list/container_info as anything in all_spawn_info) + containers += spawn_container_from_data(user, container_info) + + return spawn_grenade(user, containers, grenade_info) + +/datum/beaker_panel/proc/spawn_grenade(mob/user, list/beakers, list/grenade_info) + var/obj/item/grenade/chem_grenade/grenade = new(user.drop_location()) + grenade.beakers = beakers + grenade.stage_change(GRENADE_READY) + + for(var/obj/beaker as anything in grenade.beakers) + beaker.forceMove(grenade) + + switch(grenade_info["detonation_type"]) + if("normal") + var/det_time = text2num(grenade_info["detonation_timer"]) * 1 SECONDS + if(det_time) + grenade.det_time = det_time + + return grenade + ADMIN_VERB(beaker_panel, R_SPAWN, "Spawn Reagent Container", "Spawn a reagent container.", ADMIN_CATEGORY_EVENTS) - var/datum/asset/asset_datum = get_asset_datum(/datum/asset/simple/namespaced/common) - asset_datum.send(user) - //Could somebody tell me why this isn't using the browser datum, given that it copypastes all of browser datum's html - // fuck if I know, but im not touching it - var/dat = {" - - - - - - - - - - - - - - -
-
Beaker panel
-
- -
- - - -
-
-
-note: beakers recommended, other containers may have issues -
- - "} - for (var/i in 1 to 2 ) - dat += {" -
-

- Container [i]: -

-
- - -
-
-
- -     - -     - - -
-
    -
  • - -
       - -
    -
  • -
-
- "} - - dat += {" -
-
- - - "} - - user << browse(dat, "window=beakerpanel;size=1100x720") + var/datum/beaker_panel/panel = new + panel.ui_interact(user.mob) diff --git a/code/modules/antagonists/blob/blobstrains/_reagent.dm b/code/modules/antagonists/blob/blobstrains/_reagent.dm index 3273087a692..e2841ceb019 100644 --- a/code/modules/antagonists/blob/blobstrains/_reagent.dm +++ b/code/modules/antagonists/blob/blobstrains/_reagent.dm @@ -26,8 +26,6 @@ // These can only be applied by blobs. They are what (reagent) blobs are made out of. /datum/reagent/blob - name = "Unknown" - description = "" color = COLOR_WHITE taste_description = "bad code and slime" chemical_flags = NONE diff --git a/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm b/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm index 3130c23741f..cab73988509 100644 --- a/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm +++ b/code/modules/reagents/chemistry/reagents/drinks/drink_reagents.dm @@ -226,7 +226,7 @@ holder.remove_reagent(/datum/reagent/consumable/capsaicin, seconds_per_tick) return ..() || . -/datum/reagent/milk/used_on_fish(obj/item/fish/fish) +/datum/reagent/consumable/milk/used_on_fish(obj/item/fish/fish) if(HAS_TRAIT(fish, TRAIT_FISH_MADE_OF_BONE)) fish.repair_damage(fish.max_integrity * max(fish.get_hunger() * 0.5, 0.12)) fish.sate_hunger() diff --git a/tgui/packages/tgui/interfaces/BeakerPanel.tsx b/tgui/packages/tgui/interfaces/BeakerPanel.tsx new file mode 100644 index 00000000000..de3cd5ade0a --- /dev/null +++ b/tgui/packages/tgui/interfaces/BeakerPanel.tsx @@ -0,0 +1,321 @@ +import { useState } from 'react'; +import { + Button, + Dropdown, + NumberInput, + Section, + Stack, +} from 'tgui-core/components'; +import { capitalizeFirst } from 'tgui-core/string'; +import { useBackend } from '../backend'; +import { Window } from '../layouts'; + +type typePath = string; + +type Reagent = { + id: typePath; + text: string; +}; + +type ContainerType = { + id: typePath; + text: string; + volume: number; +}; + +type Data = { + reagents: Reagent[]; + containers: ContainerType[]; +}; + +type Container = { + type: typePath; + reagents: Record; +}; + +function makeContainerState(default_type: ContainerType) { + return useState({ + type: default_type.id, + reagents: {}, + }); +} + +function removeContainerReagent( + container: Container, + setContainer: (container: Container) => void, + reagent: typePath, +) { + const newReagents = { ...container.reagents }; + delete newReagents[reagent]; + setContainer({ ...container, reagents: newReagents }); +} + +function setContainerReagentVolume( + container: Container, + setContainer: (container: Container) => void, + reagent: typePath, + volume: number = 10, +) { + const newReagents = { ...container.reagents }; + newReagents[reagent] = volume; + setContainer({ ...container, reagents: newReagents }); +} + +function containerToSpawnInfo(container: Container) { + return { + container: container.type, + reagents: container.reagents, + }; +} + +function readableContainerType(container_type: ContainerType) { + return capitalizeFirst(`${container_type.text} (${container_type.volume}u)`); +} + +function readableReagentType(reagent: Reagent) { + return capitalizeFirst(reagent.text); +} + +function grenadeCheck(containers: Container[]) { + return containers.every((container) => container.type.includes('beaker')); +} + +type ContainerProps = { + container: Container; + number: number; + updateContainer: (container: Container) => void; + reagents: Reagent[]; + containers: ContainerType[]; +}; + +const ContainerSection = (props: ContainerProps) => { + const { container, number, updateContainer, reagents, containers } = props; + const { act } = useBackend(); + + const [setAddingReagent, setSetAddingReagent] = useState( + reagents[0].id, + ); + const [setAddingReagentVolume, setSetAddingReagentVolume] = + useState(50); + + return ( +
+ act('spawn', { spawn_info: containerToSpawnInfo(container) }) + } + > + Spawn + + } + > + + + ({ + displayText: readableContainerType(container), + value: container.id, + }))} + placeholder="Select Container Type" + selected={container.type} + displayText={readableContainerType( + containers.find((c) => c.id === container.type)!, + )} + onSelected={(value) => { + updateContainer({ ...container, type: value }); + }} + /> + + {Object.keys(container.reagents).map((reagent) => ( + + + + + + + { + setContainerReagentVolume( + container, + updateContainer, + reagent, + value, + ); + }} + /> + + +
+ ); +}; + +export const BeakerPanel = () => { + const { act, data } = useBackend(); + const { reagents, containers } = data; + + const [container_one, setContainerOne] = makeContainerState(containers[0]); + const [container_two, setContainerTwo] = makeContainerState(containers[0]); + const [grenadeTimer, setGrenadeTimer] = useState(5.0); + + const reagentsSorted = reagents.sort((a, b) => (a.text < b.text ? -1 : 1)); + const containersSorted = containers.sort((a, b) => + readableContainerType(a) < readableContainerType(b) ? -1 : 1, + ); + + return ( + + + + +
+ + + + + + Timer:  + { + setGrenadeTimer(value); + }} + /> + + + + Spawned containers will grow to fit all listed reagents! + + + +
+
+ + + + + + + + + + +
+
+
+ ); +};