[MIRROR] Customizable dragon plushie! (#11717)

Co-authored-by: MeepleMuncher <76881946+MeepleMuncher@users.noreply.github.com>
Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2025-09-21 15:41:42 -07:00
committed by GitHub
parent 349ac807c4
commit 3fd22e8106
15 changed files with 686 additions and 2 deletions

View File

@@ -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"
)

View File

@@ -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))

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -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<React.SetStateAction<string | null>>;
};
export const ActiveOverlaysGrid = memo<ActiveOverlaysGridProps>(
({ icon, overlays, selectedOverlay, onSelect }) => (
<Stack wrap="wrap" justify="center">
{overlays.map((ov) => (
<Stack.Item key={ov.icon_state}>
<ImageButton
dmIcon={icon}
dmIconState={ov.icon_state}
onClick={() => onSelect(ov.icon_state)}
color={selectedOverlay === ov.icon_state ? 'green' : undefined}
>
{ov.name}
</ImageButton>
</Stack.Item>
))}
</Stack>
),
);

View File

@@ -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<OverlayModalProps> = ({
toggleOverlay,
}) => {
const { data } = useBackend<Data>();
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 (
<Floating
placement="bottom-end"
contentClasses="PlushEditor__Floating"
content={
<Section title="Overlays" scrollable>
<Stack vertical fill>
<Stack.Item>
<Input
placeholder="Search overlays..."
value={filter}
onChange={setFilter}
fluid
/>
</Stack.Item>
<Box>
{filteredPossible.map(({ name, icon_state }) => (
<ImageButton
key={icon_state}
dmIcon={icon}
dmIconState={icon_state}
onClick={() => toggleOverlay(icon_state)}
color={overlayMap[icon_state] ? 'green' : 'red'}
fluid
align="start"
>
{name}
</ImageButton>
))}
</Box>
</Stack>
</Section>
}
>
<Box className="VorePanel__floatingButton">+/-</Box>
</Floating>
);
};

View File

@@ -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<SetStateAction<string | null>>;
};
export const PreviewPanel: React.FC<PreviewPanelProps> = ({
setSelectedOverlay,
}) => {
const { act, data } = useBackend<Data>();
const { base_color, preview, name } = data;
return (
<Section
fill
title="Preview"
buttons={
<Button.Confirm
icon="trash"
color="red"
tooltip="Reset the edits"
onClick={() => {
setSelectedOverlay(null);
act('clear');
}}
>
Clear
</Button.Confirm>
}
>
<Stack vertical fill>
<Stack.Item>
<Box
p={2}
align="center"
width="100%"
height="320px"
style={{
borderRadius: 16,
border: '1px solid rgba(255,255,255,0.1)',
}}
>
<Box m="auto" minWidth="256px" minHeight="256px">
<Image
src={`data:image/jpeg;base64,${preview}`}
style={{ width: '256px', height: '256px' }}
/>
</Box>
</Box>
</Stack.Item>
<Stack.Divider />
<Stack.Item>
<LabeledList>
<LabeledList.Item label="Name">
<Input
fluid
value={name}
onChange={(value) => act('rename', { name: value })}
/>
</LabeledList.Item>
<LabeledList.Item label="Base color">
<Stack align="center" justify="space-between">
<ColorBox color={base_color} mr={2} />
<Button icon="brush" onClick={() => act('change_base_color')}>
Recolor
</Button>
</Stack>
</LabeledList.Item>
</LabeledList>
</Stack.Item>
</Stack>
</Section>
);
};

View File

@@ -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<React.SetStateAction<string | null>>;
};
export const SidebarPanel: React.FC<SidebarPanelProps> = ({
selectedOverlay,
setSelectedOverlay,
}) => {
const { act, data } = useBackend<Data>();
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 (
<Section
title="Editor"
fill
buttons={
<Stack>
<Stack.Item>
<Button.File
accept=".json"
tooltip="Import plushie data"
icon="upload"
onSelectFiles={(files) => handleImportData(files)}
>
Import
</Button.File>
</Stack.Item>
<Stack.Item>
<Button
icon="download"
onClick={() =>
downloadJson(`plushie-${name}-config.json`, {
name: name,
base_color: base_color,
icon: icon,
overlays: overlays,
})
}
>
Export
</Button>
</Stack.Item>
</Stack>
}
>
<Stack vertical fill>
<Stack.Item grow>
<Section
title={`Overlays (${overlays.length} active)`}
fill
scrollable
buttons={<OverlaySelector toggleOverlay={toggleOverlay} />}
>
<ActiveOverlaysGrid
icon={icon}
overlays={overlays}
selectedOverlay={selectedOverlay}
onSelect={(id) =>
setSelectedOverlay(selectedOverlay === id ? null : id)
}
/>
</Section>
</Stack.Item>
<Stack.Item basis="35%">
{!selectedOverlay ? (
<Section fill title="Overlay details">
<NoticeBox>
Select an active overlay to edit its settings.
</NoticeBox>
</Section>
) : (
<Section
title="Overlay details"
fill
buttons={
<Stack>
<Stack.Item>
<Button
icon="arrow-up"
onClick={() =>
act('move_overlay_up', {
icon: overlayMap[selectedOverlay].icon_state,
})
}
/>
</Stack.Item>
<Stack.Item>
<Button
icon="arrow-down"
onClick={() =>
act('move_overlay_down', {
icon: overlayMap[selectedOverlay].icon_state,
})
}
/>
</Stack.Item>
<Stack.Item>
<Button
icon="trash"
backgroundColor="#d63939ff"
onClick={() =>
toggleOverlay(overlayMap[selectedOverlay].icon_state)
}
>
Remove
</Button>
</Stack.Item>
</Stack>
}
>
<LabeledList>
<LabeledList.Item label="Overlay">
{overlayMap[selectedOverlay].icon_state}
</LabeledList.Item>
<LabeledList.Item label="Color">
<Stack align="center" justify="space-between">
<ColorBox
color={overlayMap[selectedOverlay].color}
mr={2}
/>
<Button
icon="brush"
onClick={() =>
act('change_overlay_color', {
icon_state: overlayMap[selectedOverlay].icon_state,
})
}
>
Edit Overlay Color
</Button>
</Stack>
</LabeledList.Item>
<LabeledList.Item label="Opacity">
<Stack align="center">
<Box width={12} mr={1} textAlign="right">
{Math.round(
((overlayMap[selectedOverlay].alpha ?? 255) / 255) *
100,
)}
%
</Box>
<Slider
value={overlayMap[selectedOverlay].alpha ?? 255}
minValue={0}
maxValue={255}
step={5}
onChange={(e, val) =>
act('set_overlay_alpha', {
icon_state: overlayMap[selectedOverlay].icon_state,
alpha: val,
})
}
/>
</Stack>
</LabeledList.Item>
</LabeledList>
</Section>
)}
</Stack.Item>
</Stack>
</Section>
);
};

View File

@@ -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],
);
}

View File

@@ -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<string | null>(null);
return (
<Window width={780} height={560}>
<Window.Content scrollable>
<Stack fill>
<Stack.Item grow>
<PreviewPanel setSelectedOverlay={setSelectedOverlay} />
</Stack.Item>
<Stack.Item grow>
<SidebarPanel
selectedOverlay={selectedOverlay}
setSelectedOverlay={setSelectedOverlay}
/>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
};

View File

@@ -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[];
};

View File

@@ -39,7 +39,7 @@ export const VorePanelEditCheckboxes = (props: {
<Stack.Item>
<Floating
placement="bottom-end"
contentClasses="VorePanel__fLoating"
contentClasses="VorePanel__Floating"
content={
<Stack vertical fill>
{options.map((value) => (

View File

@@ -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);
}

View File

@@ -1,4 +1,4 @@
.VorePanel__fLoating {
.VorePanel__Floating {
height: 200px;
background-color: var(--color-section);
backdrop-filter: var(--blur-medium);

View File

@@ -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');

View File

@@ -1662,6 +1662,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_ch.dm"
#include "code\game\objects\items\toys\toys_vr.dm"