diff --git a/code/game/objects/items/toys/toy_customizable.dm b/code/game/objects/items/toys/toy_customizable.dm new file mode 100644 index 0000000000..0a8105606e --- /dev/null +++ b/code/game/objects/items/toys/toy_customizable.dm @@ -0,0 +1,206 @@ +/obj/item/toy/plushie/customizable + name = "You shouldn't be seeing this..." + desc = "Allan, please add details!" + icon = 'icons/obj/customizable_toys/durg.dmi' + icon_state = "blankdurg" + pokephrase = "Squeaky!" + + var/base_color = "#FFFFFF" + var/list/possible_overlays + var/list/added_overlays + +/obj/item/toy/plushie/customizable/update_icon() + cut_overlays() + var/mutable_appearance/B = mutable_appearance(icon, icon_state) + B.color = base_color + add_overlay(B) + if(added_overlays) + for(var/key, value in added_overlays) + var/mutable_appearance/our_image = mutable_appearance(icon, key) + our_image.color = value["color"] + our_image.alpha = value["alpha"] + add_overlay(our_image) + +/obj/item/toy/plushie/customizable/Initialize(mapload) + . = ..() + update_icon() + +/obj/item/toy/plushie/customizable/tgui_state(mob/user) + return GLOB.tgui_conscious_state + +/obj/item/toy/plushie/customizable/tgui_interact(mob/user, datum/tgui/ui = null) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "PlushieEditor") + ui.set_autoupdate(FALSE) //This might be a bit intensive, better to not update it every few ticks + ui.open() + +/obj/item/toy/plushie/customizable/tgui_data(mob/user) + + var/list/possible_overlay_data = list() + if(possible_overlays) + for(var/state,name in possible_overlays) + UNTYPED_LIST_ADD(possible_overlay_data, list( + "name" = name, + "icon_state" = state + )) + + var/list/our_overlays = list() + if(added_overlays) + for(var/state,overlay in added_overlays) + UNTYPED_LIST_ADD(our_overlays, list( + "icon_state" = state, + "name" = possible_overlays[state], + "color" = overlay["color"], + "alpha" = overlay["alpha"] + )) + + var/list/data = list( + "base_color" = base_color, + "name" = name, + "icon" = icon, + "preview" = icon2base64(get_flat_icon(src)), + "possible_overlays" = possible_overlay_data, + "overlays" = our_overlays + ) + return data + +/obj/item/toy/plushie/customizable/tgui_act(action, params, datum/tgui/ui) + if(..()) + return TRUE + add_fingerprint(ui.user) + + if(!added_overlays) + added_overlays = list() + + switch(action) + if("add_overlay") + if(!possible_overlays) + return FALSE + var/new_overlay = params["new_overlay"] + if(!(new_overlay in possible_overlays)) + return FALSE + . = TRUE + added_overlays[new_overlay] = list(color = "#FFFFFF", alpha = 255) + update_icon() + + if("remove_overlay") + if(!added_overlays) + return FALSE + var/removed_overlay = params["removed_overlay"] + if(!(removed_overlay in added_overlays)) + return FALSE + . = TRUE + added_overlays.Remove(removed_overlay) + update_icon() + + if("change_overlay_color") + if(!possible_overlays) + return FALSE + var/selected_icon_state = params["icon_state"] + if(!(selected_icon_state in possible_overlays)) + return FALSE + . = TRUE + var/target = added_overlays[selected_icon_state] + var/mob/our_user = ui.user + var/new_color = tgui_color_picker(our_user, "Choose a color:", possible_overlays[selected_icon_state], base_color) + if(!new_color || our_user.stat || !Adjacent(our_user)) + return FALSE + target["color"] = new_color + update_icon() + + if("move_overlay_up") + var/target = params["icon"] + var/idx = added_overlays.Find(target) + if(!idx) + return FALSE + . = TRUE + if (idx < added_overlays.len) + added_overlays.Swap(idx, idx + 1) + update_icon() + + if("move_overlay_down") + var/target = params["icon"] + var/idx = added_overlays.Find(target) + if(!idx) + return FALSE + . = TRUE + if (idx > 1) + added_overlays.Swap(idx, idx - 1) + update_icon() + + if("change_base_color") + . = TRUE + var/mob/our_user = ui.user + var/new_color = tgui_color_picker(our_user, "Choose a color:", "Plushie base color", base_color) + if(!new_color || our_user.stat || !Adjacent(our_user)) + return FALSE + base_color = new_color + update_icon() + + if("set_overlay_alpha") + var/target = added_overlays[params["icon_state"]] + if(!target) + return FALSE + . = TRUE + var/new_alpha = params["alpha"] + target["alpha"] = new_alpha + update_icon() + + if("import_config") + . = TRUE + var/our_data = params["config"] + var/imported_color = sanitize_hexcolor(our_data["base_color"]) + if(imported_color) + base_color = imported_color + set_new_name(our_data["name"]) + added_overlays.Cut() + if(!possible_overlays) + return + for(var/overlay in our_data["overlays"]) + if(possible_overlays.Find(overlay["icon_state"])) + added_overlays[overlay["icon_state"]] = list( color = overlay["color"], alpha = overlay["alpha"] ) + update_icon() + + if("clear") + . = TRUE + added_overlays.Cut() + base_color = "#FFFFFF" + update_icon() + + if("rename") + return set_new_name(params["name"]) + +/obj/item/toy/plushie/customizable/proc/set_new_name(new_name) + var/sane_name = sanitize_name(new_name) + if(!sane_name) + return FALSE + name = sane_name + adjusted_name = sane_name + return TRUE + +/obj/item/toy/plushie/customizable/AltClick(mob/user) + tgui_interact(user) + +/obj/item/toy/plushie/customizable/dragon + name = "custom dragon plushie" + desc = "A customizable, modular plushie in the shape of a dragon. How cute!" + pokephrase = "Gawr!" + possible_overlays = list( + "durg_underbelly" = "Underbelly", + "durg_fur" = "Fur", + "durg_spines" = "Spines", + "classic_w_1" = "Wings, Western, L", + "classic_w_2" = "Wings, Western, R", + "classic_w_misc" = "Wings, Western, Underside", + "fairy_w_1" = "Wings, Fairy, L", + "fairy_w_2" = "Wings, Fairy, R", + "angular_w_1" = "Wings, Angular, L", + "angular_w_2" = "Wings, Angular, R", + "double_h_1" = "Horns, Double, L", + "double_h_2" = "Horns, Double, R", + "classic_h_1" = "Horns, Classic, L", + "classic_h_2" = "Horns, Classic, R", + "thick_h_1" = "Horns, Thick, L", + "thick_h_2" = "Horns, Thick, R" + ) diff --git a/code/modules/client/preference_setup/loadout/loadout_general.dm b/code/modules/client/preference_setup/loadout/loadout_general.dm index 1137055a8a..05ce4c91d8 100644 --- a/code/modules/client/preference_setup/loadout/loadout_general.dm +++ b/code/modules/client/preference_setup/loadout/loadout_general.dm @@ -86,6 +86,7 @@ blacklisted_types += subtypesof(/obj/item/toy/plushie/fluff) blacklisted_types += /obj/item/toy/plushie/borgplushie/drake //VOREStation addition blacklisted_types += /obj/item/toy/plushie/dragon/gold_east + blacklisted_types += /obj/item/toy/plushie/customizable for(var/obj/item/toy/plushie/plushie_type as anything in subtypesof(/obj/item/toy/plushie) - blacklisted_types) plushies[initial(plushie_type.name)] = plushie_type gear_tweaks += new/datum/gear_tweak/path(sortAssoc(plushies)) diff --git a/icons/obj/customizable_toys/durg.dmi b/icons/obj/customizable_toys/durg.dmi new file mode 100644 index 0000000000..047a065df6 Binary files /dev/null and b/icons/obj/customizable_toys/durg.dmi differ diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/ActiveOverlayGrid.tsx b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/ActiveOverlayGrid.tsx new file mode 100644 index 0000000000..5b2b274bdf --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/ActiveOverlayGrid.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; +import { memo } from 'react'; +import { ImageButton, Stack } from 'tgui-core/components'; +import type { Overlay } from '../types'; + +type ActiveOverlaysGridProps = { + icon: string; + overlays: Overlay[]; + selectedOverlay: string | null; + onSelect: React.Dispatch>; +}; + +export const ActiveOverlaysGrid = memo( + ({ icon, overlays, selectedOverlay, onSelect }) => ( + + {overlays.map((ov) => ( + + onSelect(ov.icon_state)} + color={selectedOverlay === ov.icon_state ? 'green' : undefined} + > + {ov.name} + + + ))} + + ), +); diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/OverlaySelector.tsx b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/OverlaySelector.tsx new file mode 100644 index 0000000000..0c1122fafe --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/OverlaySelector.tsx @@ -0,0 +1,72 @@ +import type React from 'react'; +import { useState } from 'react'; +import { useBackend } from 'tgui/backend'; +import { + Box, + Floating, + ImageButton, + Input, + Section, + Stack, +} from 'tgui-core/components'; +import { createSearch } from 'tgui-core/string'; +import { useOverlayMap } from '../function'; +import type { Data } from '../types'; + +type OverlayModalProps = { + toggleOverlay: (icon_state: string) => void; +}; + +export const OverlaySelector: React.FC = ({ + toggleOverlay, +}) => { + const { data } = useBackend(); + const [filter, setFilter] = useState(''); + const { possible_overlays, overlays, icon } = data; + + const searcher = createSearch( + filter, + (ov: { name: string; icon_state: string }) => ov.name, + ); + + const overlayMap = useOverlayMap(overlays); + const filteredPossible = possible_overlays.filter(searcher); + + return ( + + + + + + + {filteredPossible.map(({ name, icon_state }) => ( + toggleOverlay(icon_state)} + color={overlayMap[icon_state] ? 'green' : 'red'} + fluid + align="start" + > + {name} + + ))} + + + + } + > + +/- + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/PreviewPanel.tsx b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/PreviewPanel.tsx new file mode 100644 index 0000000000..1253c9e9a7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/PreviewPanel.tsx @@ -0,0 +1,86 @@ +import type React from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useBackend } from 'tgui/backend'; +import { + Box, + Button, + ColorBox, + Image, + Input, + LabeledList, + Section, + Stack, +} from 'tgui-core/components'; +import type { Data } from '../types'; + +type PreviewPanelProps = { + setSelectedOverlay: Dispatch>; +}; +export const PreviewPanel: React.FC = ({ + setSelectedOverlay, +}) => { + const { act, data } = useBackend(); + const { base_color, preview, name } = data; + + return ( +
{ + setSelectedOverlay(null); + act('clear'); + }} + > + Clear + + } + > + + + + + + + + + + + + + act('rename', { name: value })} + /> + + + + + + + + + + +
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/SidePanel.tsx b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/SidePanel.tsx new file mode 100644 index 0000000000..d3eac06dce --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/EditorElements/SidePanel.tsx @@ -0,0 +1,193 @@ +import type React from 'react'; +import { useBackend } from 'tgui/backend'; +import { + Box, + Button, + ColorBox, + LabeledList, + NoticeBox, + Section, + Slider, + Stack, +} from 'tgui-core/components'; +import { downloadJson, handleImportData, useOverlayMap } from '../function'; +import type { Data } from '../types'; +import { ActiveOverlaysGrid } from './ActiveOverlayGrid'; +import { OverlaySelector } from './OverlaySelector'; + +type SidebarPanelProps = { + selectedOverlay: string | null; + setSelectedOverlay: React.Dispatch>; +}; + +export const SidebarPanel: React.FC = ({ + selectedOverlay, + setSelectedOverlay, +}) => { + const { act, data } = useBackend(); + const { base_color, icon, name, overlays } = data; + const overlayMap = useOverlayMap(overlays); + + function toggleOverlay(icon_state: string) { + if (overlayMap[icon_state]) { + act('remove_overlay', { removed_overlay: icon_state }); + if (selectedOverlay === icon_state) { + setSelectedOverlay(null); + } + } else { + act('add_overlay', { new_overlay: icon_state }); + } + } + + return ( +
+ + handleImportData(files)} + > + Import + + + + + + + } + > + + +
} + > + + setSelectedOverlay(selectedOverlay === id ? null : id) + } + /> +
+
+ + {!selectedOverlay ? ( +
+ + Select an active overlay to edit its settings. + +
+ ) : ( +
+ + + + + } + > + + + {overlayMap[selectedOverlay].icon_state} + + + + + + + + + + + {Math.round( + ((overlayMap[selectedOverlay].alpha ?? 255) / 255) * + 100, + )} + % + + + act('set_overlay_alpha', { + icon_state: overlayMap[selectedOverlay].icon_state, + alpha: val, + }) + } + /> + + + +
+ )} +
+
+
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/function.ts b/tgui/packages/tgui/interfaces/PlushieEditor/function.ts new file mode 100644 index 0000000000..104d56b8f1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/function.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { useBackend } from 'tgui/backend'; +import type { Overlay, PlushieConfig } from './types'; + +export function downloadJson(filename: string, data: PlushieConfig) { + const blob = new Blob([JSON.stringify(data)], { + type: 'application/json', + }); + Byond.saveBlob(blob, filename, '.json'); +} + +export function handleImportData( + importString: string | string[], +): PlushieConfig | null { + const { act } = useBackend(); + const ourInput = Array.isArray(importString) ? importString[0] : importString; + try { + const parsedData: PlushieConfig = JSON.parse(ourInput); + act('import_config', { config: parsedData }); + } catch (err) { + console.error('Failed to parse JSON:', err); + } + return null; +} + +export function useOverlayMap(overlays: Overlay[]) { + return useMemo( + () => Object.fromEntries(overlays.map((o) => [o.icon_state, o])), + [overlays], + ); +} diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/index.tsx b/tgui/packages/tgui/interfaces/PlushieEditor/index.tsx new file mode 100644 index 0000000000..c3ed24fa6d --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/index.tsx @@ -0,0 +1,28 @@ +import type React from 'react'; +import { useState } from 'react'; +import { Window } from 'tgui/layouts'; +import { Stack } from 'tgui-core/components'; +import { PreviewPanel } from './EditorElements/PreviewPanel'; +import { SidebarPanel } from './EditorElements/SidePanel'; + +export const PlushieEditor: React.FC = () => { + const [selectedOverlay, setSelectedOverlay] = useState(null); + + return ( + + + + + + + + + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/PlushieEditor/types.tsx b/tgui/packages/tgui/interfaces/PlushieEditor/types.tsx new file mode 100644 index 0000000000..f6b5adc520 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlushieEditor/types.tsx @@ -0,0 +1,22 @@ +export type Data = { + name: string; + base_color: string; + icon: string; + preview: string; + possible_overlays: { name: string; icon_state: string }[]; + overlays: Overlay[]; +}; + +export type Overlay = { + icon_state: string; + name?: string; + color?: string; + alpha?: number; +}; + +export type PlushieConfig = { + name: string; + base_color: string; + icon: string; + overlays: Overlay[]; +}; diff --git a/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelEditCheckboxes.tsx b/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelEditCheckboxes.tsx index 3902e4ff1b..1e9a190c8c 100644 --- a/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelEditCheckboxes.tsx +++ b/tgui/packages/tgui/interfaces/VorePanel/VorePanelElements/VorePanelEditCheckboxes.tsx @@ -39,7 +39,7 @@ export const VorePanelEditCheckboxes = (props: { {options.map((value) => ( diff --git a/tgui/packages/tgui/styles/interfaces/PlushEditor.scss b/tgui/packages/tgui/styles/interfaces/PlushEditor.scss new file mode 100644 index 0000000000..4a2a2a60d6 --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/PlushEditor.scss @@ -0,0 +1,13 @@ +.PlushEditor__Floating { + height: 450px; + background-color: var(--color-section); + backdrop-filter: var(--blur-medium); + border: var(--border-thickness-tiny) solid var(--color-border); + border-radius: var(--border-radius-medium); + box-shadow: var(--shadow-glow-medium) hsla(0, 0%, 0%, 0.5); + margin: var(--space-m); + color: var(--color-text); + overflow-y: scroll; + overflow-x: hidden; + padding: var(--space-m); +} diff --git a/tgui/packages/tgui/styles/interfaces/VorePanel.scss b/tgui/packages/tgui/styles/interfaces/VorePanel.scss index 51216dfa1c..8d76c4c012 100644 --- a/tgui/packages/tgui/styles/interfaces/VorePanel.scss +++ b/tgui/packages/tgui/styles/interfaces/VorePanel.scss @@ -1,4 +1,4 @@ -.VorePanel__fLoating { +.VorePanel__Floating { height: 200px; background-color: var(--color-section); backdrop-filter: var(--blur-medium); diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index 504326892f..4a50326095 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -56,6 +56,7 @@ @include meta.load-css('./interfaces/TicketPanel.scss'); @include meta.load-css('./interfaces/VorePanel.scss'); @include meta.load-css('./interfaces/Wires.scss'); +@include meta.load-css('./interfaces/PlushEditor.scss'); // Layouts @include meta.load-css('./layouts/Layout.scss'); diff --git a/vorestation.dme b/vorestation.dme index c97056e1cc..a54e3a84ed 100644 --- a/vorestation.dme +++ b/vorestation.dme @@ -1594,6 +1594,7 @@ #include "code\game\objects\items\toys\godfigures.dm" #include "code\game\objects\items\toys\mech_toys.dm" #include "code\game\objects\items\toys\target_toy.dm" +#include "code\game\objects\items\toys\toy_customizable.dm" #include "code\game\objects\items\toys\toys.dm" #include "code\game\objects\items\toys\toys_vr.dm" #include "code\game\objects\items\weapons\AI_modules.dm"