[tgui] uselocalstate maintenance 2 (#92716)

## About The Pull Request
Refactors much of spellbook to rid it of some bad conventions and
improves style
Replaces use of uselocalstate -> useAtom
Moves spellbook to a folder to improve readability
## Why It's Good For The Game
useLocalState is deprecated
## Changelog
This commit is contained in:
Jeremiah
2025-09-03 01:01:44 -07:00
committed by GitHub
parent 479759521d
commit 50d609242d
13 changed files with 875 additions and 792 deletions
-792
View File
@@ -1,792 +0,0 @@
import type { ReactNode } from 'react';
import {
Box,
Button,
Dimmer,
Divider,
Icon,
Input,
NoticeBox,
ProgressBar,
Section,
Stack,
} from 'tgui-core/components';
import type { BooleanLike } from 'tgui-core/react';
import { useBackend, useLocalState } from '../backend';
import { Window } from '../layouts';
enum SpellCategory {
Offensive = 'Offensive',
Defensive = 'Defensive',
Mobility = 'Mobility',
Assistance = 'Assistance',
Rituals = 'Rituals',
Perks = 'Perks',
}
type byondRef = string;
type SpellEntry = {
// Name of the spell
name: string;
// Description of what the spell does
desc: string;
// Byond REF of the spell entry datum
ref: byondRef;
// Whether the spell requires wizard clothing to cast
requires_wizard_garb: BooleanLike;
// Spell points required to buy the spell
cost: number;
// How many times the spell has been bought
times: number;
// Cooldown length of the spell once cast once
cooldown: number;
// Category of the spell
cat: SpellCategory;
// Whether the spell is refundable
refundable: BooleanLike;
// The verb displayed when buying
buyword: Buywords;
};
type Data = {
owner: string;
points: number;
semi_random_bonus: number;
full_random_bonus: number;
entries: SpellEntry[];
};
type TabType = {
title: string;
blurb?: string;
component?: () => ReactNode;
locked?: boolean;
scrollable?: boolean;
};
const TAB2NAME: TabType[] = [
{
title: 'Enscribed Name',
blurb:
"This book answers only to its owner, and of course, must have one. The permanence of the pact between a spellbook and its owner ensures such a powerful artifact cannot fall into enemy hands, or be used in ways that break the Federation's rules such as bartering spells.",
component: () => <EnscribedName />,
},
{
title: 'Table of Contents',
component: () => <TableOfContents />,
},
{
title: 'Offensive',
blurb: 'Spells and items geared towards debilitating and destroying.',
scrollable: true,
},
{
title: 'Defensive',
blurb:
"Spells and items geared towards improving your survivability or reducing foes' ability to attack.",
scrollable: true,
},
{
title: 'Mobility',
blurb:
'Spells and items geared towards improving your ability to move. It is a good idea to take at least one.',
scrollable: true,
},
{
title: 'Assistance',
blurb:
'Spells and items geared towards bringing in outside forces to aid you or improving upon your other items and abilities.',
scrollable: true,
},
{
title: 'Challenges',
blurb:
'The Wizard Federation is looking for shows of power. Arming the station against you will increase the danger, but will grant you more charges for your spellbook.',
locked: true,
scrollable: true,
},
{
title: 'Rituals',
blurb:
'These powerful spells change the very fabric of reality. Not always in your favour.',
scrollable: true,
},
{
title: 'Loadouts',
blurb:
'The Wizard Federation accepts that sometimes, choosing is hard. You can choose from some approved wizard loadouts here.',
component: () => <Loadouts />,
},
{
title: 'Randomize',
blurb:
"If you didn't like the loadouts offered, you can embrace chaos. Not recommended for newer wizards.",
component: () => <Randomize />,
},
{
title: 'Perks',
blurb:
'Perks are useful (and not so useful) improvements to the soul and body collected from all corners of the universe.',
scrollable: true,
},
{
title: 'Table of Contents',
component: () => <TableOfContents />,
},
];
enum Buywords {
Learn = 'Learn',
Summon = 'Summon',
Cast = 'Cast',
}
const BUYWORD2ICON = {
Learn: 'plus',
Summon: 'hat-wizard',
Cast: 'meteor',
};
const EnscribedName = (props) => {
const { data } = useBackend<Data>();
const { owner } = data;
return (
<>
<Box
mt={25}
mb={-3}
fontSize="50px"
color="bad"
textAlign="center"
fontFamily="Ink Free"
>
{owner}
</Box>
<Divider />
</>
);
};
const lineHeightToc = '30.6px';
const TableOfContents = (props) => {
const [tabIndex, setTabIndex] = useLocalState('tab-index', 1);
return (
<Box textAlign="center">
<Button
lineHeight={lineHeightToc}
fluid
icon="pen"
disabled
content="Name Enscription"
/>
<Button
lineHeight={lineHeightToc}
fluid
icon="clipboard"
disabled
content="Table of Contents"
/>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="fire"
content="Deadly Evocations"
onClick={() => setTabIndex(3)}
/>
<Button
lineHeight={lineHeightToc}
fluid
icon="shield-alt"
content="Defensive Evocations"
onClick={() => setTabIndex(3)}
/>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="globe-americas"
content="Magical Transportation"
onClick={() => setTabIndex(5)}
/>
<Button
lineHeight={lineHeightToc}
fluid
icon="users"
content="Assistance and Summoning"
onClick={() => setTabIndex(5)}
/>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="crown"
content="Challenges"
onClick={() => setTabIndex(7)}
/>
<Button
lineHeight={lineHeightToc}
fluid
icon="magic"
content="Rituals"
onClick={() => setTabIndex(7)}
/>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="thumbs-up"
content="Wizard Approved Loadouts"
onClick={() => setTabIndex(9)}
/>
<Button
lineHeight={lineHeightToc}
fluid
icon="dice"
content="Arcane Randomizer"
onClick={() => setTabIndex(9)}
/>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="cog"
content="Perks"
onClick={() => setTabIndex(11)}
/>
</Box>
);
};
const LockedPage = (props) => {
const { act, data } = useBackend<Data>();
const { owner } = data;
return (
<Dimmer>
<Stack vertical>
<Stack.Item>
<Icon color="purple" name="lock" size={10} />
</Stack.Item>
<Stack.Item fontSize="18px" color="purple">
The Wizard Federation has locked this page.
</Stack.Item>
</Stack>
</Dimmer>
);
};
const PointLocked = (props) => {
return (
<Dimmer>
<Stack vertical>
<Stack.Item>
<Icon color="purple" name="dollar-sign" size={10} />
<div
style={{
background: 'purple',
bottom: '60%',
left: '33%',
height: '10px',
position: 'relative',
transform: 'rotate(45deg)',
width: '150px',
}}
/>
</Stack.Item>
<Stack.Item fontSize="18px" color="purple">
You do not have enough points to use this page.
</Stack.Item>
</Stack>
</Dimmer>
);
};
type WizardLoadout = {
loadoutId: string;
loadoutColor: string;
name: string;
blurb: string;
icon: string;
author: string;
};
const SingleLoadout = (props: WizardLoadout) => {
const { act } = useBackend<WizardLoadout>();
const { author, name, blurb, icon, loadoutId, loadoutColor } = props;
return (
<Stack.Item grow>
<Section width={LoadoutWidth} title={name}>
{blurb}
<Divider />
<Button.Confirm
confirmContent="Confirm Purchase?"
confirmIcon="dollar-sign"
confirmColor="good"
fluid
icon={icon}
content="Purchase Loadout"
onClick={() =>
act('purchase_loadout', {
id: loadoutId,
})
}
/>
<Divider />
<Box color={loadoutColor}>Added by {author}.</Box>
</Section>
</Stack.Item>
);
};
const LoadoutWidth = 19.17;
const Loadouts = (props) => {
const { data } = useBackend<Data>();
const { points } = data;
// Future todo : Make these datums on the DM side
return (
<Stack ml={0.5} mt={-0.5} vertical fill>
{points < 10 && <PointLocked />}
<Stack.Item>
<Stack fill>
<SingleLoadout
loadoutId="loadout_classic"
loadoutColor="purple"
name="The Classic Wizard"
icon="fire"
author="Archchancellor Gray"
blurb={`
This is the classic wizard, crazy popular in
the 2550's. Comes with Fireball, Magic Missile,
Ei Nath, and Ethereal Jaunt. The key here is that
every part of this kit is very easy to pick up and use.
`}
/>
<SingleLoadout
name="Mjolnir's Power"
icon="hammer"
loadoutId="loadout_hammer"
loadoutColor="green"
author="Jegudiel Worldshaker"
blurb={`
The power of the mighty Mjolnir! Best not to lose it.
This loadout has Summon Item, Mutate, Blink, Force Wall,
Tesla Blast, and Mjolnir. Mutate is your utility in this case:
Use it for limited ranged fire and getting out of bad blinks.
`}
/>
</Stack>
</Stack.Item>
<Stack.Item>
<Stack fill>
<SingleLoadout
name="Fantastical Army"
icon="pastafarianism"
loadoutId="loadout_army"
loadoutColor="yellow"
author="Prospero Spellstone"
blurb={`
Why kill when others will gladly do it for you?
Embrace chaos with your kit: Soulshards, Staff of Change,
Necro Stone, Teleport, and Jaunt! Remember, no offense spells!
`}
/>
<SingleLoadout
name="Soul Tapper"
icon="skull"
loadoutId="loadout_tap"
loadoutColor="white"
author="Tom the Empty"
blurb={`
Embrace the dark, and tap into your soul.
You can recharge very long recharge spells
like Ei Nath by jumping into new bodies with
Mind Swap and starting Soul Tap anew.
`}
/>
</Stack>
</Stack.Item>
</Stack>
);
};
const lineHeightRandomize = 6;
const Randomize = (props) => {
const { act, data } = useBackend<Data>();
const { points, semi_random_bonus, full_random_bonus } = data;
return (
<Stack fill vertical>
{points < 10 && <PointLocked />}
<Stack.Item>
Semi-Randomize will ensure you at least get some mobility and lethality.
Guaranteed to have {semi_random_bonus} points worth of spells.
</Stack.Item>
<Stack.Item>
<Button.Confirm
confirmContent="Cowabunga it is?"
confirmIcon="dice-three"
lineHeight={lineHeightRandomize}
fluid
icon="dice-three"
content="Semi-Randomize!"
onClick={() => act('semirandomize')}
/>
<Divider />
</Stack.Item>
<Stack.Item>
Full Random will give you anything. There&apos;s no going back, either!
Guaranteed to have {full_random_bonus} points worth of spells.
</Stack.Item>
<Stack.Item>
<NoticeBox danger>
<Button.Confirm
confirmContent="Cowabunga it is?"
confirmIcon="dice"
lineHeight={lineHeightRandomize}
fluid
color="black"
icon="dice"
content="Full Random!"
onClick={() => act('randomize')}
/>
</NoticeBox>
</Stack.Item>
</Stack>
);
};
const SearchSpells = (props) => {
const { data } = useBackend<Data>();
const [spellSearch] = useLocalState('spell-search', '');
const { entries } = data;
const filterEntryList = (entries: SpellEntry[]) => {
const searchStatement = spellSearch.toLowerCase();
if (searchStatement === 'robeless') {
// Lets you just search for robeless spells, you're welcome mindswap-bros
return entries.filter((entry) => !entry.requires_wizard_garb);
}
return entries.filter(
(entry) =>
entry.name.toLowerCase().includes(searchStatement) ||
// Unsure about including description. Wizard spell descriptions
// are painfully original and use the same verbiage often,
// which may both be a benefit and a curse
entry.desc.toLowerCase().includes(searchStatement) ||
// Also opting to include category
// so you can search "rituals" to see them all at once
entry.cat.toLowerCase().includes(searchStatement),
);
};
const filteredEntries = filterEntryList(entries);
if (filteredEntries.length === 0) {
return (
<Stack width="100%" vertical>
<Stack.Item>
<NoticeBox>{`No spells found!`}</NoticeBox>
</Stack.Item>
<Stack.Item>
<Box italic align="center" color="lightgrey">
{`Search tip: Searching "Robeless" will only show you
spells that don't require wizard garb!`}
</Box>
</Stack.Item>
</Stack>
);
}
return (
<SpellTabDisplay
TabSpells={filteredEntries}
CooldownOffset={32}
PointOffset={84}
/>
);
};
const SpellTabDisplay = (props: {
TabSpells: SpellEntry[];
CooldownOffset?: number;
PointOffset?: number;
}) => {
const { act, data } = useBackend<Data>();
const { points } = data;
const { TabSpells, CooldownOffset, PointOffset } = props;
const getTimeOrCat = (entry: SpellEntry) => {
if (entry.cat === SpellCategory.Rituals) {
if (entry.times) {
return `Cast ${entry.times} times.`;
} else {
return 'Not cast yet.';
}
} else {
if (entry.cooldown) {
return `${entry.cooldown}s Cooldown`;
} else {
return '';
}
}
};
return (
<Stack vertical>
{TabSpells.sort((a, b) => a.name.localeCompare(b.name)).map((entry) => (
<Stack.Item key={entry.name}>
<Divider />
<Stack mt={1.3} width="100%" position="absolute" textAlign="left">
<Stack.Item width="120px" ml={CooldownOffset}>
{getTimeOrCat(entry)}
</Stack.Item>
<Stack.Item width="60px" ml={PointOffset}>
{entry.cost} points
</Stack.Item>
{entry.buyword === Buywords.Learn && (
<Stack.Item>
<Button
mt={-0.8}
icon="tshirt"
color={entry.requires_wizard_garb ? 'bad' : 'green'}
tooltipPosition="bottom-start"
tooltip={
entry.requires_wizard_garb
? 'Requires wizard garb.'
: 'Can be cast without wizard garb.'
}
/>
</Stack.Item>
)}
</Stack>
<Section title={entry.name}>
<Stack>
<Stack.Item grow>{entry.desc}</Stack.Item>
<Stack.Item>
<Divider vertical />
</Stack.Item>
<Stack.Item>
<Button
fluid
textAlign="center"
color={points >= entry.cost ? 'green' : 'bad'}
disabled={points < entry.cost}
width={7}
icon={BUYWORD2ICON[entry.buyword]}
content={entry.buyword}
onClick={() =>
act('purchase', {
spellref: entry.ref,
})
}
/>
<br />
{(!entry.refundable && <NoticeBox>No refunds.</NoticeBox>) || (
<Button
textAlign="center"
width={7}
icon="arrow-left"
content="Refund"
onClick={() =>
act('refund', {
spellref: entry.ref,
})
}
/>
)}
</Stack.Item>
</Stack>
</Section>
</Stack.Item>
))}
</Stack>
);
};
const CategoryDisplay = (props: { ActiveCat: TabType }) => {
const { data } = useBackend<Data>();
const { entries } = data;
const { ActiveCat } = props;
const TabSpells = entries.filter((entry) => entry.cat === ActiveCat.title);
return (
<>
{!!ActiveCat.locked && <LockedPage />}
<Stack vertical>
{ActiveCat.blurb && (
<Stack.Item>
<Box textAlign="center" bold height="30px">
{ActiveCat.blurb}
</Box>
</Stack.Item>
)}
<Stack.Item>
{ActiveCat.component?.() || (
<SpellTabDisplay TabSpells={TabSpells} PointOffset={38} />
)}
</Stack.Item>
</Stack>
</>
);
};
const widthSection = '466px';
const heightSection = '456px';
export const Spellbook = (props) => {
const { data } = useBackend<Data>();
const { points } = data;
const [tabIndex, setTabIndex] = useLocalState('tab-index', 1);
const [spellSearch, setSpellSearch] = useLocalState('spell-search', '');
const ActiveCat = TAB2NAME[tabIndex - 1];
const ActiveNextCat = TAB2NAME[tabIndex];
// Has a chance of selecting a random funny verb instead of "Searching"
const SelectSearchVerb = () => {
const found = Math.random();
if (found <= 0.03) {
return 'Seeking';
}
if (found <= 0.06) {
return 'Contemplating';
}
if (found <= 0.09) {
return 'Divining';
}
if (found <= 0.12) {
return 'Scrying';
}
if (found <= 0.15) {
return 'Peeking';
}
if (found <= 0.18) {
return 'Pondering';
}
if (found <= 0.21) {
return 'Divining';
}
if (found <= 0.24) {
return 'Gazing';
}
if (found <= 0.27) {
return 'Studying';
}
if (found <= 0.3) {
return 'Reviewing';
}
return 'Searching';
};
const SelectedVerb = SelectSearchVerb();
return (
<Window title="Spellbook" theme="wizard" width={950} height={540}>
<Window.Content>
<Stack vertical fill>
<Stack.Item>
<Stack fill>
{spellSearch.length > 1 ? (
<Stack.Item grow>
<Section
title={`${SelectedVerb}...`}
scrollable
height={heightSection}
fill
buttons={
<Button
content={`Stop ${SelectedVerb}`}
icon="arrow-rotate-left"
onClick={() => setSpellSearch('')}
/>
}
>
<SearchSpells />
</Section>
</Stack.Item>
) : (
<>
<Stack.Item grow>
<Section
scrollable={ActiveCat.scrollable}
textAlign="center"
width={widthSection}
height={heightSection}
fill
title={ActiveCat.title}
buttons={
<>
<Button
mr={57}
disabled={tabIndex === 1}
icon="arrow-left"
content="Previous Page"
onClick={() => setTabIndex(tabIndex - 2)}
/>
<Box textAlign="right" bold mt={-3.3} mr={1}>
{tabIndex}
</Box>
</>
}
>
<CategoryDisplay ActiveCat={ActiveCat} />
</Section>
</Stack.Item>
<Stack.Item grow>
<Section
scrollable={ActiveNextCat.scrollable}
textAlign="center"
width={widthSection}
height={heightSection}
fill
title={ActiveNextCat.title}
buttons={
<>
<Button
mr={0}
icon="arrow-right"
disabled={tabIndex === 11}
content="Next Page"
onClick={() => setTabIndex(tabIndex + 2)}
/>
<Box textAlign="left" bold mt={-3.3} ml={-59.8}>
{tabIndex + 1}
</Box>
</>
}
>
<CategoryDisplay ActiveCat={ActiveNextCat} />
</Section>
</Stack.Item>
</>
)}
</Stack>
</Stack.Item>
<Stack.Item>
<Section>
<Stack>
<Stack.Item grow>
<ProgressBar value={points / 10}>
{`${points} points left to spend.`}
</ProgressBar>
</Stack.Item>
<Stack.Item>
<Input
width={15}
placeholder="Search for a spell..."
onChange={setSpellSearch}
/>
</Stack.Item>
</Stack>
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
};
@@ -0,0 +1,51 @@
import { Box, Dimmer, Icon, Stack } from 'tgui-core/components';
import { useBackend } from '../../backend';
import { SpellTabDisplay } from './SpellTabDisplay';
import type { SpellbookData, TabType } from './types';
type Props = {
activeCat: TabType;
};
export function CategoryDisplay(props: Props) {
const { data } = useBackend<SpellbookData>();
const { entries } = data;
const { activeCat } = props;
const tabSpells = entries.filter((entry) => entry.cat === activeCat.title);
return (
<>
{!!activeCat.locked && <LockedPage />}
<Stack vertical>
{activeCat.blurb && (
<Stack.Item>
<Box textAlign="center" bold height="30px">
{activeCat.blurb}
</Box>
</Stack.Item>
)}
<Stack.Item>
{activeCat.component?.() || (
<SpellTabDisplay tabSpells={tabSpells} pointOffset={38} />
)}
</Stack.Item>
</Stack>
</>
);
}
function LockedPage(props) {
return (
<Dimmer>
<Stack vertical>
<Stack.Item>
<Icon color="purple" name="lock" size={10} />
</Stack.Item>
<Stack.Item fontSize="18px" color="purple">
The Wizard Federation has locked this page.
</Stack.Item>
</Stack>
</Dimmer>
);
}
@@ -0,0 +1,24 @@
import { Box, Divider } from 'tgui-core/components';
import { useBackend } from '../../backend';
import type { SpellbookData } from './types';
export function EnscribedName(props) {
const { data } = useBackend<SpellbookData>();
const { owner } = data;
return (
<>
<Box
mt={25}
mb={-3}
fontSize="50px"
color="bad"
textAlign="center"
fontFamily="Ink Free"
>
{owner}
</Box>
<Divider />
</>
);
}
@@ -0,0 +1,113 @@
import { Box, Button, Divider, Section, Stack } from 'tgui-core/components';
import { useBackend } from '../../backend';
import { PointLocked } from './Locked';
import type { SpellbookData } from './types';
export function Loadouts(props) {
const { data } = useBackend<SpellbookData>();
const { points } = data;
// Future todo : Make these datums on the DM side
return (
<Stack ml={0.5} mt={-0.5} vertical fill>
{points < 10 && <PointLocked />}
<Stack.Item>
<Stack fill>
<SingleLoadout
loadoutId="loadout_classic"
loadoutColor="purple"
name="The Classic Wizard"
icon="fire"
author="Archchancellor Gray"
blurb="
This is the classic wizard, crazy popular in
the 2550's. Comes with Fireball, Magic Missile,
Ei Nath, and Ethereal Jaunt. The key here is that
every part of this kit is very easy to pick up and use.
"
/>
<SingleLoadout
name="Mjolnir's Power"
icon="hammer"
loadoutId="loadout_hammer"
loadoutColor="green"
author="Jegudiel Worldshaker"
blurb="
The power of the mighty Mjolnir! Best not to lose it.
This loadout has Summon Item, Mutate, Blink, Force Wall,
Tesla Blast, and Mjolnir. Mutate is your utility in this case:
Use it for limited ranged fire and getting out of bad blinks.
"
/>
</Stack>
</Stack.Item>
<Stack.Item>
<Stack fill>
<SingleLoadout
name="Fantastical Army"
icon="pastafarianism"
loadoutId="loadout_army"
loadoutColor="yellow"
author="Prospero Spellstone"
blurb="
Why kill when others will gladly do it for you?
Embrace chaos with your kit: Soulshards, Staff of Change,
Necro Stone, Teleport, and Jaunt! Remember, no offense spells!
"
/>
<SingleLoadout
name="Soul Tapper"
icon="skull"
loadoutId="loadout_tap"
loadoutColor="white"
author="Tom the Empty"
blurb="
Embrace the dark, and tap into your soul.
You can recharge very long recharge spells
like Ei Nath by jumping into new bodies with
Mind Swap and starting Soul Tap anew.
"
/>
</Stack>
</Stack.Item>
</Stack>
);
}
type Props = {
author: string;
blurb: string;
icon: string;
loadoutColor: string;
loadoutId: string;
name: string;
};
function SingleLoadout(props: Props) {
const { act } = useBackend();
const { author, name, blurb, icon, loadoutId, loadoutColor } = props;
return (
<Stack.Item grow>
<Section width={19.17} title={name}>
{blurb}
<Divider />
<Button.Confirm
confirmContent="Confirm Purchase?"
confirmIcon="dollar-sign"
confirmColor="good"
fluid
icon={icon}
onClick={() =>
act('purchase_loadout', {
id: loadoutId,
})
}
>
Purchase Loadout
</Button.Confirm>
<Divider />
<Box color={loadoutColor}>Added by {author}.</Box>
</Section>
</Stack.Item>
);
}
@@ -0,0 +1,27 @@
import { Dimmer, Icon, Stack } from 'tgui-core/components';
export function PointLocked(props) {
return (
<Dimmer>
<Stack vertical>
<Stack.Item>
<Icon color="purple" name="dollar-sign" size={10} />
<div
style={{
background: 'purple',
bottom: '60%',
left: '33%',
height: '10px',
position: 'relative',
transform: 'rotate(45deg)',
width: '150px',
}}
/>
</Stack.Item>
<Stack.Item fontSize="18px" color="purple">
You do not have enough points to use this page.
</Stack.Item>
</Stack>
</Dimmer>
);
}
@@ -0,0 +1,51 @@
import { Button, Divider, NoticeBox, Stack } from 'tgui-core/components';
import { useBackend } from '../../backend';
import { PointLocked } from './Locked';
import type { SpellbookData } from './types';
export function Randomize(props) {
const { act, data } = useBackend<SpellbookData>();
const { points, semi_random_bonus, full_random_bonus } = data;
return (
<Stack fill vertical>
{points < 10 && <PointLocked />}
<Stack.Item>
Semi-Randomize will ensure you at least get some mobility and lethality.
Guaranteed to have {semi_random_bonus} points worth of spells.
</Stack.Item>
<Stack.Item>
<Button.Confirm
confirmContent="Cowabunga it is?"
confirmIcon="dice-three"
lineHeight={6}
fluid
icon="dice-three"
onClick={() => act('semirandomize')}
>
Semi-Randomize
</Button.Confirm>
<Divider />
</Stack.Item>
<Stack.Item>
Full Random will give you anything. There&apos;s no going back, either!
Guaranteed to have {full_random_bonus} points worth of spells.
</Stack.Item>
<Stack.Item>
<NoticeBox danger>
<Button.Confirm
confirmContent="Cowabunga it is?"
confirmIcon="dice"
lineHeight={6}
fluid
color="black"
icon="dice"
onClick={() => act('randomize')}
>
Full Random
</Button.Confirm>
</NoticeBox>
</Stack.Item>
</Stack>
);
}
@@ -0,0 +1,60 @@
import { useAtom } from 'jotai';
import { Box, NoticeBox, Stack } from 'tgui-core/components';
import { useBackend } from '../../backend';
import { spellSearchAtom } from '.';
import { SpellTabDisplay } from './SpellTabDisplay';
import type { SpellbookData, SpellEntry } from './types';
export function SearchSpells(props) {
const { data } = useBackend<SpellbookData>();
const [spellSearch] = useAtom(spellSearchAtom);
const { entries } = data;
const searchStatement = spellSearch.toLowerCase();
let filteredEntries: SpellEntry[] = [];
if (searchStatement === 'robeless') {
// Lets you just search for robeless spells, you're welcome mindswap-bros
filteredEntries = entries.filter((entry) => !entry.requires_wizard_garb);
} else {
filteredEntries = entries.filter(
(entry) =>
entry.name.toLowerCase().includes(searchStatement) ||
// Unsure about including description. Wizard spell descriptions
// are painfully original and use the same verbiage often,
// which may both be a benefit and a curse
entry.desc
.toLowerCase()
.includes(searchStatement) ||
// Also opting to include category
// so you can search "rituals" to see them all at once
entry.cat
.toLowerCase()
.includes(searchStatement),
);
}
if (filteredEntries.length === 0) {
return (
<Stack width="100%" vertical>
<Stack.Item>
<NoticeBox>No spells found!</NoticeBox>
</Stack.Item>
<Stack.Item>
<Box italic align="center" color="lightgrey">
Search tip: Searching "Robeless" will only show you spells that
don't require wizard garb!
</Box>
</Stack.Item>
</Stack>
);
}
return (
<SpellTabDisplay
tabSpells={filteredEntries}
cooldownOffset={32}
pointOffset={84}
/>
);
}
@@ -0,0 +1,71 @@
import { useAtom } from 'jotai';
import { Box, Button, Section, Stack } from 'tgui-core/components';
import { heightSection, tabAtom, widthSection } from '.';
import { CategoryDisplay } from './CategoryDisplay';
import { TAB2NAME } from './constants';
export function SpellResults() {
const [tabIndex, setTabIndex] = useAtom(tabAtom);
const activeCat = TAB2NAME[tabIndex - 1];
const activeNextCat = TAB2NAME[tabIndex];
return (
<>
<Stack.Item grow>
<Section
scrollable={activeCat.scrollable}
textAlign="center"
width={widthSection}
height={heightSection}
fill
title={activeCat.title}
buttons={
<>
<Button
mr={57}
disabled={tabIndex === 1}
icon="arrow-left"
onClick={() => setTabIndex(tabIndex - 2)}
>
Previous Page
</Button>
<Box textAlign="right" bold mt={-3.3} mr={1}>
{tabIndex}
</Box>
</>
}
>
<CategoryDisplay activeCat={activeCat} />
</Section>
</Stack.Item>
<Stack.Item grow>
<Section
scrollable={activeNextCat.scrollable}
textAlign="center"
width={widthSection}
height={heightSection}
fill
title={activeNextCat.title}
buttons={
<>
<Button
mr={0}
icon="arrow-right"
disabled={tabIndex === 11}
onClick={() => setTabIndex(tabIndex + 2)}
>
Next Page
</Button>
<Box textAlign="left" bold mt={-3.3} ml={-59.8}>
{tabIndex + 1}
</Box>
</>
}
>
<CategoryDisplay activeCat={activeNextCat} />
</Section>
</Stack.Item>
</>
);
}
@@ -0,0 +1,122 @@
import {
Button,
Divider,
NoticeBox,
Section,
Stack,
} from 'tgui-core/components';
import { useBackend } from '../../backend';
import { BUYWORD2ICON } from './constants';
import {
Buywords,
type SpellbookData,
SpellCategory,
type SpellEntry,
} from './types';
type Props = {
tabSpells: SpellEntry[];
cooldownOffset?: number;
pointOffset?: number;
};
function getTimeOrCat(entry: SpellEntry) {
if (entry.cat === SpellCategory.Rituals) {
if (entry.times) {
return `Cast ${entry.times} times.`;
} else {
return 'Not cast yet.';
}
} else {
if (entry.cooldown) {
return `${entry.cooldown}s Cooldown`;
} else {
return '';
}
}
}
export function SpellTabDisplay(props: Props) {
const { act, data } = useBackend<SpellbookData>();
const { points } = data;
const { tabSpells, cooldownOffset, pointOffset } = props;
return (
<Stack vertical>
{tabSpells
.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
})
.map((entry) => (
<Stack.Item key={entry.name}>
<Divider />
<Stack mt={1.3} width="100%" position="absolute" textAlign="left">
<Stack.Item width="120px" ml={cooldownOffset}>
{getTimeOrCat(entry)}
</Stack.Item>
<Stack.Item width="60px" ml={pointOffset}>
{entry.cost} points
</Stack.Item>
{entry.buyword === Buywords.Learn && (
<Stack.Item>
<Button
mt={-0.8}
icon="tshirt"
color={entry.requires_wizard_garb ? 'bad' : 'green'}
tooltipPosition="bottom-start"
tooltip={
entry.requires_wizard_garb
? 'Requires wizard garb.'
: 'Can be cast without wizard garb.'
}
/>
</Stack.Item>
)}
</Stack>
<Section title={entry.name}>
<Stack>
<Stack.Item grow>{entry.desc}</Stack.Item>
<Stack.Item>
<Divider vertical />
</Stack.Item>
<Stack.Item>
<Button
fluid
textAlign="center"
color={points >= entry.cost ? 'green' : 'bad'}
disabled={points < entry.cost}
width={7}
icon={BUYWORD2ICON[entry.buyword]}
onClick={() =>
act('purchase', {
spellref: entry.ref,
})
}
>
{entry.buyword}
</Button>
<br />
{!entry.refundable ? (
<NoticeBox>No refunds.</NoticeBox>
) : (
<Button
textAlign="center"
width={7}
icon="arrow-left"
onClick={() =>
act('refund', {
spellref: entry.ref,
})
}
>
Refund
</Button>
)}
</Stack.Item>
</Stack>
</Section>
</Stack.Item>
))}
</Stack>
);
}
@@ -0,0 +1,101 @@
import { useAtom } from 'jotai';
import { Box, Button, Divider } from 'tgui-core/components';
import { tabAtom } from '.';
import { Tab } from './types';
export const lineHeightToc = '30.6px';
export function TableOfContents(props) {
const [_tabIndex, setTabIndex] = useAtom(tabAtom);
return (
<Box textAlign="center">
<Button lineHeight={lineHeightToc} fluid icon="pen" disabled>
Name Enscription
</Button>
<Button lineHeight={lineHeightToc} fluid icon="clipboard" disabled>
Table of Contents
</Button>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="fire"
onClick={() => setTabIndex(Tab.Defensive)}
>
Deadly Evocations
</Button>
<Button
lineHeight={lineHeightToc}
fluid
icon="shield-alt"
onClick={() => setTabIndex(Tab.Defensive)}
>
Defensive Evocations
</Button>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="globe-americas"
onClick={() => setTabIndex(Tab.Assistance)}
>
Magical Transportation
</Button>
<Button
lineHeight={lineHeightToc}
fluid
icon="users"
onClick={() => setTabIndex(Tab.Assistance)}
>
Assistance and Summoning
</Button>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="crown"
onClick={() => setTabIndex(Tab.Rituals)}
>
Challenges
</Button>
<Button
lineHeight={lineHeightToc}
fluid
icon="magic"
onClick={() => setTabIndex(Tab.Rituals)}
>
Rituals
</Button>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="thumbs-up"
onClick={() => setTabIndex(Tab.Randomize)}
>
Wizard Approved Loadouts
</Button>
<Button
lineHeight={lineHeightToc}
fluid
icon="dice"
onClick={() => setTabIndex(Tab.Randomize)}
>
Arcane Randomizer
</Button>
<Divider />
<Button
lineHeight={lineHeightToc}
fluid
icon="cog"
onClick={() => setTabIndex(Tab.TableOfContents2)}
>
Perks
</Button>
</Box>
);
}
@@ -0,0 +1,82 @@
import { EnscribedName } from './EnscribedName';
import { Loadouts } from './Loadouts';
import { Randomize } from './Randomize';
import { TableOfContents } from './TableOfContents';
import type { TabType } from './types';
export const TAB2NAME: TabType[] = [
{
title: 'Enscribed Name',
blurb:
"This book answers only to its owner, and of course, must have one. The permanence of the pact between a spellbook and its owner ensures such a powerful artifact cannot fall into enemy hands, or be used in ways that break the Federation's rules such as bartering spells.",
component: EnscribedName,
},
{
title: 'Table of Contents',
component: TableOfContents,
},
{
title: 'Offensive',
blurb: 'Spells and items geared towards debilitating and destroying.',
scrollable: true,
},
{
title: 'Defensive',
blurb:
"Spells and items geared towards improving your survivability or reducing foes' ability to attack.",
scrollable: true,
},
{
title: 'Mobility',
blurb:
'Spells and items geared towards improving your ability to move. It is a good idea to take at least one.',
scrollable: true,
},
{
title: 'Assistance',
blurb:
'Spells and items geared towards bringing in outside forces to aid you or improving upon your other items and abilities.',
scrollable: true,
},
{
title: 'Challenges',
blurb:
'The Wizard Federation is looking for shows of power. Arming the station against you will increase the danger, but will grant you more charges for your spellbook.',
locked: true,
scrollable: true,
},
{
title: 'Rituals',
blurb:
'These powerful spells change the very fabric of reality. Not always in your favour.',
scrollable: true,
},
{
title: 'Loadouts',
blurb:
'The Wizard Federation accepts that sometimes, choosing is hard. You can choose from some approved wizard loadouts here.',
component: Loadouts,
},
{
title: 'Randomize',
blurb:
"If you didn't like the loadouts offered, you can embrace chaos. Not recommended for newer wizards.",
component: Randomize,
},
{
title: 'Perks',
blurb:
'Perks are useful (and not so useful) improvements to the soul and body collected from all corners of the universe.',
scrollable: true,
},
{
title: 'Table of Contents',
component: TableOfContents,
},
];
export const BUYWORD2ICON = {
Learn: 'plus',
Summon: 'hat-wizard',
Cast: 'meteor',
};
@@ -0,0 +1,102 @@
import { sample } from 'es-toolkit';
import { atom, useAtom } from 'jotai';
import { useEffect, useState } from 'react';
import {
Button,
Input,
ProgressBar,
Section,
Stack,
} from 'tgui-core/components';
import { useBackend } from '../../backend';
import { Window } from '../../layouts';
import { SearchSpells } from './SearchSpells';
import { SpellResults } from './SpellResults';
import { type SpellbookData, Tab } from './types';
export const tabAtom = atom(Tab.TableOfContents);
export const spellSearchAtom = atom('');
export const widthSection = '466px';
export const heightSection = '456px';
const searchVerbs = [
'Searching',
'Seeking',
'Contemplating',
'Divining',
'Scrying',
'Peeking',
'Pondering',
'Gazing',
'Studying',
'Reviewing',
];
export function Spellbook(props) {
const { data } = useBackend<SpellbookData>();
const { points } = data;
const [selectedVerb, setSelectedVerb] = useState(searchVerbs[0]);
const [spellSearch, setSpellSearch] = useAtom(spellSearchAtom);
useEffect(() => {
// Ensures it only changes on reset
if (spellSearch === '') {
setSelectedVerb(sample(searchVerbs));
}
}, [spellSearch]);
return (
<Window title="Spellbook" theme="wizard" width={950} height={540}>
<Window.Content>
<Stack vertical fill>
<Stack.Item>
<Stack fill>
{spellSearch.length > 1 ? (
<Stack.Item grow>
<Section
title={`${selectedVerb}...`}
scrollable
height={heightSection}
fill
buttons={
<Button
icon="arrow-rotate-left"
onClick={() => setSpellSearch('')}
>
Stop {selectedVerb}
</Button>
}
>
<SearchSpells />
</Section>
</Stack.Item>
) : (
<SpellResults />
)}
</Stack>
</Stack.Item>
<Stack.Item>
<Section>
<Stack>
<Stack.Item grow>
<ProgressBar value={points / 10}>
{`${points} points left to spend.`}
</ProgressBar>
</Stack.Item>
<Stack.Item>
<Input
width={15}
placeholder="Search for a spell..."
onChange={setSpellSearch}
/>
</Stack.Item>
</Stack>
</Section>
</Stack.Item>
</Stack>
</Window.Content>
</Window>
);
}
@@ -0,0 +1,71 @@
import type { BooleanLike } from 'tgui-core/react';
export enum SpellCategory {
Offensive = 'Offensive',
Defensive = 'Defensive',
Mobility = 'Mobility',
Assistance = 'Assistance',
Rituals = 'Rituals',
Perks = 'Perks',
}
export type SpellEntry = {
// Name of the spell
name: string;
// Description of what the spell does
desc: string;
// Byond REF of the spell entry datum
ref: string;
// Whether the spell requires wizard clothing to cast
requires_wizard_garb: BooleanLike;
// Spell points required to buy the spell
cost: number;
// How many times the spell has been bought
times: number;
// Cooldown length of the spell once cast once
cooldown: number;
// Category of the spell
cat: SpellCategory;
// Whether the spell is refundable
refundable: BooleanLike;
// The verb displayed when buying
buyword: Buywords;
};
export type SpellbookData = {
owner: string;
points: number;
semi_random_bonus: number;
full_random_bonus: number;
entries: SpellEntry[];
};
export type TabType = {
title: string;
} & Partial<{
blurb: string;
component: (props?: any) => React.JSX.Element;
locked: boolean;
scrollable: boolean;
}>;
export enum Buywords {
Learn = 'Learn',
Summon = 'Summon',
Cast = 'Cast',
}
export enum Tab {
EnscribedName = 0,
TableOfContents = 1,
Offensive = 2,
Defensive = 3,
Mobility = 4,
Assistance = 5,
Challenges = 6,
Rituals = 7,
Loadouts = 8,
Randomize = 9,
Perks = 10,
TableOfContents2 = 11,
}