mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-29 08:08:49 +01:00
[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:
@@ -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'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'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,
|
||||
}
|
||||
Reference in New Issue
Block a user