[Nova Port] Updated Interact Panel UI (#5233)

## About The Pull Request
Original PR: https://github.com/NovaSector/NovaSector/pull/6736

<img width="499" height="601" alt="image"
src="https://github.com/user-attachments/assets/05503d48-b021-4d35-9360-8f0c4b0180e3"
/>

## Why It's Good For The Game
The old UI was super outdated.
## Proof Of Testing
See the image above.
## Changelog
🆑 Adeheuer, xPokee
qol: Updated the Interaction Panel UI.
/🆑
This commit is contained in:
Alexis
2026-02-08 17:20:24 -05:00
committed by GitHub
parent c2de58300f
commit 19f97ffa33
8 changed files with 500 additions and 140 deletions
@@ -67,7 +67,7 @@
/datum/component/interactable/ui_interact(mob/user, datum/tgui/ui)
ui = SStgui.try_update_ui(user, src, ui)
if(!ui)
ui = new(user, src, "InteractionMenu")
ui = new(user, src, "InteractionPanel")
ui.open()
/datum/component/interactable/ui_status(mob/user, datum/ui_state/state)
@@ -76,6 +76,11 @@
return UI_INTERACTIVE // This UI is always interactive as we handle distance flags via can_interact
/datum/component/interactable/ui_static_data(mob/user)
var/list/data = list()
data["arousalLimit"] = AROUSAL_LIMIT
return data
/datum/component/interactable/ui_data(mob/user)
var/list/data = list()
var/list/descriptions = list()
@@ -106,6 +111,34 @@
data["self"] = body_relay.name
data["block_interact"] = interact_next >= world.time
data["interactions"] = categories
data["erp_interaction"] = self.client?.prefs?.read_preference(/datum/preference/toggle/erp)
var/mob/living/carbon/human/human_user = user
data["isTargetSelf"] = (user == self)
// user (the one who opened the ui)
var/user_pleasure = 0
var/user_arousal = 0
var/user_pain = 0
if(user)
user_pleasure = human_user.pleasure
user_arousal = human_user.arousal
user_pain = human_user.pain
data["pleasure"] = user_pleasure
data["arousal"] = user_arousal
data["pain"] = user_pain
data["yourName"] = human_user.real_name
// self - the one who the interaction component belongs to, aka who it's opened on (confusing var name yep)
if(user != self)
data["theirPleasure"] = self.pleasure
data["theirArousal"] = self.arousal
data["theirPain"] = self.pain
data["theirName"] = self.real_name
var/list/parts = list()
@@ -1,139 +0,0 @@
// THIS IS A SKYRAT UI FILE
import {
Box,
Button,
Collapsible,
Icon,
NoticeBox,
Section,
Stack,
} from 'tgui-core/components';
import { useBackend } from '../backend';
import { Window } from '../layouts';
class Interaction {
categories;
interactions;
descriptions;
colors;
lewd_slots: LewdSlot[];
self;
ref_self;
ref_user;
block_interact;
}
class LewdSlot {
img;
name;
}
export const InteractionMenu = (props) => {
const { act, data } = useBackend<Interaction>();
const {
categories = [],
interactions,
descriptions,
colors,
lewd_slots,
self,
ref_self,
ref_user,
block_interact,
} = data;
return (
<Window width={500} height={600} title={`Interact - ${self}`}>
<Window.Content scrollable>
{(block_interact && <NoticeBox>Unable to Interact</NoticeBox>) || (
<NoticeBox>Able to Interact</NoticeBox>
)}
<Stack vertical>
<Section key="interactions">
{categories.map((category) => (
<Collapsible key={category} title={category}>
<Section fill>
<Box mt={0.2}>
{interactions[category].map((interaction) => (
<Button
key={interaction}
width="150.5px"
lineHeight={1.75}
disabled={block_interact}
color={block_interact ? 'grey' : colors[interaction]}
content={interaction}
tooltip={descriptions[interaction]}
icon="exclamation-circle"
onClick={() =>
act('interact', {
interaction: interaction,
selfref: ref_self,
userref: ref_user,
})
}
/>
))}
</Box>
</Section>
</Collapsible>
))}
</Section>
{lewd_slots.length > 0 ? (
<Section key="item_slots" title={'Lewd Slots'}>
<Stack fill>
{lewd_slots.map((element: LewdSlot) => {
return (
<Stack.Item key={element.name}>
<Button
onClick={() =>
act('remove_lewd_item', {
item_slot: element.name,
selfref: ref_self,
userref: ref_user,
})
}
color="pink"
tooltip={element.name}
>
<Box
style={{
width: '32px',
height: '32px',
margin: '0.5em 0',
}}
>
{element.img ? (
<img
src={`data:image/png;base64,${element.img}`}
style={{
width: '100%',
height: '100%',
}}
/>
) : (
<Icon
name="eye-slash"
size={2}
ml={0}
mt={0.75}
style={{
textAlign: 'center',
}}
/>
)}
</Box>
</Button>
</Stack.Item>
);
})}
</Stack>
</Section>
) : (
''
)}
</Stack>
</Window.Content>
</Window>
);
};
@@ -0,0 +1,110 @@
// THIS IS A BUBBER UI FILE
import { Icon, ProgressBar, Section, Stack } from 'tgui-core/components';
import type { BooleanLike } from 'tgui-core/react';
import { useBackend } from '../../backend';
type HeaderInfo = {
isTargetSelf: BooleanLike;
pleasure: number;
arousal: number;
pain: number;
theirPleasure: number;
theirArousal: number;
theirPain: number;
arousalLimit: number;
theirName: string;
yourName: string;
};
export const InfoSection = () => {
const { data } = useBackend<HeaderInfo>();
const {
isTargetSelf,
pleasure,
arousal,
pain,
theirPleasure,
theirArousal,
theirPain,
yourName,
theirName,
arousalLimit,
} = data;
return (
<Section fill>
<Stack vertical fill>
<Stack.Item grow>
<Stack>
<Stack.Item grow>{yourName}</Stack.Item>
{!isTargetSelf ? <Stack.Item grow>{theirName}</Stack.Item> : null}
</Stack>
</Stack.Item>
<Stack.Item>
<Stack fill>
<Stack.Item grow>
<Stack vertical>
<Stack.Item>
<ProgressBar
value={pleasure}
maxValue={arousalLimit}
color="purple"
>
<Icon name="heart" /> Pleasure
</ProgressBar>
</Stack.Item>
<Stack.Item>
<ProgressBar
value={arousal}
maxValue={arousalLimit}
color="pink"
>
<Icon name="tint" /> Arousal
</ProgressBar>
</Stack.Item>
<Stack.Item>
<ProgressBar value={pain} maxValue={arousalLimit} color="red">
<Icon name="bolt" /> Pain
</ProgressBar>
</Stack.Item>
</Stack>
</Stack.Item>
{!isTargetSelf ? (
<Stack.Item grow>
<Stack vertical>
<Stack.Item>
<ProgressBar
value={theirPleasure}
maxValue={arousalLimit}
color="purple"
>
<Icon name="heart" /> Pleasure
</ProgressBar>
</Stack.Item>
<Stack.Item>
<ProgressBar
value={theirArousal}
maxValue={arousalLimit}
color="pink"
>
<Icon name="tint" /> Arousal
</ProgressBar>
</Stack.Item>
<Stack.Item>
<ProgressBar
value={theirPain}
maxValue={arousalLimit}
color="red"
>
<Icon name="bolt" /> Pain
</ProgressBar>
</Stack.Item>
</Stack>
</Stack.Item>
) : null}
</Stack>
</Stack.Item>
</Stack>
</Section>
);
};
@@ -0,0 +1,94 @@
// THIS IS A BUBBER UI FILE
import { useState } from 'react';
import {
Button,
Icon,
Input,
Section,
Stack,
Tabs,
} from 'tgui-core/components';
import type { BooleanLike } from 'tgui-core/react';
import { useBackend } from '../../backend';
type Interaction = {
erp_interaction: BooleanLike;
};
import { InteractionsTab, LewdItemsTab } from './tabs';
export const MainContent = () => {
const [searchText, setSearchText] = useState('');
const [tabIndex, setTabIndex] = useState(0);
const [showCategories, setShowCategories] = useState(true);
const { data } = useBackend<Interaction>();
const { erp_interaction } = data;
const placeholder =
tabIndex === 0
? 'Search for an interaction'
: tabIndex === 1
? 'Search for an item'
: 'Searching is unavailable for this tab';
return (
<Section fill>
<Stack vertical fill>
<Stack.Item>
<Tabs fluid textAlign="center">
<Tabs.Tab selected={tabIndex === 0} onClick={() => setTabIndex(0)}>
Interactions
</Tabs.Tab>
{erp_interaction && (
<Tabs.Tab
selected={tabIndex === 1}
onClick={() => setTabIndex(1)}
>
Lewd Items
</Tabs.Tab>
)}
</Tabs>
</Stack.Item>
<Stack.Item>
<Stack align="baseline" fill>
<Stack.Item>
<Icon name="search" />
</Stack.Item>
<Stack.Item grow>
<Input
fluid
value={searchText}
placeholder={placeholder}
onChange={(value) => setSearchText(value)}
/>
</Stack.Item>
{tabIndex === 0 && (
<Stack.Item>
<Button
icon={showCategories ? 'folder' : 'list'}
color="green"
tooltip={
showCategories ? 'Hide Categories' : 'Show Categories'
}
onClick={() => setShowCategories(!showCategories)}
/>
</Stack.Item>
)}
</Stack>
</Stack.Item>
<Stack.Item grow mb={-1.6}>
<Section fill>
{tabIndex === 1 ? (
<LewdItemsTab searchText={searchText} />
) : (
<InteractionsTab
searchText={searchText}
showCategories={showCategories}
/>
)}
</Section>
</Stack.Item>
</Stack>
</Section>
);
};
@@ -0,0 +1,39 @@
// THIS IS A NOVA SECTOR UI FILE
import { Section, Stack } from 'tgui-core/components';
import type { BooleanLike } from 'tgui-core/react';
import { useBackend } from '../../backend';
import { Window } from '../../layouts';
import { InfoSection } from './InfoSection';
import { MainContent } from './MainContent';
type Interaction = {
self;
erp_interaction: BooleanLike;
};
export function InteractionPanel() {
const { act, data } = useBackend<Interaction>();
const { self, erp_interaction } = data;
return (
<Window width={500} height={600} title={`Interact - ${self}`}>
<Window.Content scrollable>
{erp_interaction && (
<Section>
<Stack vertical fill>
<Stack.Item grow>
<InfoSection />
</Stack.Item>
</Stack>
</Section>
)}
<Stack>
<Stack.Item grow>
<MainContent />
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
}
@@ -0,0 +1,135 @@
// THIS IS A BUBBER UI FILE
import { useMemo } from 'react';
import {
Box,
Button,
Collapsible,
NoticeBox,
Section,
Stack,
} from 'tgui-core/components';
import type { BooleanLike } from 'tgui-core/react';
import { useBackend } from '../../../backend';
type Interaction = {
categories: string[];
interactions: Record<string, string[]>;
descriptions: Record<string, string>;
colors: Record<string, string>;
self: string;
ref_self: string;
ref_user: string;
block_interact: BooleanLike;
};
type InteractionsTabProps = {
searchText: string;
showCategories: boolean;
};
export const InteractionsTab = (props: InteractionsTabProps) => {
const { act, data } = useBackend<Interaction>();
const {
categories = [],
interactions = {},
descriptions = {},
colors = {},
ref_self,
ref_user,
block_interact,
} = data;
const { searchText, showCategories } = props;
const searchLower = searchText.toLowerCase();
const renderInteractionButton = (interaction: string) => {
return (
<Button
key={interaction}
width="150px"
lineHeight={1.75}
disabled={block_interact}
color={block_interact ? 'grey' : colors[interaction]}
tooltip={
block_interact
? 'You cannot interact right now'
: descriptions[interaction]
}
icon="exclamation-circle"
onClick={() =>
act('interact', {
interaction: interaction,
selfref: ref_self,
userref: ref_user,
})
}
>
{interaction}
</Button>
);
};
const filterInteractions = (category: string) => {
let categoryInteractions = interactions[category] || [];
if (searchText) {
categoryInteractions = categoryInteractions.filter((interaction) =>
interaction.toLowerCase().includes(searchLower),
);
}
return categoryInteractions;
};
const allInteractions = useMemo(() => {
return categories.flatMap((category) =>
filterInteractions(category).map((interaction) => ({
name: interaction,
category,
})),
);
}, [categories, searchLower]);
return (
<Stack fill vertical>
<NoticeBox>
{block_interact ? 'Unable to Interact' : 'Able to Interact'}
</NoticeBox>
<Stack.Item grow>
{showCategories ? (
categories.map((category) => {
const filteredInteractions = filterInteractions(category);
if (filteredInteractions.length === 0) return null;
return (
<Collapsible
key={category}
title={category}
buttons={
<Box inline color="grey" fontSize={0.9}>
{filteredInteractions.length}
{' interactions'}
</Box>
}
>
<Section fill>
<Box mt={0.2}>
{filteredInteractions.map((interaction) =>
renderInteractionButton(interaction),
)}
</Box>
</Section>
</Collapsible>
);
})
) : (
<Section fill>
<Box mt={0.2}>
{allInteractions.map(({ name, category }) =>
renderInteractionButton(name),
)}
</Box>
</Section>
)}
</Stack.Item>
</Stack>
);
};
@@ -0,0 +1,85 @@
// THIS IS A BUBBER UI FILE
import { Box, Button, Icon, Stack } from 'tgui-core/components';
import { useBackend } from '../../../backend';
type LewdSlot = {
img: string;
name: string;
item_name: string;
lewd_slots: LewdSlot[];
ref_self: string;
ref_user: string;
};
type LewdItemsTabPropsData = {
searchText: string;
};
export const LewdItemsTab = (props: LewdItemsTabPropsData) => {
const { act, data } = useBackend<LewdSlot>();
const { lewd_slots = [], ref_self, ref_user } = data;
const { searchText } = props;
const searchLower = searchText.toLowerCase();
const filteredSlots = lewd_slots.filter((slot) => {
return (
slot.name.toLowerCase().includes(searchLower) ||
slot.item_name?.toLowerCase().includes(searchLower)
);
});
return (
<Stack fill>
{filteredSlots.length > 0 && (
<Stack fill>
{filteredSlots.map((slot) => {
return (
<Stack.Item key={slot.name}>
<Button
onClick={() =>
act('remove_lewd_item', {
item_slot: slot.name,
selfref: ref_self,
userref: ref_user,
})
}
color="pink"
tooltip={`${slot.name}${slot.item_name ? ` - ${slot.item_name}` : ''}`}
>
<Box
style={{
width: '32px',
height: '32px',
margin: '0.5em 0',
}}
>
{slot.img ? (
<img
src={`data:image/png;base64,${slot.img}`}
style={{
width: '100%',
height: '100%',
}}
/>
) : (
<Icon
name="eye-slash"
size={2}
ml={0}
mt={0.75}
style={{
textAlign: 'center',
}}
/>
)}
</Box>
</Button>
</Stack.Item>
);
})}
</Stack>
)}
</Stack>
);
};
@@ -0,0 +1,3 @@
// THIS IS A BUBBER UI FILE
export { InteractionsTab } from './InteractionsTab';
export { LewdItemsTab } from './LewdItemsTab';