mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 11:39:50 +02:00
Merge branch 'main' into dev-embed
This commit is contained in:
commit
e99b125517
120 files changed
+3253
-2798
No files matched your search
@@ -9,13 +9,18 @@ import * as App from '../../stores/app';
|
||||
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants';
|
||||
import { PatreonAuthResponse } from '../auth/[provider]/page';
|
||||
|
||||
interface Extension {
|
||||
data: {
|
||||
access_token: string;
|
||||
account_id: number;
|
||||
expires_at: number;
|
||||
};
|
||||
}
|
||||
type Extension =
|
||||
| {
|
||||
status: 'ok';
|
||||
data: {
|
||||
access_token: string;
|
||||
account_id: number;
|
||||
expires_at: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
};
|
||||
|
||||
/**
|
||||
* Wargaming: 14 (refresh: 7)
|
||||
@@ -80,11 +85,15 @@ export function Checks() {
|
||||
.then((response) => response.json() as Promise<Extension>)
|
||||
.then((json) => {
|
||||
mutateApp((draft) => {
|
||||
draft.logins.wargaming = {
|
||||
id: wargaming.id,
|
||||
token: json.data.access_token,
|
||||
expires: json.data.expires_at * 1000,
|
||||
};
|
||||
if (json.status === 'error') {
|
||||
draft.logins.wargaming = undefined;
|
||||
} else {
|
||||
draft.logins.wargaming = {
|
||||
id: wargaming.id,
|
||||
token: json.data.access_token,
|
||||
expires: json.data.expires_at * 1000,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
|
||||
{...props}
|
||||
>
|
||||
<Link
|
||||
href={`/tools/${tool.id}`}
|
||||
href={tool.href ?? `/tools/${tool.id}`}
|
||||
target={tool.href ? '_blank' : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
@@ -78,7 +79,7 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
|
||||
width="100%"
|
||||
direction={{
|
||||
initial: 'column',
|
||||
sm: 'row',
|
||||
xs: 'row',
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel-translucent)',
|
||||
@@ -91,7 +92,7 @@ export function ToolCard({ tool, style, ...props }: ToolCardProps) {
|
||||
justify="center"
|
||||
align={{
|
||||
initial: 'center',
|
||||
sm: 'start',
|
||||
xs: 'start',
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { Roboto_Flex } from 'next/font/google';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import { Footer } from '../components/Footer';
|
||||
import Navbar, { NAVBAR_HEIGHT } from '../components/Navbar';
|
||||
import Navbar from '../components/Navbar';
|
||||
import { Party3 } from '../components/Party3';
|
||||
import * as App from '../stores/app';
|
||||
import { Checks } from './components/Checks';
|
||||
@@ -48,7 +48,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
</>
|
||||
)}
|
||||
<meta property="og:site_name" content="BlitzKit" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.png" />
|
||||
|
||||
<Party3 />
|
||||
</head>
|
||||
@@ -71,7 +71,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
direction="column"
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
paddingTop: hideNav ? undefined : NAVBAR_HEIGHT,
|
||||
// paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{!hideNav && <Navbar />}
|
||||
|
||||
@@ -5,7 +5,7 @@ import PageWrapper from '../../components/PageWrapper';
|
||||
import * as App from '../../stores/app';
|
||||
|
||||
export default function Page() {
|
||||
const developerMode = App.use((state) => state.developerMode);
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const appStore = App.useStore();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Flex, Popover, Table } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { Button, Flex, Popover, Slider, Table, Text } from '@radix-ui/themes';
|
||||
import { debounce, times } from 'lodash';
|
||||
import { use } from 'react';
|
||||
import { ConsumablesManager } from '../../../../components/ConsumablesManager';
|
||||
import { CrewSkillManager } from '../../../../components/CrewSkillManager';
|
||||
@@ -17,7 +17,7 @@ import { equipmentDefinitions } from '../../../../core/blitzkit/equipmentDefinit
|
||||
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
|
||||
import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions';
|
||||
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
|
||||
import { BlitzkitButtonWatermark } from '../../../../icons/BlitzkitButtonWatermark';
|
||||
import { BlitzkitButtonGray } from '../../../../icons/BlitzkitButtonGray';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import { EquipmentMatrix } from '../../../../stores/duel';
|
||||
@@ -56,7 +56,7 @@ export function CompareTable({ stats }: CompareTableProps) {
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<BlitzkitButtonWatermark width={64} height={64} />
|
||||
<BlitzkitButtonGray opacity="0.25" width={64} height={64} />
|
||||
<InsertionMarker index={0} />
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
@@ -167,6 +167,7 @@ export function CompareTable({ stats }: CompareTableProps) {
|
||||
engine,
|
||||
track,
|
||||
consumables,
|
||||
crewMastery,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
@@ -509,7 +510,7 @@ export function CompareTable({ stats }: CompareTableProps) {
|
||||
<ConsumablesManager
|
||||
consumables={consumablesList}
|
||||
selected={consumables}
|
||||
onChange={(consumables) => {
|
||||
onConsumablesChange={(consumables) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].consumables = consumables;
|
||||
});
|
||||
@@ -532,6 +533,23 @@ export function CompareTable({ stats }: CompareTableProps) {
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2" width="100%" align="center">
|
||||
<Text>{Math.round(crewMastery * 100)}%</Text>
|
||||
<Flex flexGrow="1">
|
||||
<Slider
|
||||
defaultValue={[crewMastery]}
|
||||
min={0.5}
|
||||
max={1}
|
||||
step={1 / 100}
|
||||
onValueChange={debounce(([value]: number[]) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].crewMastery = value;
|
||||
});
|
||||
}, 500)}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Tabs, Text } from '@radix-ui/themes';
|
||||
import { TriggerProps } from '@radix-ui/themes/dist/cjs/components/tabs';
|
||||
|
||||
export function FlippedTrigger({ children, ...props }: TriggerProps) {
|
||||
return (
|
||||
<Tabs.Trigger {...props}>
|
||||
<Text
|
||||
style={{
|
||||
transform: 'scaleY(-1)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Tabs.Trigger>
|
||||
);
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { TimerIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Link,
|
||||
Popover,
|
||||
SegmentedControl,
|
||||
Text,
|
||||
Theme,
|
||||
} from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import { DatePicker } from '../../../../components/DatePicker';
|
||||
import { Speedometer } from '../../../../components/Speedometer';
|
||||
import { getAccountInfo } from '../../../../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../../../../core/blitz/getClanAccountInfo';
|
||||
import { idToRegion } from '../../../../core/blitz/idToRegion';
|
||||
import { parseBkni } from '../../../../core/blitzkit/parseBkni';
|
||||
import { PeriodType } from '../../../../core/discord/addPeriodSubCommands';
|
||||
import {
|
||||
BKNI_COLORS,
|
||||
BKNI_PERCENTILES,
|
||||
} from '../../../../core/statistics/getBkniPercentile';
|
||||
import { useAwait } from '../../../../hooks/useAwait';
|
||||
import strings from '../../../../lang/en-US.json';
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
const id = parseInt(params.id);
|
||||
const region = idToRegion(id);
|
||||
const accountInfo = useAwait(getAccountInfo(region, id));
|
||||
const clanAccountInfo = useAwait(getClanAccountInfo(region, id, ['clan']));
|
||||
// const bkni = ((Math.round(Date.now() / 1000 / 60) / 100) % 2) - 1;
|
||||
const bkni = 0.81;
|
||||
const winrate = 0.6;
|
||||
const [period, setPeriod] = useState<PeriodType>('30');
|
||||
const { bkniColor, bkniMetric, bkniFraction, bkniPercentile } =
|
||||
parseBkni(bkni);
|
||||
const [customFrom, setCustomFrom] = useState<Date>(
|
||||
new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
const stats = [
|
||||
['Damage', '3,203'],
|
||||
['Tier', '9.83'],
|
||||
['Survival', '70%'],
|
||||
['Kills', '1.54'],
|
||||
['Damage ratio', '2.45'],
|
||||
['Accuracy', '91%'],
|
||||
];
|
||||
const [customTo, setCustomTo] = useState<Date>(new Date());
|
||||
const [fromSelectorOpen, setFromSelectorOpen] = useState(false);
|
||||
const [toSelectorOpen, setToSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme accentColor={bkniColor}>
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
py="8"
|
||||
gap="6"
|
||||
style={{
|
||||
background: `linear-gradient(var(--${bkniColor}-a3), var(--${bkniColor}-a1))`,
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" align="center">
|
||||
<Heading
|
||||
size={{
|
||||
initial: '8',
|
||||
sm: '9',
|
||||
}}
|
||||
align="center"
|
||||
>
|
||||
{accountInfo.nickname}
|
||||
</Heading>
|
||||
<Text color="gray" align="center">
|
||||
{clanAccountInfo?.clan && `[${clanAccountInfo.clan.tag}] • `}
|
||||
{
|
||||
(clanAccountInfo?.clan
|
||||
? strings.common.regions.short
|
||||
: strings.common.regions.normal)[region]
|
||||
}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" align="center" gap="3">
|
||||
<SegmentedControl.Root
|
||||
value={period}
|
||||
onValueChange={(value) => setPeriod(value as PeriodType)}
|
||||
>
|
||||
<SegmentedControl.Item value={'today' satisfies PeriodType}>
|
||||
Today
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={'30' satisfies PeriodType}>
|
||||
30 days
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={'career' satisfies PeriodType}>
|
||||
Career
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={'custom' satisfies PeriodType}>
|
||||
Custom
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
|
||||
{period === 'custom' && (
|
||||
<Text>
|
||||
From{' '}
|
||||
<Popover.Root
|
||||
open={fromSelectorOpen}
|
||||
onOpenChange={setFromSelectorOpen}
|
||||
>
|
||||
<Popover.Trigger>
|
||||
<Link underline="always" href="#">
|
||||
{customFrom.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Link>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<DatePicker
|
||||
defaultDate={customFrom}
|
||||
onDateChange={(date) => {
|
||||
setCustomFrom(date);
|
||||
setFromSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>{' '}
|
||||
to{' '}
|
||||
<Popover.Root
|
||||
open={toSelectorOpen}
|
||||
onOpenChange={setToSelectorOpen}
|
||||
>
|
||||
<Popover.Trigger>
|
||||
<Link underline="always" href="#">
|
||||
{customTo.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Link>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<DatePicker
|
||||
defaultDate={customTo}
|
||||
onDateChange={(date) => {
|
||||
setCustomTo(date);
|
||||
setToSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center" py="8" gap="6">
|
||||
<Speedometer
|
||||
value={`${bkniMetric}`}
|
||||
color={bkniColor}
|
||||
fill={bkniFraction}
|
||||
label={
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
style={{ position: 'relative' }}
|
||||
color="gray"
|
||||
size="2"
|
||||
variant="ghost"
|
||||
>
|
||||
<TimerIcon />
|
||||
{strings.common.bkni_percentile[bkniPercentile]}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="center">
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" mb="3" align="center">
|
||||
<Heading weight="bold" size="5">
|
||||
BkNI
|
||||
</Heading>
|
||||
<Text size="2" color="gray" wrap="wrap">
|
||||
A next generation performance metric
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{BKNI_PERCENTILES.map(({ percentile, min }, index) => (
|
||||
<Button
|
||||
radius="none"
|
||||
color={BKNI_COLORS[percentile]}
|
||||
key={percentile}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
width="100%"
|
||||
gap="2"
|
||||
>
|
||||
<Text>
|
||||
{strings.common.bkni_percentile[percentile]}
|
||||
</Text>
|
||||
<Text size="1">
|
||||
{min}% - {BKNI_PERCENTILES[index + 1]?.min ?? 100}%
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
}
|
||||
/>
|
||||
|
||||
<Speedometer
|
||||
value={
|
||||
<Flex align="center" gap="1">
|
||||
{(winrate * 100).toFixed(0)}
|
||||
<Text size="3" color="gray">
|
||||
%
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
color="green"
|
||||
fill={0.5 * (Math.cbrt(2 * winrate - 1) + 1)}
|
||||
label={<Text color="gray">Winrate</Text>}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Flex style={{ flex: 1 }} wrap="wrap" gap="2" justify="center">
|
||||
{stats.map((stat) => (
|
||||
<Flex minWidth="96px" direction="column" align="center">
|
||||
<Text size="6">{stat[1]}</Text>
|
||||
<Text color="gray" size="2">
|
||||
{stat[0]}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Theme>
|
||||
|
||||
<Box flexGrow="1" />
|
||||
|
||||
{/* <Flex gap="4">
|
||||
<Link href="/tools/tankopedia">
|
||||
<Button variant="ghost" size="1" ml="-1">
|
||||
<ChevronLeftIcon />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex> */}
|
||||
{/* <Flex style={{ flex: 1 }} justify="center">
|
||||
</Flex>
|
||||
|
||||
*/}
|
||||
|
||||
{/* <PageWrapper
|
||||
p="6"
|
||||
pt="9"
|
||||
size={1600}
|
||||
noFlex1
|
||||
position="relative"
|
||||
color={bkniColor}
|
||||
containerProps={{
|
||||
style: {
|
||||
background: `linear-gradient(var(--${bkniColor}-a2), var(--${bkniColor}-a1))`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Root
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: 'scaleY(-1) translateX(-50%)',
|
||||
}}
|
||||
value={`${period}`}
|
||||
onValueChange={(value) => {
|
||||
setPeriod(value === 'custom' ? 'custom' : Number(value));
|
||||
}}
|
||||
>
|
||||
<Tabs.List justify="center">
|
||||
<FlippedTrigger value="1">Today</FlippedTrigger>
|
||||
<FlippedTrigger value="30">30 days</FlippedTrigger>
|
||||
<FlippedTrigger value="Infinity">Career</FlippedTrigger>
|
||||
<FlippedTrigger value="custom">Custom</FlippedTrigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
|
||||
|
||||
|
||||
</PageWrapper>
|
||||
|
||||
<Flex flexGrow="1" justify="center" align="center" p="4">
|
||||
<Callout.Root color="red" mt="4">
|
||||
<Callout.Icon>
|
||||
<ExclamationTriangleIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
We haven't tracked long enough to provide statistics. Please check
|
||||
back after a few days.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
</Flex>
|
||||
|
||||
{isTracking && <Box flexGrow="1" />} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowDownIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Card, Flex, Text, TextField } from '@radix-ui/themes';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link } from '../../../components/Link';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import { UNLOCALIZED_REGION_NAMES_SHORT } from '../../../constants/regions';
|
||||
import searchPlayersAcrossRegions, {
|
||||
AccountListWithServer,
|
||||
} from '../../../core/blitz/searchPlayersAcrossRegions';
|
||||
|
||||
export default function Page() {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<AccountListWithServer>([]);
|
||||
|
||||
async function search() {
|
||||
if (!input.current) return;
|
||||
|
||||
setSearchResults(await searchPlayersAcrossRegions(input.current.value, 9));
|
||||
setSearching(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Flex
|
||||
style={{ flex: 1 }}
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="3"
|
||||
>
|
||||
<Flex gap="2" align="center">
|
||||
<Text color="gray">Look up a player to get started</Text>
|
||||
<ArrowDownIcon />
|
||||
</Flex>
|
||||
|
||||
<TextField.Root
|
||||
size="3"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '75vw',
|
||||
maxWidth: 480,
|
||||
}}
|
||||
ref={input}
|
||||
placeholder="Search players..."
|
||||
onChange={() => {
|
||||
if (!input.current) return;
|
||||
|
||||
const sanitized = input.current.value.trim();
|
||||
|
||||
if (sanitized.length > 0) {
|
||||
setShowSearch(true);
|
||||
setSearching(true);
|
||||
search();
|
||||
} else {
|
||||
setShowSearch(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon />
|
||||
</TextField.Slot>
|
||||
|
||||
{showSearch && (
|
||||
<Card
|
||||
mt="2"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{searching && (
|
||||
<Flex justify="center">
|
||||
<Text color="gray">Searching...</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!searching && searchResults.length === 0 && (
|
||||
<Flex justify="center">
|
||||
<Text color="gray">No players found</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!searching && searchResults.length > 0 && (
|
||||
<Flex direction="column" gap="2">
|
||||
{searchResults.map((player) => (
|
||||
<Link
|
||||
key={player.account_id}
|
||||
href={`/tools/player-stats/${player.account_id}`}
|
||||
>
|
||||
<Button key={player.account_id} variant="ghost">
|
||||
{player.nickname} (
|
||||
{UNLOCALIZED_REGION_NAMES_SHORT[player.region]})
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</TextField.Root>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
+10
-4
@@ -1,15 +1,21 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { Flex, FlexProps } from '@radix-ui/themes';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ConfigurationChildWrapperProps {
|
||||
type ConfigurationChildWrapperProps = FlexProps & {
|
||||
children: ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
export function ConfigurationChildWrapper({
|
||||
children,
|
||||
...props
|
||||
}: ConfigurationChildWrapperProps) {
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
<Flex
|
||||
gap="2"
|
||||
direction="column"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -77,11 +77,12 @@ export function Consumables() {
|
||||
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
|
||||
hasHighEndConsumables={hasHighEndConsumables}
|
||||
timers
|
||||
onChange={(consumables) => {
|
||||
onConsumablesChange={(consumables) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist.consumables = consumables;
|
||||
});
|
||||
}}
|
||||
justify={{ initial: 'center', sm: 'start' }}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
|
||||
@@ -47,8 +47,6 @@ export function Miscellaneous() {
|
||||
left: '50%',
|
||||
transform: 'translate(calc(-50% + 4px), calc(-50% + 4px))',
|
||||
}}
|
||||
first
|
||||
last
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist.camouflage = !camouflage;
|
||||
@@ -59,8 +57,6 @@ export function Miscellaneous() {
|
||||
<GenericTankComponentButton
|
||||
icon={asset('icons/boosters/equipment.webp')}
|
||||
selected={cooldownBooster > 0}
|
||||
first
|
||||
last
|
||||
banner={
|
||||
cooldownBooster === 0
|
||||
? undefined
|
||||
|
||||
@@ -214,9 +214,7 @@ export function Modules() {
|
||||
align="center"
|
||||
direction="column"
|
||||
gap="2"
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
position="relative"
|
||||
>
|
||||
{unlock.cost.value > 0 && (
|
||||
<>
|
||||
@@ -310,6 +308,7 @@ export function Modules() {
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">Modules</Heading>
|
||||
|
||||
{hasUpgrades && (
|
||||
<>
|
||||
<Button
|
||||
@@ -348,7 +347,7 @@ export function Modules() {
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2">
|
||||
<Flex gap="2" wrap="wrap" gapY="6">
|
||||
{tree('engine', [
|
||||
{
|
||||
cost: { type: 'xp', value: 0 },
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
Slider,
|
||||
Text,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@radix-ui/themes';
|
||||
import { debounce } from 'lodash';
|
||||
import { Fragment, use, useEffect, useRef, useState } from 'react';
|
||||
import { lerp } from 'three/src/math/MathUtils';
|
||||
import { Ad, AdType } from '../../../../../../components/Ad';
|
||||
import { Link } from '../../../../../../components/Link';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
|
||||
@@ -44,6 +44,9 @@ export function Characteristics() {
|
||||
const hasImprovedVentilation = useEquipment(102);
|
||||
const crewMastery = Duel.use((state) => state.protagonist.crewMastery);
|
||||
const [penetrationDistance, setPenetrationDistance] = useState(250);
|
||||
const setPenetrationDistanceDebounced = debounce((value: number) => {
|
||||
setPenetrationDistance(value);
|
||||
}, 500);
|
||||
|
||||
const provisions = Duel.use((state) => state.protagonist.provisions);
|
||||
const provisionCrewBonus =
|
||||
@@ -145,32 +148,33 @@ export function Characteristics() {
|
||||
|
||||
<Flex>
|
||||
{gun.shells.map((thisShell, shellIndex) => (
|
||||
<IconButton
|
||||
color={thisShell.id === shell.id ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
key={thisShell.id}
|
||||
style={{
|
||||
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
marginLeft: shellIndex === 0 ? 0 : -1,
|
||||
}}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist.shell = thisShell;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={thisShell.name}
|
||||
width={16}
|
||||
height={16}
|
||||
src={asset(`icons/shells/${thisShell.icon}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
<Tooltip content={thisShell.name} key={thisShell.id}>
|
||||
<IconButton
|
||||
color={thisShell.id === shell.id ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
style={{
|
||||
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
marginLeft: shellIndex === 0 ? 0 : -1,
|
||||
}}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist.shell = thisShell;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={thisShell.name}
|
||||
width={16}
|
||||
height={16}
|
||||
src={asset(`icons/shells/${thisShell.icon}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -179,35 +183,22 @@ export function Characteristics() {
|
||||
{stats.dpm}
|
||||
</InfoWithDelta>
|
||||
{gun.type === 'autoReloader' && (
|
||||
<>
|
||||
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
|
||||
{stats.dpmMaximum!}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={0}
|
||||
indent
|
||||
name="Effective at 60s"
|
||||
unit="hp / min"
|
||||
>
|
||||
{stats.dpmEffective!}
|
||||
</InfoWithDelta>
|
||||
<Info
|
||||
indent
|
||||
name={
|
||||
<Link target="_blank" href="/docs/guide/dpm">
|
||||
What's the difference?
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<InfoWithDelta
|
||||
decimals={0}
|
||||
indent
|
||||
name="Effective at 60s"
|
||||
unit="hp / min"
|
||||
>
|
||||
{stats.dpmEffective!}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
{gun.type !== 'regular' && (
|
||||
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
|
||||
)}
|
||||
{gun.type === 'autoReloader' ? (
|
||||
<>
|
||||
<Info indent name="Most optimal">
|
||||
{stats.mostOptimalShellIndex}
|
||||
<Info indent name="Most optimal shell index">
|
||||
{stats.mostOptimalShellIndex! + 1}
|
||||
</Info>
|
||||
<Info name="Shell reloads" unit="s" />
|
||||
{stats.shellReloads!.map((reload, index) => (
|
||||
@@ -269,9 +260,13 @@ export function Characteristics() {
|
||||
max={500}
|
||||
style={{ flex: 1 }}
|
||||
defaultValue={[penetrationDistance]}
|
||||
onValueChange={debounce(([value]) => {
|
||||
setPenetrationDistance(value);
|
||||
}, 500)}
|
||||
onValueChange={([value]) => {
|
||||
setPenetrationDistanceDebounced(value);
|
||||
|
||||
if (!penetrationDistanceInput.current) return;
|
||||
|
||||
penetrationDistanceInput.current.value = value.toString();
|
||||
}}
|
||||
/>
|
||||
<TextField.Root
|
||||
style={{ width: 64 }}
|
||||
@@ -477,15 +472,18 @@ export function Characteristics() {
|
||||
>
|
||||
{(member.type === 'commander'
|
||||
? commanderMastery
|
||||
: crewMemberMastery) * 100}
|
||||
: commanderMastery * 1.1) * 100}
|
||||
</InfoWithDelta>
|
||||
|
||||
{member.substitute && (
|
||||
<Info
|
||||
<InfoWithDelta
|
||||
key={`${member.type}-substitute`}
|
||||
decimals={0}
|
||||
unit="%"
|
||||
indent
|
||||
name={
|
||||
<>
|
||||
<Flex align="center" gap="1">
|
||||
<Flex align="center" gap="1" display="inline-flex">
|
||||
<AccessibilityIcon />
|
||||
{member.substitute
|
||||
.map((sub, index) =>
|
||||
@@ -495,7 +493,9 @@ export function Characteristics() {
|
||||
</Flex>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
>
|
||||
{commanderMastery * 1.05 * 100}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ export function HistorySection() {
|
||||
if (!tank.description) return null;
|
||||
|
||||
return (
|
||||
<PageWrapper noFlex1>
|
||||
<PageWrapper noFlex1 align="center">
|
||||
<Heading>History of the {tank.name}</Heading>
|
||||
<Text>{tank.description}</Text>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
|
||||
export function AutoClear() {
|
||||
useFrame(({ gl }) => {
|
||||
gl.clear();
|
||||
}, 0);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { Ad, AdType } from '../../../../../../components/Ad';
|
||||
import { useAdExempt } from '../../../../../../hooks/useAdExempt';
|
||||
import { Characteristics } from '../Characteristics';
|
||||
import { Consumables } from '../Characteristics/components/Consumables';
|
||||
import { Equipment } from '../Characteristics/components/Equipment';
|
||||
@@ -10,33 +8,21 @@ import { Provisions } from '../Characteristics/components/Provisions';
|
||||
import { Skills } from '../Characteristics/components/Skills';
|
||||
|
||||
export function CharacteristicsSection() {
|
||||
const exempt = useAdExempt();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
mt={exempt ? '4' : '0'}
|
||||
px="4"
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
gap="6"
|
||||
>
|
||||
{!exempt && (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
display={{ initial: 'none', lg: 'flex' }}
|
||||
pt={{ initial: '9', md: '0' }}
|
||||
>
|
||||
<Ad type={AdType.WideSkyscraperVerticalPurple} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
gap="6"
|
||||
gap="9"
|
||||
justify="center"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
>
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="6"
|
||||
maxWidth="21rem"
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
>
|
||||
<Heading>Configuration</Heading>
|
||||
<Modules />
|
||||
<Equipment />
|
||||
@@ -45,33 +31,19 @@ export function CharacteristicsSection() {
|
||||
<Skills />
|
||||
<Miscellaneous />
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
flexGrow="1"
|
||||
maxWidth={{
|
||||
initial: 'unset',
|
||||
xs: exempt ? undefined : '320px',
|
||||
md: '640px',
|
||||
}}
|
||||
maxWidth={{ initial: '20rem', md: '40rem' }}
|
||||
direction="column"
|
||||
gap="4"
|
||||
gap="6"
|
||||
width="100%"
|
||||
>
|
||||
<Heading>Characteristics</Heading>
|
||||
<Heading align={{ initial: 'center', sm: 'left' }}>
|
||||
Characteristics
|
||||
</Heading>
|
||||
<Characteristics />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{!exempt && (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
height="100%"
|
||||
display={{ initial: 'none', xs: 'flex' }}
|
||||
pt={{ initial: '9', md: '0' }}
|
||||
>
|
||||
<Ad type={AdType.WideSkyscraperVerticalPurple} />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { asset } from '../../../../../../core/blitzkit/asset';
|
||||
import { consumableDefinitions } from '../../../../../../core/blitzkit/consumableDefinitions';
|
||||
import { gameDefinitions } from '../../../../../../core/blitzkit/gameDefinitions';
|
||||
import { provisionDefinitions } from '../../../../../../core/blitzkit/provisionDefinitions';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
|
||||
export function GameModeSection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const awaitedGameDefinitions = use(gameDefinitions);
|
||||
const awaitedConsumableDefinitions = use(consumableDefinitions);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const roles = Object.entries(tank.roles);
|
||||
|
||||
if (roles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<Heading size="6">Game mode abilities</Heading>
|
||||
|
||||
<Flex justify="center" gap="4" wrap="wrap" px="4">
|
||||
{roles.map(([key, value]) => {
|
||||
const id = Number(key);
|
||||
const gameMode = awaitedGameDefinitions.gameModes[id];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={id}
|
||||
width="16rem"
|
||||
height="20rem"
|
||||
overflow="hidden"
|
||||
direction="column"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-3)',
|
||||
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
flexGrow="1"
|
||||
style={{
|
||||
background: `url(${asset(`icons/game_mode_banners/${id}.webp`)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
p="3"
|
||||
direction="column"
|
||||
height="98px"
|
||||
flexShrink="0"
|
||||
style={{
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
}}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text size="4" weight="bold">
|
||||
{gameMode.name}
|
||||
</Text>
|
||||
|
||||
<Flex direction="column">
|
||||
{awaitedGameDefinitions.roles[value].provisions.map((id) => {
|
||||
const provisions = awaitedProvisionDefinitions[id];
|
||||
|
||||
return (
|
||||
<Text color="gray" wrap="nowrap" key={id}>
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{
|
||||
width: '1.5em',
|
||||
height: '1.5em',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={asset(`icons/provisions/${id}.webp`)}
|
||||
alt={provisions.name}
|
||||
/>
|
||||
{provisions.name}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{awaitedGameDefinitions.roles[value].consumables.map((id) => {
|
||||
const consumable = awaitedConsumableDefinitions[id];
|
||||
|
||||
return (
|
||||
<Text color="gray" wrap="nowrap" key={id}>
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{
|
||||
width: '1.5em',
|
||||
height: '1.5em',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
src={asset(`icons/consumables/${id}.webp`)}
|
||||
alt={consumable.name}
|
||||
/>
|
||||
{consumable.name}
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons';
|
||||
import { Box, Button, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { Box, Button, Flex, Heading } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, use, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { classIcons } from '../../../../../../components/ClassIcon';
|
||||
import { ThicknessRange } from '../../../../../../components/StaticArmor';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
|
||||
import strings from '../../../../../../lang/en-US.json';
|
||||
import * as App from '../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
import { Options } from './components/Options';
|
||||
import { MetaSection } from './MetaSection';
|
||||
import { TankSandbox } from './TankSandbox';
|
||||
import { TankSandboxLoader } from './TankSandboxLoader';
|
||||
|
||||
@@ -19,6 +19,8 @@ interface HeroSectionProps {
|
||||
}
|
||||
|
||||
export function HeroSection({ id }: HeroSectionProps) {
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const antagonist = Duel.use((state) => state.antagonist.tank);
|
||||
const compareTanks =
|
||||
@@ -36,6 +38,7 @@ export function HeroSection({ id }: HeroSectionProps) {
|
||||
: undefined;
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
const ClassIcon = classIcons[tank.class];
|
||||
const thicknessRange = useMemo(() => {
|
||||
const entries = Object.values(awaitedTankDefinitions);
|
||||
const filtered = entries.filter((thisTank) => thisTank.tier === tank.tier);
|
||||
@@ -63,12 +66,14 @@ export function HeroSection({ id }: HeroSectionProps) {
|
||||
justify="center"
|
||||
pt={{ initial: '6', md: '0' }}
|
||||
style={{ background: 'var(--color-surface)', position: 'relative' }}
|
||||
height={{ initial: 'calc(75vh - 64px)', md: undefined }}
|
||||
height={{ initial: 'calc(75vh - 4rem)', md: undefined }}
|
||||
minHeight={{ initial: '30rem' }}
|
||||
gap="4"
|
||||
>
|
||||
<Flex
|
||||
direction={{ initial: 'column', md: 'row' }}
|
||||
style={{ maxWidth: 1600, flex: 1 }}
|
||||
style={{ maxWidth: '140rem', flex: 1 }}
|
||||
gap={{ initial: '0', md: '6' }}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
@@ -77,39 +82,14 @@ export function HeroSection({ id }: HeroSectionProps) {
|
||||
flexBasis="0"
|
||||
>
|
||||
<Flex
|
||||
gap={{ initial: '2', md: '4' }}
|
||||
gap="2"
|
||||
direction="column"
|
||||
ml={{ initial: '0', md: '8' }}
|
||||
align={{ initial: 'center', md: 'start' }}
|
||||
justify="center"
|
||||
style={{
|
||||
height: '100%',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
style={{ height: '100%', userSelect: 'none' }}
|
||||
>
|
||||
<Flex gap="4">
|
||||
<Heading size={{ initial: '8', md: '9' }} color={treeColor}>
|
||||
<Icon style={{ width: '0.75em', height: '0.75em' }} />{' '}
|
||||
</Heading>
|
||||
<Heading
|
||||
size={{ initial: '8', md: '9' }}
|
||||
align={{ initial: 'center', md: 'left' }}
|
||||
color={treeColor}
|
||||
>
|
||||
{protagonist.name}
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<Text color="gray" ml={{ initial: '0', md: '9' }}>
|
||||
Tier {TIER_ROMAN_NUMERALS[protagonist.tier]}{' '}
|
||||
{
|
||||
(strings.common.nations_adjectives as Record<string, string>)[
|
||||
protagonist.nation
|
||||
]
|
||||
}{' '}
|
||||
{strings.common.tank_class_short[protagonist.class]}
|
||||
</Text>
|
||||
<Flex gap="4" ml={{ initial: '0', md: '9' }} mt="-1">
|
||||
<Link href="/tools/tankopedia">
|
||||
<Button variant="ghost" size="1" ml="-1">
|
||||
<ChevronLeftIcon />
|
||||
@@ -124,6 +104,22 @@ export function HeroSection({ id }: HeroSectionProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="4">
|
||||
<Heading size={{ initial: '8', sm: '9' }} color={treeColor}>
|
||||
<Icon style={{ width: '0.75em', height: '0.75em' }} />{' '}
|
||||
</Heading>
|
||||
|
||||
<Heading
|
||||
size={{ initial: '8', sm: '9' }}
|
||||
align={{ initial: 'center', md: 'left' }}
|
||||
color={treeColor}
|
||||
>
|
||||
{protagonist.name}
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<MetaSection naked />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -156,11 +152,11 @@ export function HeroSection({ id }: HeroSectionProps) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TankSandbox thicknessRange={thicknessRange} />
|
||||
<TankSandbox ref={canvas} thicknessRange={thicknessRange} />
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
<Options thicknessRange={thicknessRange} />
|
||||
<Options canvas={canvas} thicknessRange={thicknessRange} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
||||
|
||||
type ListingProps = TextProps & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function Listing({ label, ...props }: ListingProps) {
|
||||
return (
|
||||
<Flex minWidth="15rem" justify="between" gap="4">
|
||||
<Text color="gray">{label}</Text>
|
||||
<Text {...props} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Code, Flex } from '@radix-ui/themes';
|
||||
import { classIcons } from '../../../../../../../components/ClassIcon';
|
||||
import { asset } from '../../../../../../../core/blitzkit/asset';
|
||||
import strings from '../../../../../../../lang/en-US.json';
|
||||
import * as App from '../../../../../../../stores/app';
|
||||
import * as Duel from '../../../../../../../stores/duel';
|
||||
import { Listing } from './components/Listing';
|
||||
|
||||
interface MetaSectionProps {
|
||||
naked?: boolean;
|
||||
}
|
||||
|
||||
export function MetaSection({ naked }: MetaSectionProps) {
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const ClassIcon = classIcons[tank.class];
|
||||
const content = (
|
||||
<Flex
|
||||
wrap={{ initial: 'nowrap', sm: 'wrap' }}
|
||||
direction={{ initial: 'column', sm: 'row' }}
|
||||
align={{ initial: 'center', sm: 'start' }}
|
||||
gapX="4"
|
||||
width="100%"
|
||||
mt={{ initial: '4', md: '6' }}
|
||||
display={naked ? { initial: 'none', sm: 'flex' } : undefined}
|
||||
maxWidth="calc(2 * 15rem + var(--space-4))"
|
||||
>
|
||||
{tank.nameFull && <Listing label="Full-name">{tank.nameFull}</Listing>}
|
||||
<Listing label="Nation">
|
||||
<Flex align="center" gap="1">
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={tank.nation}
|
||||
src={asset(`flags/circle/${tank.nation}.webp`)}
|
||||
/>
|
||||
{
|
||||
strings.common.nations[
|
||||
tank.nation as keyof typeof strings.common.nations
|
||||
]
|
||||
}
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Tier">{tank.tier}</Listing>
|
||||
<Listing label="Class">
|
||||
<Flex align="center" gap="1">
|
||||
<ClassIcon width="1em" height="1em" />
|
||||
{strings.common.tank_class_short[tank.class]}
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing
|
||||
label="Type"
|
||||
color={
|
||||
tank.treeType === 'collector'
|
||||
? 'blue'
|
||||
: tank.treeType === 'premium'
|
||||
? 'amber'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{strings.common.tree_type[tank.treeType]}
|
||||
</Listing>
|
||||
{developerMode && (
|
||||
<Listing label="DEV: ID">
|
||||
<Code>{tank.id}</Code>
|
||||
</Listing>
|
||||
)}
|
||||
{tank.treeType === 'premium' && (
|
||||
<Listing label="Purchase price">
|
||||
<Flex align="center" gap="1">
|
||||
{tank.price.value / 400}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt="gold"
|
||||
src={asset('icons/currencies/gold.webp')}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
)}
|
||||
<Listing
|
||||
label={`${tank.treeType === 'researchable' ? 'Purchase' : 'Restoration'} price`}
|
||||
>
|
||||
<Flex align="center" gap="1">
|
||||
{tank.price.value.toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={tank.price.type}
|
||||
src={asset(
|
||||
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
<Listing label="Sale price">
|
||||
<Flex align="center" gap="1">
|
||||
{(tank.price.value / 2).toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt={tank.price.type}
|
||||
src={asset(
|
||||
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
{tank.xp && (
|
||||
<Listing label="Research XP">
|
||||
<Flex align="center" gap="1">
|
||||
{tank.xp.toLocaleString()}
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
alt="xp"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
/>
|
||||
</Flex>
|
||||
</Listing>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (naked) return content;
|
||||
|
||||
return (
|
||||
<Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
|
||||
{content}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Suspense, useEffect, useRef } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Armor } from '../../../../../../components/Armor';
|
||||
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
|
||||
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
|
||||
@@ -11,6 +17,7 @@ import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawL
|
||||
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
|
||||
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import useOnScreen from '../../../../../../hooks/useOnScreen';
|
||||
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
|
||||
@@ -19,6 +26,7 @@ import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent
|
||||
import { Controls } from '../Control';
|
||||
import { Lighting } from '../Lighting';
|
||||
import { SceneProps } from '../SceneProps';
|
||||
import { AutoClear } from './AutoClear';
|
||||
import { ModelLoader } from './components/ModelLoader';
|
||||
import { TankModel } from './components/TankModel';
|
||||
|
||||
@@ -26,153 +34,166 @@ interface TankSandboxProps {
|
||||
thicknessRange: ThicknessRange;
|
||||
}
|
||||
|
||||
export function TankSandbox({ thicknessRange }: TankSandboxProps) {
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const tankModelDefinition = useTankModelDefinition();
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const display = TankopediaPersistent.use((state) => state.display);
|
||||
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
|
||||
({ thicknessRange }, ref) => {
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const protagonist = Duel.use((state) => state.protagonist);
|
||||
const mutateDuel = Duel.useMutation();
|
||||
const tankModelDefinition = useTankModelDefinition();
|
||||
const turretModelDefinition =
|
||||
tankModelDefinition.turrets[protagonist.turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
|
||||
const display = TankopediaPersistent.use((state) => state.display);
|
||||
const onScreen = useOnScreen(canvas);
|
||||
|
||||
function handlePointerDown() {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
console.log(onScreen);
|
||||
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
useImperativeHandle(ref, () => canvas.current!, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePoseEvent(pose: Pose) {
|
||||
switch (pose) {
|
||||
case Pose.HullDown: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
function handlePointerDown() {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
|
||||
break;
|
||||
useEffect(() => {
|
||||
function handlePoseEvent(pose: Pose) {
|
||||
switch (pose) {
|
||||
case Pose.HullDown: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
-Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.FaceHug: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.Default:
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.FaceHug: {
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
Infinity,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case Pose.Default:
|
||||
mutateDuel((draft) => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
0,
|
||||
0,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
draft.protagonist.pitch = pitch;
|
||||
draft.protagonist.yaw = yaw;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
poseEvent.on(handlePoseEvent);
|
||||
poseEvent.on(handlePoseEvent);
|
||||
|
||||
return () => {
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
|
||||
draft.protagonist.pitch,
|
||||
draft.protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
return () => {
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
});
|
||||
}, [protagonist.gun, protagonist.turret]);
|
||||
|
||||
useEffect(() => {
|
||||
if (display !== TankopediaDisplay.DynamicArmor) {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
useEffect(() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
|
||||
draft.protagonist.pitch,
|
||||
draft.protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [protagonist.gun, protagonist.turret]);
|
||||
|
||||
if (display !== TankopediaDisplay.StaticArmor) {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.highlightArmor = undefined;
|
||||
});
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
gl={{ clippingPlanes: Object.freeze([]), localClippingEnabled: true }}
|
||||
shadows
|
||||
ref={canvas}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMissed={() => {
|
||||
useEffect(() => {
|
||||
if (display !== TankopediaDisplay.DynamicArmor) {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (display !== TankopediaDisplay.StaticArmor) {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.highlightArmor = undefined;
|
||||
});
|
||||
}}
|
||||
style={{ userSelect: 'none' }}
|
||||
camera={{ fov: 25, far: 32 }}
|
||||
>
|
||||
<Controls />
|
||||
<SceneProps />
|
||||
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
<ShotDisplay />
|
||||
<ArmorPlateDisplay />
|
||||
return (
|
||||
<Canvas
|
||||
frameloop={onScreen ? 'demand' : 'never'}
|
||||
gl={{
|
||||
clippingPlanes: Object.freeze([]),
|
||||
localClippingEnabled: true,
|
||||
preserveDrawingBuffer: true,
|
||||
}}
|
||||
shadows
|
||||
ref={canvas}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMissed={() => {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
draft.highlightArmor = undefined;
|
||||
});
|
||||
}}
|
||||
style={{ userSelect: 'none' }}
|
||||
camera={{ fov: 25, far: 32 }}
|
||||
>
|
||||
<Controls />
|
||||
<SceneProps />
|
||||
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
|
||||
|
||||
{/* idk why the shot display doesn't work without suspense here lol */}
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
{display === TankopediaDisplay.DynamicArmor && <Armor />}
|
||||
{display === TankopediaDisplay.StaticArmor && (
|
||||
<StaticArmor thicknessRange={thicknessRange} />
|
||||
)}
|
||||
<Lighting />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
<ShotDisplay />
|
||||
<ArmorPlateDisplay />
|
||||
<AutoClear />
|
||||
|
||||
{/* idk why the shot display doesn't work without suspense here lol */}
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
{display === TankopediaDisplay.DynamicArmor && <Armor />}
|
||||
{display === TankopediaDisplay.StaticArmor && (
|
||||
<StaticArmor thicknessRange={thicknessRange} />
|
||||
)}
|
||||
<Lighting />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Flex, Link, Text } from '@radix-ui/themes';
|
||||
import PageWrapper from '../../../../../../components/PageWrapper';
|
||||
import { PatreonIcon } from '../../../../../../icons/Patreon';
|
||||
|
||||
export function TankopediaPlug() {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Flex justify="center">
|
||||
<Text>
|
||||
Liking BlitzKit? Consider donating on{' '}
|
||||
<Link
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
target="_blank"
|
||||
underline="hover"
|
||||
>
|
||||
<PatreonIcon
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
transform: 'translateY(12.5%)',
|
||||
}}
|
||||
/>{' '}
|
||||
Patreon
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { ChevronRightIcon } from '@radix-ui/react-icons';
|
||||
import { Box, ChevronDownIcon, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { ComponentProps, use } from 'react';
|
||||
import { asset } from '../../../../../../core/blitzkit/asset';
|
||||
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import { formatCompact } from '../../../../../../core/math/formatCompact';
|
||||
import * as Duel from '../../../../../../stores/duel';
|
||||
|
||||
export function TreeArrow({
|
||||
style,
|
||||
down = false,
|
||||
...props
|
||||
}: ComponentProps<typeof Text> & { down?: boolean }) {
|
||||
const Icon = down ? ChevronDownIcon : ChevronRightIcon;
|
||||
|
||||
return (
|
||||
<Text
|
||||
color="gray"
|
||||
style={{
|
||||
flex: 1,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Flex
|
||||
style={{
|
||||
backgroundColor: 'currentcolor',
|
||||
height: down ? '100%' : 1,
|
||||
width: down ? 1 : '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
direction={down ? 'column' : 'row'}
|
||||
justify="end"
|
||||
>
|
||||
<Icon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: down ? undefined : 0,
|
||||
top: down ? undefined : '50%',
|
||||
bottom: down ? 0 : undefined,
|
||||
left: down ? '50%' : undefined,
|
||||
transform: down ? 'translate(-50%, 2px)' : 'translate(5px, -50%)',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function TechTreeSection() {
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
|
||||
if (tank.treeType !== 'researchable') return null;
|
||||
|
||||
function Card({ id }: { id: number }) {
|
||||
const ancestor = awaitedTankDefinitions[id];
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/tools/tankopedia/${id}`}
|
||||
key={id}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" align="center">
|
||||
<img
|
||||
alt={ancestor.name}
|
||||
src={asset(`icons/tanks/big/${id}.webp`)}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex direction="column" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Text color="gray" size="1">
|
||||
{TIER_ROMAN_NUMERALS[ancestor.tier]}
|
||||
</Text>
|
||||
<Text>{ancestor.name}</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2" align="center">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="XP"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<Text color="gray" size="1">
|
||||
{formatCompact(ancestor.xp!)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="Silver"
|
||||
src={asset('icons/currencies/silver.webp')}
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<Text color="gray" size="1">
|
||||
{formatCompact(ancestor.price.value)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
p="5"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
}}
|
||||
align="center"
|
||||
direction="column"
|
||||
position="relative"
|
||||
>
|
||||
<img
|
||||
src={asset(`flags/scratched/${tank.nation}.webp`)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-40%, -50%)',
|
||||
filter: 'blur(2px)',
|
||||
opacity: 0.25,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Heading mb="4">Tech tree</Heading>
|
||||
|
||||
<Flex align="center" gap="6" direction={{ initial: 'column', sm: 'row' }}>
|
||||
{tank.ancestors && (
|
||||
<>
|
||||
<Flex
|
||||
wrap="wrap"
|
||||
direction={{ initial: 'row', sm: 'column' }}
|
||||
maxHeight={{ initial: 'unset', sm: '320px' }}
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="6"
|
||||
>
|
||||
{tank.ancestors.map((id) => (
|
||||
<Card key={id} id={id} />
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Box display={{ initial: 'block', sm: 'none' }}>
|
||||
<TreeArrow down />
|
||||
</Box>
|
||||
<Box display={{ initial: 'none', sm: 'block' }}>
|
||||
<TreeArrow />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card id={tank.id} />
|
||||
|
||||
{tank.successors && (
|
||||
<>
|
||||
<Box display={{ initial: 'block', sm: 'none' }}>
|
||||
<TreeArrow down />
|
||||
</Box>
|
||||
<Box display={{ initial: 'none', sm: 'block' }}>
|
||||
<TreeArrow />
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
wrap="wrap"
|
||||
direction={{ initial: 'row', sm: 'column' }}
|
||||
maxHeight={{ initial: 'unset', sm: '320px' }}
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="6"
|
||||
>
|
||||
{tank.successors.map((id) => (
|
||||
<Card key={id} id={id} />
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { Text } from '@radix-ui/themes';
|
||||
|
||||
export function Arrow() {
|
||||
return (
|
||||
<Text color="gray">
|
||||
<CaretRightIcon />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Box, Flex, Text } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { use } from 'react';
|
||||
import { asset } from '../../../../../../../../core/blitzkit/asset';
|
||||
import { averageDefinitions } from '../../../../../../../../core/blitzkit/averageDefinitions';
|
||||
import { tankDefinitions } from '../../../../../../../../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import { formatCompact } from '../../../../../../../../core/math/formatCompact';
|
||||
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
|
||||
|
||||
interface NodeProps {
|
||||
id: number;
|
||||
nextIds?: number[];
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function Node({ id, highlight, nextIds }: NodeProps) {
|
||||
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
const nextTanks = nextIds?.map((id) => awaitedTankDefinitions[id]);
|
||||
const awaitedAverageDefinitions = use(averageDefinitions);
|
||||
const thisTankXp =
|
||||
tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier;
|
||||
const nextTanksXp = nextTanks?.reduce(
|
||||
(xp, tank) =>
|
||||
xp +
|
||||
(tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier),
|
||||
0,
|
||||
);
|
||||
const averages = awaitedAverageDefinitions.averages[tank.id]?.mu;
|
||||
const averageXp = averages ? averages.xp / averages.battles : undefined;
|
||||
const games =
|
||||
averages && nextTanks ? Math.round(nextTanksXp! / averageXp!) : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/tools/tankopedia/${id}`}
|
||||
key={id}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
mx={highlight ? '2' : '0'}
|
||||
style={{
|
||||
background: highlight
|
||||
? `url(${asset(`flags/fade_small/${tank.nation}.webp`)})`
|
||||
: undefined,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
boxShadow: highlight ? 'var(--shadow-4)' : undefined,
|
||||
borderRadius: 'var(--radius-4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
py="4"
|
||||
px={highlight ? '4' : '2'}
|
||||
style={{
|
||||
backdropFilter: highlight ? 'brightness(0.25)' : undefined,
|
||||
WebkitBackdropFilter: highlight ? 'brightness(0.25)' : undefined,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={tank.name}
|
||||
src={asset(`icons/tanks/big/${id}.webp`)}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex direction="column" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Text color="gray" size="1">
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</Text>
|
||||
<Text wrap="nowrap">{tank.name}</Text>
|
||||
</Flex>
|
||||
<Flex gap="2" align="center">
|
||||
<Text color="gray" size="1" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="XP"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{formatCompact(thisTankXp)}
|
||||
</Flex>
|
||||
</Text>
|
||||
<Text color="gray" size="1" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="Silver"
|
||||
src={asset('icons/currencies/silver.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{formatCompact(tank.price.value)}
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{averages && nextTanks && (
|
||||
<Text color="gray" size="1" mt="2">
|
||||
{games!} {games === 1 ? 'battle' : 'battles'}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { CaretLeftIcon, CaretRightIcon, PlusIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes';
|
||||
import { use, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { asset } from '../../../../../../../core/blitzkit/asset';
|
||||
import { imgur } from '../../../../../../../core/blitzkit/imgur';
|
||||
import { tankDefinitions } from '../../../../../../../core/blitzkit/tankDefinitions';
|
||||
import * as Duel from '../../../../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
|
||||
import { Arrow } from './components/Arrow';
|
||||
import { Node } from './components/Node';
|
||||
|
||||
type Line = number[];
|
||||
|
||||
export const XP_MULTIPLIERS = [1, 2, 3, 4, 5, 10];
|
||||
|
||||
export function TechTreeSection() {
|
||||
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const master = Duel.use((state) => state.protagonist.tank);
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const lines = useMemo(() => {
|
||||
function extend(line: Line): Line[] {
|
||||
const root = awaitedTankDefinitions[line.at(-1)!];
|
||||
|
||||
if (root.ancestors === undefined || root.tier === 1) {
|
||||
return [line];
|
||||
} else {
|
||||
if (root.ancestors.length === 1 || root.tier === 2) {
|
||||
line.push(root.ancestors[0]);
|
||||
return extend(line);
|
||||
} else {
|
||||
return root.ancestors
|
||||
.map((ancestor) => {
|
||||
const newLine = [...line, ancestor];
|
||||
return extend(newLine);
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extend([master.id]);
|
||||
}, [master]);
|
||||
const [lineIndex, setLineIndex] = useState(0);
|
||||
const line = useMemo(
|
||||
() => [...lines[lineIndex]].toReversed(),
|
||||
[master, lineIndex],
|
||||
);
|
||||
const totalXp = line.reduce(
|
||||
(xp, id) =>
|
||||
xp +
|
||||
(awaitedTankDefinitions[id].xp === undefined ||
|
||||
awaitedTankDefinitions[id].tier === 1
|
||||
? 0
|
||||
: awaitedTankDefinitions[id].xp),
|
||||
0,
|
||||
);
|
||||
const totalCredits = line.reduce(
|
||||
(credits, id) => credits + awaitedTankDefinitions[id].price.value,
|
||||
0,
|
||||
);
|
||||
|
||||
if (
|
||||
master.treeType !== 'researchable' ||
|
||||
master.ancestors === undefined ||
|
||||
master.ancestors.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!container.current) return;
|
||||
|
||||
container.current.scrollLeft = container.current.scrollWidth;
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
gap="0"
|
||||
style={{ backgroundColor: 'var(--color-surface)' }}
|
||||
py="6"
|
||||
>
|
||||
<Flex direction="column" align="center">
|
||||
<Heading size="6">Tech tree</Heading>
|
||||
|
||||
{master.tier > 1 && (
|
||||
<Flex gap="4" align="center">
|
||||
<Text color="gray" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="XP"
|
||||
src={asset('icons/currencies/xp.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{Math.round(totalXp / xpMultiplier).toLocaleString()}
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<Text color="gray" wrap="nowrap">
|
||||
<Flex gap="1" align="center">
|
||||
<img
|
||||
alt="Silver"
|
||||
src={asset('icons/currencies/silver.webp')}
|
||||
style={{
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
{totalCredits.toLocaleString()}
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex gap="1" mt="2">
|
||||
{XP_MULTIPLIERS.map((multiplier) => {
|
||||
const selected = xpMultiplier === multiplier;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
style={{
|
||||
background: `url(${imgur('7hDltb4')})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
opacity: selected ? 1 : 0.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.xpMultiplier = multiplier;
|
||||
});
|
||||
}}
|
||||
key={multiplier}
|
||||
width="2.5rem"
|
||||
justify="center"
|
||||
align="center"
|
||||
pt="1"
|
||||
pl="1"
|
||||
>
|
||||
<Text size="2">
|
||||
x<b>{multiplier}</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<ScrollArea type="hover" scrollbars="horizontal" ref={container}>
|
||||
<Flex align="center" gap="2" justify="center" p="4">
|
||||
{line.map((id, index) => {
|
||||
const last = index === line.length - 1;
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
|
||||
return (
|
||||
<>
|
||||
{index > 0 && <Arrow />}
|
||||
<Node
|
||||
key={id}
|
||||
id={id}
|
||||
nextIds={last ? tank.successors : [line[index + 1]]}
|
||||
highlight={last}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
{master.tier < 10 && (
|
||||
<>
|
||||
<Arrow />
|
||||
{master.successors!.map((id, index) => (
|
||||
<>
|
||||
{index > 0 && (
|
||||
<Text color="gray">
|
||||
<PlusIcon />
|
||||
</Text>
|
||||
)}
|
||||
<Node key={id} id={id} />
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
|
||||
<Flex>
|
||||
{lines.length > 1 && (
|
||||
<Flex align="center" gap="2">
|
||||
<IconButton
|
||||
size="2"
|
||||
variant="soft"
|
||||
onClick={() =>
|
||||
setLineIndex((lines.length + lineIndex - 1) % lines.length)
|
||||
}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</IconButton>
|
||||
<Text>
|
||||
Route {lineIndex + 1} of {lines.length}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="2"
|
||||
variant="soft"
|
||||
onClick={() => setLineIndex((lineIndex + 1) % lines.length)}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
Cross1Icon,
|
||||
CameraIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EnterFullScreenIcon,
|
||||
ExitFullScreenIcon,
|
||||
EyeOpenIcon,
|
||||
@@ -11,15 +13,16 @@ import {
|
||||
Dialog,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Popover,
|
||||
SegmentedControl,
|
||||
Strong,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@radix-ui/themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invalidate } from '@react-three/fiber';
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
|
||||
import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon';
|
||||
import { ThicknessRange } from '../../../../../../../components/StaticArmor';
|
||||
@@ -28,10 +31,7 @@ import { resolvePenetrationCoefficient } from '../../../../../../../core/blitz/r
|
||||
import { asset } from '../../../../../../../core/blitzkit/asset';
|
||||
import { imgur } from '../../../../../../../core/blitzkit/imgur';
|
||||
import { Pose, poseEvent } from '../../../../../../../core/blitzkit/pose';
|
||||
import {
|
||||
SHELL_NAMES,
|
||||
TIER_ROMAN_NUMERALS,
|
||||
} from '../../../../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import { uniqueGuns } from '../../../../../../../core/blitzkit/uniqueGuns';
|
||||
import { useEquipment } from '../../../../../../../hooks/useEquipment';
|
||||
import { useFullScreen } from '../../../../../../../hooks/useFullScreen';
|
||||
@@ -48,9 +48,10 @@ import { Thicknesses } from '../../Thicknesses';
|
||||
|
||||
interface OptionsProps {
|
||||
thicknessRange: ThicknessRange;
|
||||
canvas: RefObject<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
export function Options({ thicknessRange }: OptionsProps) {
|
||||
export function Options({ thicknessRange, canvas }: OptionsProps) {
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
|
||||
const displayRaw = TankopediaPersistent.use((state) => state.display);
|
||||
@@ -75,12 +76,12 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
const environment = TankopediaPersistent.use(
|
||||
(state) => state.model.visual.environment,
|
||||
);
|
||||
const developerMode = App.use((state) => state.developerMode);
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const protagonist = Duel.use((state) => state.protagonist.tank);
|
||||
const antagonistGun = Duel.use((state) => state.antagonist.gun);
|
||||
const antagonistShell = Duel.use((state) => state.antagonist.shell);
|
||||
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
|
||||
const antagonistTank = Duel.use((state) => state.antagonist.tank);
|
||||
const antagonistTurret = Duel.use((state) => state.antagonist.turret);
|
||||
const hasCalibratedShells = useEquipment(103, true);
|
||||
const [tab, setTab] = useState('search');
|
||||
const mutateDuel = Duel.useMutation();
|
||||
@@ -126,12 +127,9 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
<Flex
|
||||
gap="2"
|
||||
direction="column"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
top="50%"
|
||||
right="3"
|
||||
style={{ position: 'absolute', transform: 'translateY(-50%)' }}
|
||||
align="end"
|
||||
>
|
||||
<Text color="gray" size={{ initial: '1', sm: '2' }}>
|
||||
@@ -181,12 +179,11 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content side="left" align="center">
|
||||
<Flex>
|
||||
<Flex gap="2">
|
||||
{[...antagonistUniqueGuns]
|
||||
.reverse()
|
||||
.map(({ gun, turret }, index) => (
|
||||
<ModuleButton
|
||||
rowChild
|
||||
module="gun"
|
||||
key={gun.id}
|
||||
onClick={() =>
|
||||
@@ -196,8 +193,6 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
draft.antagonist.shell = gun.shells[0];
|
||||
})
|
||||
}
|
||||
first={index === 0}
|
||||
last={index === antagonistUniqueGuns.length - 1}
|
||||
selected={gun.id === antagonistGun.id}
|
||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||
/>
|
||||
@@ -225,6 +220,7 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
marginTop: shellIndex === 0 ? 0 : -1,
|
||||
}}
|
||||
onClick={() => {
|
||||
invalidate();
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.shell = thisShell;
|
||||
});
|
||||
@@ -308,6 +304,59 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Box position="absolute" top="3" right="3">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton size={{ initial: '2', sm: '3' }} variant="soft">
|
||||
<CameraIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Flex direction="column" gap="2">
|
||||
<Popover.Close>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!canvas.current) return;
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.setAttribute('download', `${protagonist.name}.png`);
|
||||
anchor.setAttribute(
|
||||
'href',
|
||||
canvas.current.toDataURL('image/png'),
|
||||
);
|
||||
anchor.click();
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</Button>
|
||||
</Popover.Close>
|
||||
<Popover.Close>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!canvas.current) return;
|
||||
|
||||
canvas.current.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
Copy
|
||||
</Button>
|
||||
</Popover.Close>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -343,136 +392,21 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
onValueChange={setTab}
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Dialog.Close>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ position: 'absolute', right: 0, top: 8 }}
|
||||
>
|
||||
<Cross1Icon />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
|
||||
<Flex gap="4" direction="column">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="search">Search</Tabs.Trigger>
|
||||
<Tabs.Trigger value="configure">Configure</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="search">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.tank = tank;
|
||||
draft.antagonist.engine = tank.engines.at(-1)!;
|
||||
draft.antagonist.track = tank.tracks.at(-1)!;
|
||||
draft.antagonist.turret = tank.turrets.at(-1)!;
|
||||
draft.antagonist.gun =
|
||||
draft.antagonist.turret.guns.at(-1)!;
|
||||
draft.antagonist.shell =
|
||||
draft.antagonist.gun.shells[0];
|
||||
});
|
||||
setAntagonistSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="configure">
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex direction="column" gap="2" style={{ flex: 1 }}>
|
||||
<Heading size="4">
|
||||
{antagonistTank.name} modules
|
||||
</Heading>
|
||||
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex>
|
||||
{antagonistTank.turrets.map((turret, index) => (
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index === antagonistTank.turrets.length - 1
|
||||
}
|
||||
key={turret.id}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.turret = turret;
|
||||
draft.antagonist.gun =
|
||||
turret.guns.at(-1)!;
|
||||
draft.antagonist.shell =
|
||||
draft.antagonist.gun.shells[0];
|
||||
});
|
||||
mutateTankopediaEphemeral((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
selected={antagonistTurret.id === turret.id}
|
||||
discriminator={
|
||||
TIER_ROMAN_NUMERALS[turret.tier]
|
||||
}
|
||||
module="turret"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{antagonistTurret.guns.map((gun, index) => (
|
||||
<ModuleButton
|
||||
key={gun.id}
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index === antagonistTurret.guns.length - 1
|
||||
}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.gun = gun;
|
||||
draft.antagonist.shell = gun.shells[0];
|
||||
});
|
||||
}}
|
||||
selected={antagonistGun.id === gun.id}
|
||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||
module="gun"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" style={{ flex: 1 }}>
|
||||
<Heading size="4">Properties</Heading>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Turret: <b>{antagonistTurret.name}</b>
|
||||
</li>
|
||||
<li>
|
||||
Gun: <b>{antagonistGun.name}</b>
|
||||
</li>
|
||||
<li>Shells:</li>
|
||||
<ul>
|
||||
{antagonistGun.shells.map((shell) => (
|
||||
<li key={shell.id}>
|
||||
{SHELL_NAMES[shell.type]}:{' '}
|
||||
<b>
|
||||
{resolveNearPenetration(shell.penetration)}
|
||||
mm
|
||||
</b>
|
||||
, {shell.damage.armor}HP
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Tabs.Content>
|
||||
</Flex>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.tank = tank;
|
||||
draft.antagonist.engine = tank.engines.at(-1)!;
|
||||
draft.antagonist.track = tank.tracks.at(-1)!;
|
||||
draft.antagonist.turret = tank.turrets.at(-1)!;
|
||||
draft.antagonist.gun =
|
||||
draft.antagonist.turret.guns.at(-1)!;
|
||||
draft.antagonist.shell = draft.antagonist.gun.shells[0];
|
||||
});
|
||||
setAntagonistSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -489,19 +423,25 @@ export function Options({ thicknessRange }: OptionsProps) {
|
||||
}}
|
||||
>
|
||||
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
<Tooltip content="Model">
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}>
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
<Tooltip content="Dynamic armor">
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}>
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
<Tooltip content="Static armor">
|
||||
<Flex height="100%" align="center">
|
||||
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
|
||||
|
||||
@@ -8,13 +8,7 @@ export function ShotDisplaySection() {
|
||||
if (!shot) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="center"
|
||||
display={{
|
||||
initial: 'flex',
|
||||
sm: 'none',
|
||||
}}
|
||||
>
|
||||
<Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
|
||||
<ShotDisplayCard shot={shot} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Callout, Flex } from '@radix-ui/themes';
|
||||
import { ExperimentIcon } from '../../../../../components/ExperimentIcon';
|
||||
import * as Duel from '../../../../../stores/duel';
|
||||
|
||||
export function TestingSection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
|
||||
if (!tank.testing) return null;
|
||||
|
||||
return (
|
||||
<Flex justify="center" mb="4">
|
||||
<Callout.Root>
|
||||
<Callout.Icon>
|
||||
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
Tanks in testing are subject to change and may not represent the final
|
||||
product.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,114 @@
|
||||
import { Flex, Heading, Link, Text } from '@radix-ui/themes';
|
||||
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { Link } from '../../../../../components/Link';
|
||||
import PageWrapper from '../../../../../components/PageWrapper';
|
||||
import { videoDefinitions } from '../../../../../core/blitzkit/videos';
|
||||
import { VerifiedIcon } from '../../../../../components/VerifiedIcon';
|
||||
import { youtubers } from '../../../../../constants/youtubers';
|
||||
import { reviews } from '../../../../../core/blitzkit/reviews';
|
||||
import { Var } from '../../../../../core/blitzkit/var';
|
||||
import * as Duel from '../../../../../stores/duel';
|
||||
|
||||
export function VideoSection() {
|
||||
const tank = Duel.use((state) => state.protagonist.tank);
|
||||
const awaitedVideoDefinitions = use(videoDefinitions);
|
||||
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? [];
|
||||
const awaitedReviews = use(reviews);
|
||||
const videos = awaitedReviews.reviews[tank.id]?.videos;
|
||||
|
||||
if (!videos) return null;
|
||||
|
||||
return (
|
||||
<PageWrapper noFlex1>
|
||||
<Flex direction="column" gap="4" align="start">
|
||||
<Flex direction="column">
|
||||
<Flex direction="column" gap="6" align="center">
|
||||
<Flex direction="column" align="center">
|
||||
<Heading size="6">Review videos</Heading>
|
||||
|
||||
<Text color="gray">BlitzKit does not endorse any review videos.</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" wrap="wrap">
|
||||
{videos.map((video) => (
|
||||
<Link
|
||||
key={video}
|
||||
href={`https://www.youtube.com/watch?v=${video}`}
|
||||
target="_blank"
|
||||
<Flex gap="3" wrap="wrap" justify="center">
|
||||
{videos.map((video) => {
|
||||
const image = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
|
||||
const youtuber = youtubers.find(
|
||||
(youtuber) => youtuber.id === video.author,
|
||||
);
|
||||
|
||||
if (!youtuber) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
color="gray"
|
||||
highContrast
|
||||
underline="hover"
|
||||
key={video.id}
|
||||
href={`https://www.youtube.com/watch?v=${video.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-3)',
|
||||
overflow: 'hidden',
|
||||
background: `url(${image})`,
|
||||
backgroundPosition: '0 18px',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
background: `url(${image})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
height: 128,
|
||||
aspectRatio: '16 / 9',
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
p="2"
|
||||
width="100%"
|
||||
justify="center"
|
||||
style={{
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
}}
|
||||
align="center"
|
||||
gap="1"
|
||||
>
|
||||
<Text>{youtuber.name}</Text>
|
||||
<VerifiedIcon width="1em" height="1em" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<Link
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
color="gray"
|
||||
highContrast
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="2"
|
||||
p="3"
|
||||
direction="column"
|
||||
height="100%"
|
||||
style={{
|
||||
backgroundColor: Var('color-panel-solid'),
|
||||
borderRadius: Var('radius-3'),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="Video thumbnail"
|
||||
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`}
|
||||
<VerifiedIcon />
|
||||
<Text
|
||||
align="center"
|
||||
style={{
|
||||
aspectRatio: '16 / 9',
|
||||
objectFit: 'cover',
|
||||
height: 128,
|
||||
borderRadius: 8,
|
||||
width: '9rem',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
>
|
||||
Apply to BlitzKit YouTuber program
|
||||
</Text>
|
||||
</Flex>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/c
|
||||
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
|
||||
import strings from '../../../../lang/en-US.json';
|
||||
import * as Duel from '../../../../stores/duel';
|
||||
import * as TankFilters from '../../../../stores/tankFilters';
|
||||
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
|
||||
|
||||
export default async function Layout({
|
||||
@@ -25,26 +24,24 @@ export default async function Layout({
|
||||
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
|
||||
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
|
||||
} ${strings.common.tank_class_short[tank.class]}`;
|
||||
const description = `Statistics, armor, and equipment for ${tank.name}`;
|
||||
const description = `Statistics, armor, and equipment for ${tank.name} in World of Tanks Blitz (WoTB)`;
|
||||
|
||||
return (
|
||||
<TankopediaEphemeral.Provider data={tankModelDefinition}>
|
||||
<TankFilters.Provider>
|
||||
<Duel.Provider
|
||||
data={{
|
||||
tank: awaitedTankDefinitions[id],
|
||||
provisionDefinitions: awaitedProvisionDefinitions,
|
||||
}}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:image" content={tankIcon(id)} />
|
||||
<meta property="og:description" content={description} />
|
||||
<Duel.Provider
|
||||
data={{
|
||||
tank: awaitedTankDefinitions[id],
|
||||
provisionDefinitions: awaitedProvisionDefinitions,
|
||||
}}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:image" content={tankIcon(id)} />
|
||||
<meta property="og:description" content={description} />
|
||||
|
||||
{children}
|
||||
</Duel.Provider>
|
||||
</TankFilters.Provider>
|
||||
{children}
|
||||
</Duel.Provider>
|
||||
</TankopediaEphemeral.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { invalidate } from '@react-three/fiber';
|
||||
import { useEffect } from 'react';
|
||||
import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive';
|
||||
import PageWrapper from '../../../../components/PageWrapper';
|
||||
@@ -8,9 +9,12 @@ import * as Duel from '../../../../stores/duel';
|
||||
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
|
||||
import { HistorySection } from './components/HistorySection';
|
||||
import { CharacteristicsSection } from './components/Model/CharacteristicsSection';
|
||||
import { GameModeSection } from './components/Model/GameModeSection';
|
||||
import { HeroSection } from './components/Model/HeroSection';
|
||||
import { MetaSection } from './components/Model/MetaSection';
|
||||
import { TechTreeSection } from './components/Model/TechTreeSection';
|
||||
import { ShotDisplaySection } from './components/ShotDisplaySection';
|
||||
import { TestingSection } from './components/TestingSection';
|
||||
import { VideoSection } from './components/VideoSection';
|
||||
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
@@ -31,11 +35,13 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
}
|
||||
|
||||
if (event.key === '1') {
|
||||
invalidate();
|
||||
wipeShot();
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist.shell = draft.antagonist.gun.shells[0];
|
||||
});
|
||||
} else if (event.key === '2') {
|
||||
invalidate();
|
||||
wipeShot();
|
||||
mutateDuel((draft) => {
|
||||
if (draft.antagonist.gun.shells[1]) {
|
||||
@@ -43,6 +49,7 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
}
|
||||
});
|
||||
} else if (event.key === '3') {
|
||||
invalidate();
|
||||
wipeShot();
|
||||
mutateDuel((draft) => {
|
||||
if (draft.antagonist.gun.shells[2]) {
|
||||
@@ -73,16 +80,19 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageWrapper p="0" noMaxWidth color="purple" size={1600}>
|
||||
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
|
||||
<HeroSection id={id} />
|
||||
<ShotDisplaySection />
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
<CharacteristicsSection />
|
||||
<MetaSection />
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
<TechTreeSection />
|
||||
<TestingSection />
|
||||
<CharacteristicsSection />
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
<GameModeSection />
|
||||
<VideoSection />
|
||||
<HistorySection />
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
{!exempt && <AdMidSectionResponsive mb="6" />}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ReloadIcon } from '@radix-ui/react-icons';
|
||||
import { Box, Flex, IconButton, Text } from '@radix-ui/themes';
|
||||
import { Box, Flex, IconButton, Text, Tooltip } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { use } from 'react';
|
||||
import { classIcons } from '../../../../components/ClassIcon';
|
||||
@@ -14,6 +14,7 @@ import { asset } from '../../../../core/blitzkit/asset';
|
||||
import { gameDefinitions } from '../../../../core/blitzkit/gameDefinitions';
|
||||
import { Tier } from '../../../../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import strings from '../../../../lang/en-US.json';
|
||||
import * as TankFilters from '../../../../stores/tankFilters';
|
||||
|
||||
interface FilterControlProps {
|
||||
@@ -39,24 +40,26 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
const selected = filters.tiers?.includes(tier);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={tier}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.tiers?.includes(tier)) {
|
||||
draft.tiers = draft.tiers?.filter((t) => t !== tier);
|
||||
} else {
|
||||
draft.tiers = [...(draft.tiers ?? []), tier];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
|
||||
</IconButton>
|
||||
<Tooltip key={tier} content={`Tier ${tier}`}>
|
||||
<IconButton
|
||||
key={tier}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.tiers?.includes(tier)) {
|
||||
draft.tiers = draft.tiers?.filter((t) => t !== tier);
|
||||
} else {
|
||||
draft.tiers = [...(draft.tiers ?? []), tier];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
@@ -66,24 +69,26 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
const selected = filters.tiers?.includes(tier);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={tier}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.tiers?.includes(tier)) {
|
||||
draft.tiers = draft.tiers?.filter((t) => t !== tier);
|
||||
} else {
|
||||
draft.tiers = [...(draft.tiers ?? []), tier];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
|
||||
</IconButton>
|
||||
<Tooltip key={tier} content={`Tier ${tier}`}>
|
||||
<IconButton
|
||||
key={tier}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.tiers?.includes(tier)) {
|
||||
draft.tiers = draft.tiers?.filter((t) => t !== tier);
|
||||
} else {
|
||||
draft.tiers = [...(draft.tiers ?? []), tier];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
@@ -101,29 +106,37 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
const selected = filters.nations?.includes(nation);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={nation}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
radius="none"
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.nations?.includes(nation)) {
|
||||
draft.nations = draft.nations?.filter(
|
||||
(n) => n !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.nations = [...(draft.nations ?? []), nation];
|
||||
}
|
||||
})
|
||||
<Tooltip
|
||||
content={
|
||||
strings.common.nations[
|
||||
nation as keyof typeof strings.common.nations
|
||||
]
|
||||
}
|
||||
key={nation}
|
||||
>
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
src={asset(`flags/circle/${nation}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
radius="none"
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.nations?.includes(nation)) {
|
||||
draft.nations = draft.nations?.filter(
|
||||
(n) => n !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.nations = [...(draft.nations ?? []), nation];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
src={asset(`flags/circle/${nation}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
@@ -134,30 +147,39 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
const selected = filters.nations?.includes(nation);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={nation}
|
||||
style={{ flex: 1 }}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
radius="none"
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.nations?.includes(nation)) {
|
||||
draft.nations = draft.nations?.filter(
|
||||
(n) => n !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.nations = [...(draft.nations ?? []), nation];
|
||||
}
|
||||
})
|
||||
<Tooltip
|
||||
content={
|
||||
strings.common.nations[
|
||||
nation as keyof typeof strings.common.nations
|
||||
]
|
||||
}
|
||||
key={nation}
|
||||
>
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
src={asset(`flags/circle/${nation}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
key={nation}
|
||||
style={{ flex: 1 }}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
radius="none"
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.nations?.includes(nation)) {
|
||||
draft.nations = draft.nations?.filter(
|
||||
(n) => n !== nation,
|
||||
);
|
||||
} else {
|
||||
draft.nations = [...(draft.nations ?? []), nation];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<img
|
||||
style={{ width: '1em', height: '1em' }}
|
||||
src={asset(`flags/circle/${nation}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
@@ -173,26 +195,30 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
const selected = filters.classes?.includes(tankClass);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
<Tooltip
|
||||
content={strings.common.tank_class_medium[tankClass]}
|
||||
key={tankClass}
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.classes?.includes(tankClass)) {
|
||||
draft.classes = draft.classes?.filter(
|
||||
(c) => c !== tankClass,
|
||||
);
|
||||
} else {
|
||||
draft.classes = [...(draft.classes ?? []), tankClass];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon style={{ width: '1em', height: '1em' }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant={selected ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={selected ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.classes?.includes(tankClass)) {
|
||||
draft.classes = draft.classes?.filter(
|
||||
(c) => c !== tankClass,
|
||||
);
|
||||
} else {
|
||||
draft.classes = [...(draft.classes ?? []), tankClass];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon style={{ width: '1em', height: '1em' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
@@ -202,67 +228,75 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
|
||||
>
|
||||
<IconButton
|
||||
variant={filters.types?.includes('researchable') ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.types?.includes('researchable') ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.types?.includes('researchable')) {
|
||||
draft.types = draft.types?.filter((t) => t !== 'researchable');
|
||||
} else {
|
||||
draft.types = [...(draft.types ?? []), 'researchable'];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant={filters.types?.includes('premium') ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.types?.includes('premium') ? 'amber' : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.types?.includes('premium')) {
|
||||
draft.types = draft.types?.filter((t) => t !== 'premium');
|
||||
} else {
|
||||
draft.types = [...(draft.types ?? []), 'premium'];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text
|
||||
color={filters.types?.includes('premium') ? undefined : 'amber'}
|
||||
style={{ display: 'flex', justifyContent: 'center' }}
|
||||
<Tooltip content="Researchable">
|
||||
<IconButton
|
||||
variant={filters.types?.includes('researchable') ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.types?.includes('researchable') ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.types?.includes('researchable')) {
|
||||
draft.types = draft.types?.filter(
|
||||
(t) => t !== 'researchable',
|
||||
);
|
||||
} else {
|
||||
draft.types = [...(draft.types ?? []), 'researchable'];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Text>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant={filters.types?.includes('collector') ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.types?.includes('collector') ? 'blue' : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.types?.includes('collector')) {
|
||||
draft.types = draft.types?.filter((t) => t !== 'collector');
|
||||
} else {
|
||||
draft.types = [...(draft.types ?? []), 'collector'];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text
|
||||
color={filters.types?.includes('collector') ? undefined : 'blue'}
|
||||
style={{ display: 'flex', justifyContent: 'center' }}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Premium">
|
||||
<IconButton
|
||||
variant={filters.types?.includes('premium') ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.types?.includes('premium') ? 'amber' : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.types?.includes('premium')) {
|
||||
draft.types = draft.types?.filter((t) => t !== 'premium');
|
||||
} else {
|
||||
draft.types = [...(draft.types ?? []), 'premium'];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Text>
|
||||
</IconButton>
|
||||
<Text
|
||||
color={filters.types?.includes('premium') ? undefined : 'amber'}
|
||||
style={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Text>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Collector">
|
||||
<IconButton
|
||||
variant={filters.types?.includes('collector') ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.types?.includes('collector') ? 'blue' : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
if (draft.types?.includes('collector')) {
|
||||
draft.types = draft.types?.filter((t) => t !== 'collector');
|
||||
} else {
|
||||
draft.types = [...(draft.types ?? []), 'collector'];
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text
|
||||
color={filters.types?.includes('collector') ? undefined : 'blue'}
|
||||
style={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Text>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
@@ -270,51 +304,57 @@ export function FilterControl({ compact }: FilterControlProps) {
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
|
||||
>
|
||||
<IconButton
|
||||
variant={filters.testing === 'include' ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.testing === 'include' ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
draft.testing = 'include';
|
||||
})
|
||||
}
|
||||
>
|
||||
<ExperimentIcon
|
||||
style={{ width: '1em', height: '1em', color: 'currentColor' }}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant={filters.testing === 'exclude' ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.testing === 'exclude' ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
draft.testing = 'exclude';
|
||||
})
|
||||
}
|
||||
>
|
||||
<ScienceOffIcon
|
||||
style={{ width: '1em', height: '1em', color: 'currentColor' }}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant={filters.testing === 'only' ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.testing === 'only' ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
draft.testing = 'only';
|
||||
})
|
||||
}
|
||||
>
|
||||
<ScienceIcon
|
||||
style={{ width: '1em', height: '1em', color: 'currentColor' }}
|
||||
/>
|
||||
</IconButton>
|
||||
<Tooltip content="Include test tank">
|
||||
<IconButton
|
||||
variant={filters.testing === 'include' ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.testing === 'include' ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
draft.testing = 'include';
|
||||
})
|
||||
}
|
||||
>
|
||||
<ExperimentIcon
|
||||
style={{ width: '1em', height: '1em', color: 'currentColor' }}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Exclude test tank">
|
||||
<IconButton
|
||||
variant={filters.testing === 'exclude' ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.testing === 'exclude' ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
draft.testing = 'exclude';
|
||||
})
|
||||
}
|
||||
>
|
||||
<ScienceOffIcon
|
||||
style={{ width: '1em', height: '1em', color: 'currentColor' }}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Only test tank">
|
||||
<IconButton
|
||||
variant={filters.testing === 'only' ? 'solid' : 'soft'}
|
||||
radius="none"
|
||||
color={filters.testing === 'only' ? undefined : 'gray'}
|
||||
highContrast
|
||||
onClick={() =>
|
||||
mutateTankFilters((draft) => {
|
||||
draft.testing = 'only';
|
||||
})
|
||||
}
|
||||
>
|
||||
<ScienceIcon
|
||||
style={{ width: '1em', height: '1em', color: 'currentColor' }}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Box>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface SearchBarProps {
|
||||
|
||||
export function SearchBar({ topResult, onSelect }: SearchBarProps) {
|
||||
const tankFiltersStore = TankFilters.useStore();
|
||||
const lastSearch = tankFiltersStore.getState().search;
|
||||
const router = useRouter();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const searching = TankFilters.use((state) => state.searching);
|
||||
@@ -58,6 +59,7 @@ export function SearchBar({ topResult, onSelect }: SearchBarProps) {
|
||||
<Flex justify="center" mt="4">
|
||||
<Flex gap="2" flexGrow="1">
|
||||
<TextField.Root
|
||||
defaultValue={lastSearch}
|
||||
style={{ flex: 1 }}
|
||||
ref={input}
|
||||
placeholder="Search tanks..."
|
||||
|
||||
@@ -9,6 +9,7 @@ export const card = style({
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
background: cardBackgroundVar,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const image = style({
|
||||
@@ -34,6 +35,7 @@ export const name = style({
|
||||
export const link = style({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
width: '100%',
|
||||
|
||||
':hover': {
|
||||
textDecoration: 'underline',
|
||||
|
||||
@@ -162,7 +162,6 @@ export function TankCard({ tank, onSelect }: TankCardProps) {
|
||||
align="center"
|
||||
overflow="hidden"
|
||||
width="100%"
|
||||
maxWidth="100%"
|
||||
>
|
||||
<Icon className={styles.classIcon} />
|
||||
<Text align="center" className={styles.name}>
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
TankDefinition,
|
||||
tankDefinitions,
|
||||
tankDefinitionsArray,
|
||||
tankNames,
|
||||
} from '../../../../../core/blitzkit/tankDefinitions';
|
||||
import { tankNames } from '../../../../../core/blitzkit/tankDefinitions/tankNames';
|
||||
import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox';
|
||||
import { useAdExempt } from '../../../../../hooks/useAdExempt';
|
||||
import * as TankFilters from '../../../../../stores/tankFilters';
|
||||
@@ -375,8 +375,7 @@ export const TankSearch = memo<TankSearchProps>(
|
||||
return sort.direction === 'ascending' ? sorted : sorted.reverse();
|
||||
} else {
|
||||
const searchedRaw = go(filters.search, awaitedTankNames, {
|
||||
key: 'combined',
|
||||
limit: 25,
|
||||
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
|
||||
});
|
||||
const searchedTanks = searchedRaw.map(
|
||||
(result) => awaitedTankDefinitions[result.obj.id],
|
||||
@@ -434,8 +433,8 @@ export const TankSearch = memo<TankSearchProps>(
|
||||
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
Tanks in testing are subject to change and many not represent
|
||||
the final product.
|
||||
Tanks in testing are subject to change and may not represent the
|
||||
final product.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
</Flex>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import * as TankFilters from '../../../stores/tankFilters';
|
||||
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
|
||||
import * as TankopediaSort from '../../../stores/tankopediaSort';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<TankopediaPersistent.Provider>
|
||||
<TankFilters.Provider>
|
||||
<TankopediaPersistent.Provider>
|
||||
<TankopediaSort.Provider>{children}</TankopediaSort.Provider>
|
||||
</TankopediaPersistent.Provider>
|
||||
</TankopediaPersistent.Provider>
|
||||
</TankFilters.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import * as TankFilters from '../../../stores/tankFilters';
|
||||
import { TankSearch } from './components/TankSearch';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<TankFilters.Provider>
|
||||
<PageWrapper color="purple" size={1028}>
|
||||
<TankSearch />
|
||||
</PageWrapper>
|
||||
</TankFilters.Provider>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,7 @@ export function ArmorPlateDisplay() {
|
||||
(state) => state.highlightArmor,
|
||||
);
|
||||
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
|
||||
const developerMode = App.use((state) => state.developerMode);
|
||||
const devThicknessInput = useRef<HTMLInputElement>(null);
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const duelStore = Duel.useStore();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useArmor } from '../../../hooks/useArmor';
|
||||
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
|
||||
import { useTankTransform } from '../../../hooks/useTankTransform';
|
||||
import * as Duel from '../../../stores/duel';
|
||||
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
|
||||
import { ModelTankWrapper } from './ModelTankWrapper';
|
||||
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
|
||||
|
||||
@@ -29,13 +28,6 @@ export const PrimaryArmorScene = memo(() => {
|
||||
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
|
||||
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
|
||||
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
|
||||
const tankopediaPersistentStore = TankopediaPersistent.useStore();
|
||||
|
||||
console.log(
|
||||
tankModelDefinition.armor,
|
||||
turretModelDefinition.armor,
|
||||
gunModelDefinition.armor,
|
||||
);
|
||||
|
||||
useTankTransform(protagonist, turretContainer, gunContainer);
|
||||
|
||||
|
||||
@@ -140,8 +140,8 @@ export function SpacedArmorSceneComponent({
|
||||
|
||||
if (
|
||||
!allowRicochet &&
|
||||
!noDuplicateIntersections.some((intersection) => {
|
||||
intersection.object.userData.type === ArmorType.Primary;
|
||||
noDuplicateIntersections.every((intersection) => {
|
||||
intersection.object.userData.type !== ArmorType.Primary;
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { Flex, FlexProps } from '@radix-ui/themes';
|
||||
import { ConsumableEntry } from '../core/blitzkit/consumableDefinitions';
|
||||
import { ConsumableButton } from './ModuleButtons/ConsumableButton';
|
||||
|
||||
interface ConsumablesManagerProps {
|
||||
type ConsumablesManagerProps = FlexProps & {
|
||||
consumables: ConsumableEntry[];
|
||||
selected: number[];
|
||||
onChange?: (consumables: number[]) => void;
|
||||
onConsumablesChange?: (consumables: number[]) => void;
|
||||
disabled?: boolean;
|
||||
timers?: boolean;
|
||||
hasHighEndConsumables?: boolean;
|
||||
hasConsumableDeliverySystem?: boolean;
|
||||
cooldownBooster?: number;
|
||||
}
|
||||
};
|
||||
|
||||
export function ConsumablesManager({
|
||||
consumables,
|
||||
selected,
|
||||
onChange,
|
||||
onConsumablesChange,
|
||||
disabled,
|
||||
timers,
|
||||
hasConsumableDeliverySystem,
|
||||
cooldownBooster,
|
||||
hasHighEndConsumables,
|
||||
...props
|
||||
}: ConsumablesManagerProps) {
|
||||
return (
|
||||
<Flex
|
||||
wrap="wrap"
|
||||
style={{
|
||||
maxWidth: 288,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex wrap="wrap" gap="2" {...props}>
|
||||
{consumables.map((consumable, index) => {
|
||||
const isSelected = selected.some((id) => id === consumable.id);
|
||||
|
||||
@@ -53,14 +47,11 @@ export function ConsumablesManager({
|
||||
: undefined
|
||||
}
|
||||
key={consumable.id}
|
||||
first={index === 0}
|
||||
last={index === consumables.length - 1}
|
||||
rowChild
|
||||
disabled={disabled && !isSelected}
|
||||
consumable={consumable.id}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!onChange) return;
|
||||
if (!onConsumablesChange) return;
|
||||
|
||||
const draft = [...selected];
|
||||
|
||||
@@ -70,7 +61,7 @@ export function ConsumablesManager({
|
||||
draft.push(consumable.id);
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
onConsumablesChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -30,8 +30,6 @@ export function CrewSkillManager({
|
||||
<GenericTankComponentButton
|
||||
key={skill}
|
||||
special={!permanentSkills.includes(skill)}
|
||||
first
|
||||
last
|
||||
selected={level > 0}
|
||||
discriminator={
|
||||
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
|
||||
|
||||
@@ -23,8 +23,10 @@ export function EquipmentManager({
|
||||
<Flex key={columnIndex}>
|
||||
<EquipmentButton
|
||||
equipment={equipment[0]}
|
||||
first
|
||||
rowChild
|
||||
style={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
selected={matrix[rowIndex][columnIndex] === -1}
|
||||
onClick={() => {
|
||||
const draft = cloneDeep(matrix);
|
||||
@@ -40,8 +42,10 @@ export function EquipmentManager({
|
||||
/>
|
||||
<EquipmentButton
|
||||
equipment={equipment[1]}
|
||||
last
|
||||
rowChild
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
selected={matrix[rowIndex][columnIndex] === 1}
|
||||
onClick={() => {
|
||||
const draft = cloneDeep(matrix);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
({ href, onClick, ...props }, ref) => {
|
||||
({ href, onClick, target, ...props }, ref) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (href) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.ctrlKey || event.metaKey || target === '_blank') {
|
||||
window.open(href);
|
||||
} else {
|
||||
router.push(href);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { asset } from '../../core/blitzkit/asset';
|
||||
import { consumableDefinitions } from '../../core/blitzkit/consumableDefinitions';
|
||||
import { useDelta } from '../../hooks/useDelta';
|
||||
import { GenericTankComponentButton } from './GenericTankComponentButton';
|
||||
import { TankComponentButtonProps } from './TankComponentButton';
|
||||
@@ -17,23 +19,22 @@ export function ConsumableButton({
|
||||
duration,
|
||||
...props
|
||||
}: ConsumableButtonProps) {
|
||||
const awaitedConsumableDefinitions = use(consumableDefinitions);
|
||||
const cooldownDelta = useDelta(cooldown ?? 0);
|
||||
const durationDelta = useDelta(duration ?? 0);
|
||||
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
style={{ width: '6rem' }}
|
||||
tooltip={awaitedConsumableDefinitions[consumable].name}
|
||||
icon={asset(`icons/consumables/${consumable}.webp`)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 'fit-content',
|
||||
borderRadius: 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{(cooldown || duration) && (
|
||||
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
|
||||
<Flex direction="column" gap="1" ml="-2" mr="2">
|
||||
{duration && (
|
||||
<Text
|
||||
size="1"
|
||||
color={
|
||||
durationDelta > 0
|
||||
? 'green'
|
||||
@@ -50,6 +51,7 @@ export function ConsumableButton({
|
||||
)}
|
||||
{cooldown && (
|
||||
<Text
|
||||
size="1"
|
||||
color={
|
||||
cooldownDelta < 0
|
||||
? 'green'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { use } from 'react';
|
||||
import { asset } from '../../core/blitzkit/asset';
|
||||
import { equipmentDefinitions } from '../../core/blitzkit/equipmentDefinitions';
|
||||
import { GenericTankComponentButton } from './GenericTankComponentButton';
|
||||
import { TankComponentButtonProps } from './TankComponentButton';
|
||||
|
||||
@@ -7,8 +9,11 @@ interface EquipmentButtonProps extends TankComponentButtonProps {
|
||||
}
|
||||
|
||||
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
tooltip={awaitedEquipmentDefinitions.equipments[equipment].name}
|
||||
icon={asset(`icons/equipment/${equipment}.webp`)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { use } from 'react';
|
||||
import { asset } from '../../core/blitzkit/asset';
|
||||
import { provisionDefinitions } from '../../core/blitzkit/provisionDefinitions';
|
||||
import { GenericTankComponentButton } from './GenericTankComponentButton';
|
||||
import { TankComponentButtonProps } from './TankComponentButton';
|
||||
|
||||
@@ -7,8 +9,11 @@ interface ProvisionButtonProps extends TankComponentButtonProps {
|
||||
}
|
||||
|
||||
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
tooltip={awaitedProvisionDefinitions[provision].name}
|
||||
icon={asset(`icons/provisions/${provision}.webp`)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
import { Button, Text } from '@radix-ui/themes';
|
||||
import { Button, Text, Tooltip } from '@radix-ui/themes';
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
|
||||
export interface TankComponentButtonProps
|
||||
extends ComponentProps<typeof Button> {
|
||||
selected?: boolean;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
rowChild?: boolean;
|
||||
children?: ReactNode;
|
||||
discriminator?: ReactNode;
|
||||
disabled?: boolean;
|
||||
banner?: string;
|
||||
special?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function TankComponentButton({
|
||||
variant,
|
||||
selected,
|
||||
first = false,
|
||||
last = false,
|
||||
rowChild,
|
||||
discriminator,
|
||||
special,
|
||||
children,
|
||||
banner,
|
||||
disabled,
|
||||
style,
|
||||
tooltip,
|
||||
...props
|
||||
}: TankComponentButtonProps) {
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
radius="medium"
|
||||
color={selected ? (special ? 'amber' : undefined) : 'gray'}
|
||||
@@ -38,11 +34,6 @@ export function TankComponentButton({
|
||||
minWidth: 48,
|
||||
height: 40,
|
||||
position: 'relative',
|
||||
borderTopLeftRadius: first ? undefined : 0,
|
||||
borderTopRightRadius: last ? undefined : 0,
|
||||
borderBottomLeftRadius: first ? undefined : 0,
|
||||
borderBottomRightRadius: last ? undefined : 0,
|
||||
margin: rowChild ? -0.5 : 'unset',
|
||||
overflow: 'hidden',
|
||||
cursor: 'unset',
|
||||
pointerEvents: variant === 'ghost' ? 'none' : undefined,
|
||||
@@ -84,4 +75,7 @@ export function TankComponentButton({
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) return <Tooltip content={tooltip}>{button}</Tooltip>;
|
||||
return button;
|
||||
}
|
||||
@@ -41,9 +41,6 @@ export function ModuleManager({
|
||||
<ModuleButton
|
||||
module="turret"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setTurretMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -85,9 +82,6 @@ export function ModuleManager({
|
||||
<ModuleButton
|
||||
module="gun"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setGunMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -128,9 +122,6 @@ export function ModuleManager({
|
||||
<ModuleButton
|
||||
module="engine"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setEngineMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -170,9 +161,6 @@ export function ModuleManager({
|
||||
<ModuleButton
|
||||
module="chassis"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setTrackMenuOpen(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
|
||||
import { Card, TextField } from '@radix-ui/themes';
|
||||
import { ComponentProps, useState } from 'react';
|
||||
|
||||
export function EverythingSearch({
|
||||
style,
|
||||
...props
|
||||
}: ComponentProps<typeof TextField.Root>) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField.Root
|
||||
value={search}
|
||||
placeholder="Search tanks, players, clans..."
|
||||
style={{ position: 'relative', ...style }}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value.trim());
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon />
|
||||
</TextField.Slot>
|
||||
|
||||
{search.length > 0 && (
|
||||
<TextField.Slot>
|
||||
<Card
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
</TextField.Slot>
|
||||
)}
|
||||
</TextField.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,69 @@
|
||||
import { style, styleVariants } from '@vanilla-extract/css';
|
||||
|
||||
const MIN_EXPANDED_TOOLS = 752;
|
||||
const MIN_WIDTH = 640 + 128;
|
||||
const HEIGHT = 48;
|
||||
|
||||
export const hamburger = style({
|
||||
display: 'inline-flex',
|
||||
|
||||
export const hamburgerButton = style({
|
||||
'@media': {
|
||||
[`screen and (min-width: ${MIN_EXPANDED_TOOLS}px)`]: {
|
||||
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const navbarBase = style({
|
||||
position: 'fixed',
|
||||
export const tools = style({
|
||||
display: 'none',
|
||||
|
||||
'@media': {
|
||||
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const navbar = style({
|
||||
height: HEIGHT,
|
||||
marginBottom: -1, // 1px overlap with content to bleed color
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
const navbarExpanderBase = style({
|
||||
transitionDuration: '250ms',
|
||||
height: '1000px',
|
||||
width: '100%',
|
||||
zIndex: 2,
|
||||
padding: '1rem',
|
||||
backgroundColor: 'var(--color-panel-translucent)',
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'max-height 0.2s ease-in-out',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const navbar = styleVariants({
|
||||
expanded: [
|
||||
navbarBase,
|
||||
{
|
||||
maxHeight: '100%',
|
||||
},
|
||||
],
|
||||
collapsed: [
|
||||
navbarBase,
|
||||
{
|
||||
maxHeight: 65,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const toolCard = style({
|
||||
boxShadow: `inset 0 -200px 128px -128px var(--color-overlay)`,
|
||||
backdropFilter: 'blur(4rem) brightness(0.75)',
|
||||
WebkitBackdropFilter: 'blur(4rem) brightness(0.75)',
|
||||
|
||||
':hover': {
|
||||
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
|
||||
},
|
||||
});
|
||||
|
||||
export const toolTexts = style({
|
||||
'@media': {
|
||||
[`screen and (max-width: ${MIN_EXPANDED_TOOLS}px)`]: {
|
||||
display: 'none',
|
||||
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
|
||||
maxHeight: HEIGHT,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const navbarExpander = styleVariants({
|
||||
false: [
|
||||
navbarExpanderBase,
|
||||
{
|
||||
maxHeight: HEIGHT,
|
||||
},
|
||||
],
|
||||
true: [
|
||||
navbarExpanderBase,
|
||||
{
|
||||
maxHeight: '100vh',
|
||||
overscrollBehavior: 'contain',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const navbarContent = style({
|
||||
width: '100%',
|
||||
maxHeight: '100vh',
|
||||
});
|
||||
+281
-268
@@ -12,302 +12,315 @@ import {
|
||||
Button,
|
||||
Dialog,
|
||||
Flex,
|
||||
Grid,
|
||||
IconButton,
|
||||
Inset,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
import { TOOLS } from '../../constants/tools';
|
||||
import { homeTool, TOOLS } from '../../constants/tools';
|
||||
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||
import { imgur, ImgurSize } from '../../core/blitzkit/imgur';
|
||||
import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl';
|
||||
import { PatreonIcon } from '../../icons/Patreon';
|
||||
import { WargamingIcon } from '../../icons/Wargaming';
|
||||
import strings from '../../lang/en-US.json';
|
||||
import { theme } from '../../stitches.config';
|
||||
import * as App from '../../stores/app';
|
||||
import { Link } from '../Link';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const NAVBAR_HEIGHT = 64;
|
||||
|
||||
export default function Navbar() {
|
||||
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const logins = App.use((state) => state.logins);
|
||||
const filteredTools = TOOLS.filter((tool) => !tool.href);
|
||||
const mutateApp = App.useMutation();
|
||||
const tools = [homeTool, ...TOOLS];
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']}
|
||||
style={{ zIndex: 3 }}
|
||||
>
|
||||
<Flex direction="column" align="center">
|
||||
<Flex
|
||||
position="absolute"
|
||||
top="34px"
|
||||
justify="between"
|
||||
align="center"
|
||||
style={{
|
||||
maxWidth: 1024,
|
||||
width: '100%',
|
||||
padding: '0 16px',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
gap="8"
|
||||
>
|
||||
<Flex align="center" gap="4">
|
||||
<IconButton
|
||||
className={styles.hamburgerButton}
|
||||
size="4"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => setShowHamburgerMenu((state) => !state)}
|
||||
>
|
||||
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
href="/"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<b>BlitzKit</b>
|
||||
<Text color="gray" size="2" trim="both">
|
||||
.app
|
||||
</Text>
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center" gap="4" className={styles.toolTexts}>
|
||||
{filteredTools.map((tool) => {
|
||||
const selected = pathname.startsWith(`/tools/${tool.id}`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tool.id}
|
||||
color="gray"
|
||||
size="2"
|
||||
highContrast={selected}
|
||||
underline={selected ? 'always' : 'hover'}
|
||||
href={`/tools/${tool.id}`}
|
||||
>
|
||||
{tool.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="4">
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DiscordLogoIcon width={16} height={16} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<PatreonIcon width={14} height={14} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
color="gray"
|
||||
highContrast
|
||||
>
|
||||
<Flex style={{ width: '100%', height: '100%' }} justify="center">
|
||||
<GearIcon />
|
||||
</Flex>
|
||||
</Link>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<PersonIcon style={{ cursor: 'pointer' }} />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="end"
|
||||
width="320px"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{(logins.patreon || logins.wargaming) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Logged in accounts
|
||||
</Text>
|
||||
|
||||
{logins.patreon && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<PatreonIcon width={15} height={15} />
|
||||
<Text>Patreon</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
mutateApp((draft) => {
|
||||
draft.logins.patreon = undefined;
|
||||
})
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{logins.wargaming && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<WargamingIcon width={15} height={15} />
|
||||
<Text>Wargaming</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
mutateApp((draft) => {
|
||||
draft.logins.wargaming = undefined;
|
||||
})
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!logins.patreon !== !logins.wargaming && (
|
||||
<Inset side="x">
|
||||
<Separator my="4" size="4" />
|
||||
</Inset>
|
||||
)}
|
||||
|
||||
{(!logins.patreon || !logins.wargaming) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Log in with...
|
||||
</Text>
|
||||
|
||||
{!logins.patreon && (
|
||||
<Link style={{ width: '100%' }} href={patreonLoginUrl()}>
|
||||
<Button style={{ width: '100%' }} color="tomato">
|
||||
<PatreonIcon width={15} height={15} /> Patreon
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!logins.wargaming && (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<Button color="red">
|
||||
<WargamingIcon width={15} height={15} /> Wargaming
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content width="fit-content">
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<Text color="gray">Choose your region</Text>
|
||||
|
||||
<Flex gap="2" wrap="wrap">
|
||||
{REGIONS.map((region) => (
|
||||
<Dialog.Close key={region}>
|
||||
<Link
|
||||
href={
|
||||
typeof window !== 'undefined'
|
||||
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
|
||||
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button color="red">
|
||||
{strings.common.regions.normal[region]}
|
||||
</Button>
|
||||
</Link>
|
||||
</Dialog.Close>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex mt="9" gap="3" justify="center" wrap="wrap">
|
||||
{filteredTools.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={`/tools/${tool.id}`}
|
||||
className={styles.toolCard}
|
||||
style={{
|
||||
backgroundImage: `url(${imgur(tool.image, {
|
||||
format: 'jpeg',
|
||||
size: ImgurSize.MediumThumbnail,
|
||||
})})`,
|
||||
cursor: tool.disabled ? 'default' : 'pointer',
|
||||
opacity: tool.disabled ? 0.25 : 1,
|
||||
height: 64,
|
||||
minWidth: 176,
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 8,
|
||||
transition: `box-shadow ${theme.durations.regular}`,
|
||||
}}
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textHighContrast,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
<Flex className={styles.navbar}>
|
||||
<Flex
|
||||
className={styles.navbarExpander[`${showHamburgerMenu}`]}
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Flex justify="center">
|
||||
<Flex flexGrow="1" maxWidth="1024px" p="3" align="center">
|
||||
<Flex gap="3" align="center">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
className={styles.hamburger}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setShowHamburgerMenu((state) => !state);
|
||||
}}
|
||||
>
|
||||
{tool.title}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Link
|
||||
color="gray"
|
||||
highContrast
|
||||
href="/"
|
||||
underline="hover"
|
||||
weight="bold"
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
>
|
||||
BlitzKit
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Box flexGrow="1" />
|
||||
|
||||
<Flex
|
||||
align="center"
|
||||
gap="2"
|
||||
justify="center"
|
||||
className={styles.tools}
|
||||
>
|
||||
{TOOLS.map((tool, index) => {
|
||||
const selected = pathname.startsWith(`/tools/${tool.id}`);
|
||||
|
||||
return (
|
||||
<Fragment key={tool.title}>
|
||||
{index > 0 && <Separator orientation="vertical" size="1" />}
|
||||
<Link
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
color="gray"
|
||||
highContrast={selected}
|
||||
size="2"
|
||||
href={`/tools/${tool.id}`}
|
||||
underline={selected ? 'always' : 'hover'}
|
||||
>
|
||||
{tool.title}
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Box flexGrow="1" />
|
||||
|
||||
<Flex align="center" gap="3">
|
||||
<Link
|
||||
style={{ display: 'flex', alignContent: 'center' }}
|
||||
color="gray"
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
underline="none"
|
||||
target="_blank"
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
>
|
||||
<DiscordLogoIcon />
|
||||
</Link>
|
||||
<Link
|
||||
style={{ display: 'flex', alignContent: 'center' }}
|
||||
color="gray"
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
underline="none"
|
||||
target="_blank"
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
>
|
||||
<PatreonIcon width="0.75em" height="0.75em" />
|
||||
</Link>
|
||||
<Link
|
||||
style={{ display: 'flex', alignContent: 'center' }}
|
||||
color="gray"
|
||||
href="/settings"
|
||||
underline="none"
|
||||
target="_blank"
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
>
|
||||
<GearIcon />
|
||||
</Link>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<PersonIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="end"
|
||||
width="320px"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{(logins.patreon || logins.wargaming) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Logged in accounts
|
||||
</Text>
|
||||
|
||||
{logins.patreon && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<PatreonIcon width={15} height={15} />
|
||||
<Text>Patreon</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
mutateApp((draft) => {
|
||||
draft.logins.patreon = undefined;
|
||||
})
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{logins.wargaming && (
|
||||
<Flex align="center" gap="2" justify="center">
|
||||
<WargamingIcon width={15} height={15} />
|
||||
<Text>Wargaming</Text>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
mutateApp((draft) => {
|
||||
draft.logins.wargaming = undefined;
|
||||
})
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!logins.patreon !== !logins.wargaming && (
|
||||
<Inset side="x">
|
||||
<Separator my="4" size="4" />
|
||||
</Inset>
|
||||
)}
|
||||
|
||||
{(!logins.patreon || !logins.wargaming) && (
|
||||
<Flex direction="column" gap="2">
|
||||
<Text align="center" color="gray">
|
||||
Log in with...
|
||||
</Text>
|
||||
|
||||
{!logins.patreon && (
|
||||
<Link
|
||||
style={{ width: '100%' }}
|
||||
href={patreonLoginUrl()}
|
||||
>
|
||||
<Button style={{ width: '100%' }} color="tomato">
|
||||
<PatreonIcon width={15} height={15} /> Patreon
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!logins.wargaming && (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<Button color="red">
|
||||
<WargamingIcon width={15} height={15} /> Wargaming
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content width="fit-content">
|
||||
<Flex direction="column" gap="4" align="center">
|
||||
<Text color="gray">Choose your region</Text>
|
||||
|
||||
<Flex gap="2" wrap="wrap">
|
||||
{REGIONS.map((region) => (
|
||||
<Dialog.Close key={region}>
|
||||
<Link
|
||||
href={
|
||||
typeof window !== 'undefined'
|
||||
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
|
||||
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button color="red">
|
||||
{strings.common.regions.normal[region]}
|
||||
</Button>
|
||||
</Link>
|
||||
</Dialog.Close>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<ScrollArea>
|
||||
<Grid
|
||||
columns="repeat(auto-fill, minmax(192px, 1fr))"
|
||||
flow="dense"
|
||||
gap="2"
|
||||
width="100%"
|
||||
p="3"
|
||||
pt="0"
|
||||
>
|
||||
{tools.map((tool) => (
|
||||
<Flex
|
||||
key={tool.id}
|
||||
onClick={() => setShowHamburgerMenu(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 'var(--radius-2)',
|
||||
overflow: 'hidden',
|
||||
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size: ImgurSize.MediumThumbnail })})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
aspectRatio: '2 / 1',
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={tool.id.length === 0 ? '/' : `/tools/${tool.id}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
flexGrow="1"
|
||||
style={{
|
||||
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size: ImgurSize.MediumThumbnail })})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
p="2"
|
||||
align="center"
|
||||
width="100%"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel-translucent)',
|
||||
backdropFilter: 'blur(4rem)',
|
||||
WebkitBackdropFilter: 'blur(4rem)',
|
||||
}}
|
||||
>
|
||||
<Text weight="medium">{tool.title}</Text>
|
||||
</Flex>
|
||||
</Link>
|
||||
</Flex>
|
||||
))}
|
||||
</Grid>
|
||||
</ScrollArea>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -15,16 +15,13 @@ export function ProvisionsManager({
|
||||
disabled,
|
||||
}: ProvisionsManagerProps) {
|
||||
return (
|
||||
<Flex wrap="wrap">
|
||||
<Flex wrap="wrap" gap="2">
|
||||
{provisions.map((provision, index) => {
|
||||
const isSelected = selected.includes(provision);
|
||||
|
||||
return (
|
||||
<ProvisionButton
|
||||
key={provision}
|
||||
first={index === 0}
|
||||
last={index === provisions.length - 1}
|
||||
rowChild
|
||||
disabled={disabled && !isSelected}
|
||||
provision={provision}
|
||||
selected={isSelected}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
|
||||
thickness={thickness}
|
||||
node={node}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -116,6 +117,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
|
||||
thickness={trackModelDefinition.thickness}
|
||||
variant="track"
|
||||
node={node}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export function VerifiedIcon(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24px"
|
||||
viewBox="0 -960 960 960"
|
||||
width="24px"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="m344-60-76-128-144-32 14-148-98-112 98-112-14-148 144-32 76-128 136 58 136-58 76 128 144 32-14 148 98 112-98 112 14 148-144 32-76 128-136-58-136 58Zm34-102 102-44 104 44 56-96 110-26-10-112 74-84-74-86 10-112-110-24-58-96-102 44-104-44-56 96-110 24 10 112-74 86 74 84-10 114 110 24 58 96Zm102-318Zm-42 142 226-226-56-58-170 170-86-84-56 56 142 142Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+7
-15
@@ -90,7 +90,7 @@ export const moreTool: Tool = {
|
||||
image: '1nPm6VI',
|
||||
button: {
|
||||
text: 'Join Discord',
|
||||
color: 'indigo',
|
||||
color: 'plum',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,27 +119,19 @@ export const tankPerformanceTool: Tool = {
|
||||
image: 'vOKFB03',
|
||||
};
|
||||
|
||||
export const embedTool: Tool = {
|
||||
id: 'embed',
|
||||
title: 'Embed',
|
||||
description: 'Embeds for streaming',
|
||||
pageDescription:
|
||||
'Create embeds for streaming World of Tanks Blitz with extreme customization',
|
||||
button: {
|
||||
text: 'Create',
|
||||
color: 'red',
|
||||
},
|
||||
image: 'Q0faRYg',
|
||||
export const homeTool: Tool = {
|
||||
id: '',
|
||||
title: 'Home',
|
||||
button: { color: 'blue', text: '' },
|
||||
description: '',
|
||||
image: 'rUPie9G',
|
||||
};
|
||||
|
||||
export const TOOLS: Tool[] = [
|
||||
tankopediaTool,
|
||||
compareTool,
|
||||
tankPerformanceTool,
|
||||
playerStatsTool,
|
||||
sessionTool,
|
||||
embedTool,
|
||||
ratingTool,
|
||||
discordTool,
|
||||
moreTool,
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
export const youtubers = [
|
||||
{ id: 'UC9lE09jxmMgMoG13WrNHPmA', name: 'World of Tanks Blitz' },
|
||||
{ id: 'UCrQ-Dy8lVsm11u8pCJ8W7Tw', name: 'HisRoyalFatness' },
|
||||
{ id: 'UCxRpZqMElssTdPYDK3x7wyg', name: 'N90' },
|
||||
];
|
||||
@@ -2,7 +2,8 @@ import { Locale } from 'discord.js';
|
||||
import { go } from 'fuzzysort';
|
||||
import markdownEscape from 'markdown-escape';
|
||||
import { UserError } from '../../hooks/userError';
|
||||
import { tankDefinitions, tankNames } from '../blitzkit/tankDefinitions';
|
||||
import { tankDefinitions } from '../blitzkit/tankDefinitions';
|
||||
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
|
||||
import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks';
|
||||
import { translator } from '../localization/translator';
|
||||
|
||||
@@ -19,7 +20,7 @@ export default async function resolveTankId(
|
||||
`${tank}`,
|
||||
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
|
||||
{
|
||||
keys: ['combined'],
|
||||
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const averageDefinitions = fetch(asset('averages/manifest.json'), {
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((buffer) =>
|
||||
decode<AverageDefinitions>(
|
||||
'blitzkit.AverageDefinitions',
|
||||
'blitzkit/blitzkit.AverageDefinitions',
|
||||
new Uint8Array(buffer),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { asset } from './asset';
|
||||
import { fetchCdonLz4 } from './fetchCdonLz4';
|
||||
|
||||
export type CamouflageDefinitions = Record<
|
||||
number,
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
tankName?: string;
|
||||
tankNameFull?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export const camouflageDefinitions = fetchCdonLz4<CamouflageDefinitions>(
|
||||
asset('definitions/camouflages.cdon.lz4'),
|
||||
);
|
||||
@@ -7,41 +7,50 @@ export function checkConsumableProvisionInclusivity(
|
||||
tank: TankDefinition,
|
||||
gun: GunDefinition,
|
||||
) {
|
||||
const included = consumableProvision.include.every((rule) => {
|
||||
switch (rule.type) {
|
||||
case 'tier':
|
||||
return rule.min <= tank.tier && tank.tier <= rule.max;
|
||||
const included =
|
||||
!consumableProvision.gameMode &&
|
||||
consumableProvision.include?.every((rule) => {
|
||||
switch (rule.type) {
|
||||
case 'tier':
|
||||
return rule.min <= tank.tier && tank.tier <= rule.max;
|
||||
|
||||
case 'ids':
|
||||
return rule.ids.includes(tank.id);
|
||||
case 'ids':
|
||||
return rule.ids.includes(tank.id);
|
||||
|
||||
case 'nation':
|
||||
return rule.nations.includes(tank.nation);
|
||||
case 'nation':
|
||||
return rule.nations.includes(tank.nation);
|
||||
|
||||
case 'category':
|
||||
throw new SyntaxError('Category filtering found in include rule');
|
||||
}
|
||||
});
|
||||
const excluded = consumableProvision.exclude?.some((rule) => {
|
||||
switch (rule.type) {
|
||||
case 'tier':
|
||||
return rule.min <= tank.tier && tank.tier <= rule.max;
|
||||
case 'category':
|
||||
throw new SyntaxError('Category filtering found in include rule');
|
||||
}
|
||||
});
|
||||
const excluded =
|
||||
consumableProvision.gameMode ||
|
||||
consumableProvision.exclude?.some((rule) => {
|
||||
switch (rule.type) {
|
||||
case 'tier':
|
||||
return rule.min <= tank.tier && tank.tier <= rule.max;
|
||||
|
||||
case 'ids':
|
||||
return rule.ids.includes(tank.id);
|
||||
case 'ids':
|
||||
return rule.ids.includes(tank.id);
|
||||
|
||||
case 'nation':
|
||||
return rule.nations.includes(tank.nation);
|
||||
case 'nation':
|
||||
return rule.nations.includes(tank.nation);
|
||||
|
||||
case 'category':
|
||||
return rule.categories.some((category) => {
|
||||
switch (category) {
|
||||
case 'clip':
|
||||
return gun.type !== 'regular';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
case 'category':
|
||||
return rule.categories.some((category) => {
|
||||
switch (category) {
|
||||
case 'clip':
|
||||
return gun.type !== 'regular';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return consumableProvision.include.length > 0 && included && !excluded;
|
||||
return (
|
||||
!consumableProvision.gameMode &&
|
||||
consumableProvision.include?.length > 0 &&
|
||||
included &&
|
||||
!excluded
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,21 @@ export type TankFilterDefinition =
|
||||
nations: string[];
|
||||
};
|
||||
|
||||
export interface ConsumableEntry {
|
||||
export type ConsumableEntry = {
|
||||
id: number;
|
||||
name: string;
|
||||
cooldown: number;
|
||||
duration?: number;
|
||||
include: TankFilterDefinition[];
|
||||
exclude?: TankFilterDefinition[];
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
gameMode: false;
|
||||
include: TankFilterDefinition[];
|
||||
exclude?: TankFilterDefinition[];
|
||||
}
|
||||
| {
|
||||
gameMode: true;
|
||||
}
|
||||
);
|
||||
|
||||
export interface ConsumableDefinitions {
|
||||
[key: string]: ConsumableEntry;
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { asset } from './asset';
|
||||
import { fetchCdonLz4 } from './fetchCdonLz4';
|
||||
|
||||
interface GameMode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GameDefinitions {
|
||||
version: string;
|
||||
nations: string[];
|
||||
gameModes: Record<number, GameMode>;
|
||||
roles: Record<
|
||||
number,
|
||||
{
|
||||
provisions: number[];
|
||||
consumables: number[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invalidate } from '@react-three/fiber';
|
||||
import { EventManager } from './eventManager';
|
||||
|
||||
export interface ModelTransformEventData {
|
||||
@@ -6,3 +7,4 @@ export interface ModelTransformEventData {
|
||||
}
|
||||
|
||||
export const modelTransformEvent = new EventManager<ModelTransformEventData>();
|
||||
modelTransformEvent.on(() => invalidate());
|
||||
@@ -2,13 +2,20 @@ import { asset } from './asset';
|
||||
import { TankFilterDefinition } from './consumableDefinitions';
|
||||
import { fetchCdonLz4 } from './fetchCdonLz4';
|
||||
|
||||
export interface ProvisionEntry {
|
||||
export type ProvisionEntry = {
|
||||
id: number;
|
||||
name: string;
|
||||
include: TankFilterDefinition[];
|
||||
exclude?: TankFilterDefinition[];
|
||||
crew?: number;
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
gameMode: false;
|
||||
include: TankFilterDefinition[];
|
||||
exclude?: TankFilterDefinition[];
|
||||
}
|
||||
| {
|
||||
gameMode: true;
|
||||
}
|
||||
);
|
||||
|
||||
export interface ProvisionDefinitions {
|
||||
[key: string]: ProvisionEntry;
|
||||
|
||||
@@ -8,21 +8,32 @@ export function resolveDpm(
|
||||
intraClipCoefficient = 1,
|
||||
) {
|
||||
const alpha = shell.damage.armor * damageCoefficient;
|
||||
let dps: number;
|
||||
|
||||
if (gun.type === 'regular') {
|
||||
return (alpha / (reloadCoefficient * gun.reload)) * 60;
|
||||
dps = alpha / (reloadCoefficient * gun.reload);
|
||||
} else if (gun.type === 'autoLoader') {
|
||||
return (
|
||||
((alpha * gun.count) /
|
||||
(gun.reload + (gun.count - 1) * gun.intraClip * intraClipCoefficient)) *
|
||||
60
|
||||
);
|
||||
dps =
|
||||
(alpha * gun.count) /
|
||||
(gun.reload * reloadCoefficient +
|
||||
(gun.count - 1) * gun.intraClip * intraClipCoefficient);
|
||||
} else {
|
||||
return (
|
||||
((alpha * gun.count) /
|
||||
(reloadCoefficient * gun.reload.reduce((a, b) => a + b, 0) +
|
||||
(gun.count - 1) * gun.intraClip * intraClipCoefficient)) *
|
||||
60
|
||||
);
|
||||
const mostOptimalShell = gun.reload.reduce<null | {
|
||||
index: number;
|
||||
reload: number;
|
||||
}>((current, reloadRaw, index) => {
|
||||
const reload =
|
||||
reloadRaw * reloadCoefficient +
|
||||
(index > 0 ? gun.intraClip * intraClipCoefficient : 0);
|
||||
|
||||
if (current === null || reload < current.reload) {
|
||||
return { index, reload };
|
||||
}
|
||||
return current;
|
||||
}, null)!;
|
||||
|
||||
dps = alpha / mostOptimalShell?.reload;
|
||||
}
|
||||
|
||||
return dps * 60;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { decode } from '../protobuf/decode';
|
||||
import { asset } from './asset';
|
||||
|
||||
export interface Reviews {
|
||||
reviews: Record<number, Review>;
|
||||
}
|
||||
|
||||
interface Review {
|
||||
last_updated: number;
|
||||
videos?: Video[];
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export const reviews = fetch(asset('definitions/reviews.pb'))
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((buffer) =>
|
||||
decode<Reviews>('reviews/blitzkit.Reviews', new Uint8Array(buffer)),
|
||||
);
|
||||
@@ -138,8 +138,15 @@ export function tankCharacteristics(
|
||||
0,
|
||||
) + (hasImprovedVentilation ? 0.08 : 0);
|
||||
const commanderMastery = crewMastery + provisionCrewBonus;
|
||||
const crewMemberMastery = commanderMastery * 1.1;
|
||||
|
||||
const loaderMastery =
|
||||
commanderMastery *
|
||||
(tank.crew.some(({ type }) => type === 'loader') ? 1.1 : 1.05);
|
||||
const gunnerMastery =
|
||||
commanderMastery *
|
||||
(tank.crew.some(({ type }) => type === 'gunner') ? 1.1 : 1.05);
|
||||
const driverMastery =
|
||||
commanderMastery *
|
||||
(tank.crew.some(({ type }) => type === 'driver') ? 1.1 : 1.05);
|
||||
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
|
||||
const damageCoefficient =
|
||||
coefficient([hasTungsten, 0.15]) *
|
||||
@@ -150,7 +157,7 @@ export function tankCharacteristics(
|
||||
);
|
||||
const reloadCoefficient =
|
||||
(coefficient([hasGunRammer, -0.05]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)])) /
|
||||
coefficient([true, degressiveStat(loaderMastery)])) /
|
||||
coefficient([hasAdrenaline && gun.type === 'regular', 0.17]);
|
||||
const penetrationCoefficient = coefficient([
|
||||
hasCalibratedShells,
|
||||
@@ -173,10 +180,10 @@ export function tankCharacteristics(
|
||||
coefficient(
|
||||
[hasEnhancedGunLayingDrive, -0.15],
|
||||
[hasReticleCalibration, -0.3],
|
||||
) * coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
) * coefficient([true, degressiveStat(gunnerMastery)]);
|
||||
const dispersionStillCoefficient =
|
||||
coefficient([hasRefinedGun, -0.15], [hasReticleCalibration, -0.3]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
coefficient([true, degressiveStat(gunnerMastery)]);
|
||||
const dispersionDamagedCoefficient = coefficient([
|
||||
hasVerticalStabilizer,
|
||||
-0.12,
|
||||
@@ -212,13 +219,13 @@ export function tankCharacteristics(
|
||||
);
|
||||
const turretTraverseCoefficient =
|
||||
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
|
||||
coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
coefficient([true, progressiveStat(gunnerMastery)]);
|
||||
const hullTraverseCoefficient =
|
||||
coefficient(
|
||||
[hasImprovedControl, 0.11],
|
||||
[hasImprovedEnginePowerBoost, 0.01],
|
||||
[true, crewSkills.virtuoso / 100],
|
||||
) * coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
) * coefficient([true, progressiveStat(driverMastery)]);
|
||||
const resistanceHardCoefficient = coefficient([hasImprovedSuspension, -0.2]);
|
||||
const resistanceMediumCoefficient = coefficient([
|
||||
hasImprovedSuspension,
|
||||
@@ -248,7 +255,18 @@ export function tankCharacteristics(
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const camouflageCoefficientMoving = coefficient(
|
||||
const camouflageCoefficientMoving = sum(
|
||||
[
|
||||
true,
|
||||
coefficient([
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG'
|
||||
? 0.04
|
||||
: tank.class === 'heavyTank'
|
||||
? 0.03
|
||||
: 0.02,
|
||||
]),
|
||||
],
|
||||
[
|
||||
hasCamouflageNet,
|
||||
tank.class === 'heavyTank'
|
||||
@@ -259,12 +277,22 @@ export function tankCharacteristics(
|
||||
? 0.07
|
||||
: 0.1,
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
const camouflageCoefficientStill = coefficient(
|
||||
const camouflageCoefficientStill = sum(
|
||||
[
|
||||
true,
|
||||
coefficient(
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG'
|
||||
? 0.04
|
||||
: tank.class === 'heavyTank'
|
||||
? 0.03
|
||||
: 0.02,
|
||||
],
|
||||
[true, crewSkills.camouflage * (3 / 100)],
|
||||
),
|
||||
],
|
||||
[
|
||||
hasCamouflageNet,
|
||||
tank.class === 'heavyTank'
|
||||
@@ -275,11 +303,6 @@ export function tankCharacteristics(
|
||||
? 0.14
|
||||
: 0.2,
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
[true, crewSkills.camouflage * (3 / 100)],
|
||||
);
|
||||
|
||||
const size = normalizeBoundingBox(
|
||||
@@ -305,24 +328,27 @@ export function tankCharacteristics(
|
||||
reloadCoefficient,
|
||||
intraClipCoefficient,
|
||||
);
|
||||
const dpmMaximum =
|
||||
const intraClip =
|
||||
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
|
||||
const mostOptimalShellIndex =
|
||||
gun.type === 'autoReloader'
|
||||
? (
|
||||
gun.count > 2
|
||||
? gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
: gun.reload.at(-1)! + gun.intraClip < gun.reload.at(-2)!
|
||||
)
|
||||
? ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload.at(-1)! * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
60
|
||||
: ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload[0] * reloadCoefficient)) *
|
||||
60
|
||||
? gun.reload.reduce<null | { index: number; reload: number }>(
|
||||
(current, reloadRaw, index) => {
|
||||
const reload =
|
||||
reloadRaw * reloadCoefficient + (index > 0 ? intraClip! : 0);
|
||||
|
||||
if (current === null || reload < current.reload) {
|
||||
return { index, reload };
|
||||
}
|
||||
return current;
|
||||
},
|
||||
null,
|
||||
)!.index
|
||||
: undefined;
|
||||
const damage = shell.damage.armor * damageCoefficient;
|
||||
const dpmEffective =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? gun.reload[0] > gun.reload[1] + gun.intraClip
|
||||
? ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload.at(-1)! * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
@@ -343,24 +369,15 @@ export function tankCharacteristics(
|
||||
damageCoefficient * shell.damage.armor * gun.reload.slice(1).length
|
||||
: undefined;
|
||||
const shells = gun.type === 'regular' ? 1 : gun.count;
|
||||
const mostOptimalShellIndex =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? gun.reload.length
|
||||
: 1
|
||||
: undefined;
|
||||
const shellReloads =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.map((reload) => reload * reloadCoefficient)
|
||||
: undefined;
|
||||
const shellReload =
|
||||
gun.type === 'autoReloader' ? undefined : gun.reload * reloadCoefficient;
|
||||
const intraClip =
|
||||
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
|
||||
const caliber = shell.caliber;
|
||||
const penetration =
|
||||
resolveNearPenetration(shell.penetration) * penetrationCoefficient;
|
||||
const damage = shell.damage.armor * damageCoefficient;
|
||||
const clipDamage = gun.type === 'regular' ? undefined : gun.count * damage;
|
||||
const moduleDamage = shell.damage.module * damageCoefficient;
|
||||
const explosionRadius = shell.explosionRadius;
|
||||
@@ -470,7 +487,6 @@ export function tankCharacteristics(
|
||||
shellNormalization,
|
||||
shellRicochet,
|
||||
dpm,
|
||||
dpmMaximum,
|
||||
dpmEffective,
|
||||
shells,
|
||||
mostOptimalShellIndex,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { deburr } from 'lodash';
|
||||
import { Vector2Tuple } from 'three';
|
||||
import { TankClass, TreeType } from '../../../components/Tanks';
|
||||
import { asset } from '../asset';
|
||||
@@ -41,8 +40,10 @@ export type ModuleDefinition =
|
||||
| GunDefinition;
|
||||
export interface TankDefinition {
|
||||
id: number;
|
||||
roles: Record<number, number>;
|
||||
description?: string;
|
||||
fixedCamouflage?: boolean;
|
||||
camouflages?: number[];
|
||||
ancestors?: number[];
|
||||
successors?: number[];
|
||||
crew: Crew[];
|
||||
@@ -59,7 +60,7 @@ export interface TankDefinition {
|
||||
turrets: TurretDefinition[];
|
||||
engines: EngineDefinition[];
|
||||
tracks: TrackDefinition[];
|
||||
price: TankDefinitionPrice;
|
||||
price: TankPrice;
|
||||
xp?: number;
|
||||
speed: {
|
||||
forwards: number;
|
||||
@@ -101,7 +102,7 @@ export interface EngineDefinition {
|
||||
weight: number;
|
||||
unlocks?: Unlock[];
|
||||
}
|
||||
export type TankDefinitionPrice =
|
||||
export type TankPrice =
|
||||
| { type: 'credits'; value: number }
|
||||
| { type: 'gold'; value: number };
|
||||
export interface TurretDefinition {
|
||||
@@ -172,24 +173,9 @@ export const tankDefinitions = fetchCdonLz4<TankDefinitions>(
|
||||
asset('definitions/tanks.cdon.lz4'),
|
||||
);
|
||||
|
||||
export const tankDefinitionsArray = tankDefinitions.then((tanks) =>
|
||||
Object.values(tanks).map((tank) => tank),
|
||||
export const tanksDefinitionsArray = tankDefinitions.then((tanks) =>
|
||||
Object.values(tanks),
|
||||
);
|
||||
export const tankNames = tankDefinitionsArray.then((tanks) =>
|
||||
Promise.all(
|
||||
tanks.map(async (tank, index) => {
|
||||
const { id } = (await tankDefinitionsArray)[index];
|
||||
|
||||
return {
|
||||
id,
|
||||
original: tank.name,
|
||||
combined: `${tank.name}${deburr(tank.name)}${tank.nameFull ? `${tank.nameFull}${deburr(tank.nameFull)}` : ''}`,
|
||||
treeType: tank.treeType,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export type Tier = (typeof TIERS)[number];
|
||||
|
||||
export const flags: Record<string, string> = {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { deburr } from 'lodash';
|
||||
import { tanksDefinitionsArray } from '.';
|
||||
import { camouflageDefinitions } from '../camouflageDefinitions';
|
||||
|
||||
export const tankNames = Promise.all([
|
||||
tanksDefinitionsArray,
|
||||
camouflageDefinitions,
|
||||
]).then(([tanks, camouflages]) =>
|
||||
Promise.all(
|
||||
tanks.map(async (tank, index) => {
|
||||
const { id } = (await tanksDefinitionsArray)[index];
|
||||
|
||||
return {
|
||||
id,
|
||||
name: tank.name,
|
||||
nameFull: tank.nameFull,
|
||||
searchableName: tank.nameFull ?? tank.name,
|
||||
searchableNameDeburr: deburr(tank.nameFull ?? tank.name),
|
||||
camouflages: tank.camouflages
|
||||
?.map((id) => camouflages[id].name)
|
||||
.join(' '),
|
||||
treeType: tank.treeType,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
type Name =
|
||||
| `color-${'background' | `panel-${'solid' | 'translucent'}` | 'surface' | 'overlay'}`
|
||||
| `radius-${1 | 2 | 3 | 4 | 5 | 6 | 'full'}`;
|
||||
|
||||
export function Var(name: Name) {
|
||||
return `var(--${name})`;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { asset } from './asset';
|
||||
|
||||
export interface VideoDefinitions {
|
||||
[id: number]: {
|
||||
videos: string[];
|
||||
lastUpdated: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const videoDefinitions = fetch(asset('definitions/videos.csv'))
|
||||
.then((response) => response.text())
|
||||
.then((text) =>
|
||||
text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [idString, lastUpdatedString, ...videos] = line
|
||||
.split(',')
|
||||
.filter(Boolean);
|
||||
const id = Number(idString);
|
||||
const lastUpdated = Number(lastUpdatedString);
|
||||
return { id, videos, lastUpdated };
|
||||
})
|
||||
.filter(({ videos }) => videos.length > 0)
|
||||
.reduce<VideoDefinitions>((accumulator, { id, videos, lastUpdated }) => {
|
||||
accumulator[id] = {
|
||||
videos,
|
||||
lastUpdated,
|
||||
};
|
||||
|
||||
return accumulator;
|
||||
}, {}),
|
||||
);
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CacheType,
|
||||
} from 'discord.js';
|
||||
import { go } from 'fuzzysort';
|
||||
import { tankNames } from '../blitzkit/tankDefinitions';
|
||||
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
|
||||
import {
|
||||
DISCORD_CHOICES_MAX_NAME_SIZE,
|
||||
OVERFLOW_SUFFIX,
|
||||
@@ -29,18 +29,20 @@ export default async function autocompleteTanks(
|
||||
focusedOption.value,
|
||||
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
|
||||
{
|
||||
keys: ['combined'],
|
||||
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
|
||||
limit: 10,
|
||||
},
|
||||
).map(async (item) => {
|
||||
let name = item.obj.original;
|
||||
let name = item.obj.searchableName;
|
||||
|
||||
if (name.length > DISCORD_CHOICES_MAX_NAME_SIZE) {
|
||||
const overSize = name.length - DISCORD_CHOICES_MAX_NAME_SIZE;
|
||||
|
||||
name = `${item.obj.original.slice(
|
||||
name = `${item.obj.searchableName.slice(
|
||||
0,
|
||||
item.obj.original.length - overSize - OVERFLOW_SUFFIX.length,
|
||||
item.obj.searchableName.length -
|
||||
overSize -
|
||||
OVERFLOW_SUFFIX.length,
|
||||
)}${OVERFLOW_SUFFIX}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,9 @@ import { lookup } from './lookup';
|
||||
|
||||
export async function encode(proto: string, data: any) {
|
||||
const Message = await lookup(proto);
|
||||
const verification = Message.verify(data);
|
||||
|
||||
if (verification !== null) throw new Error(verification);
|
||||
|
||||
return Message.encode(Message.create(data)).finish();
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { Root } from 'protobufjs';
|
||||
import { context } from '../blitzkit/context';
|
||||
|
||||
export async function lookup(proto: string) {
|
||||
const name = proto.split('.')[0];
|
||||
const [file, message] = proto.split('/');
|
||||
const root = await new Root().load(
|
||||
context === 'server'
|
||||
? `public/protos/${name}.proto`
|
||||
: `/protos/${name}.proto`,
|
||||
? `public/protos/${file}.proto`
|
||||
: `/protos/${file}.proto`,
|
||||
{ keepCase: true },
|
||||
);
|
||||
|
||||
return root.lookupType(proto);
|
||||
return root.lookupType(message);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Draft, produce } from 'immer';
|
||||
import { createContext, ReactNode, useContext, useRef } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { StoreApi, useStore as useStoreZustand } from 'zustand';
|
||||
|
||||
type StoreProviderProps<InitData> = {
|
||||
@@ -49,6 +56,20 @@ export function createNextSafeStore<Store extends StoreApi<unknown>, InitData>(
|
||||
);
|
||||
}
|
||||
|
||||
function useDeferred<Slice = Type>(
|
||||
initial: Slice,
|
||||
selector: (store: Type) => Slice = (state) => state as Slice,
|
||||
) {
|
||||
const [state, setState] = useState(initial);
|
||||
const trueState = use(selector);
|
||||
|
||||
useEffect(() => {
|
||||
setState(trueState);
|
||||
}, [trueState]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function useMutation() {
|
||||
const counterStoreContext = useStore();
|
||||
|
||||
@@ -61,5 +82,5 @@ export function createNextSafeStore<Store extends StoreApi<unknown>, InitData>(
|
||||
};
|
||||
}
|
||||
|
||||
return { Provider, use, useMutation, useStore };
|
||||
return { Provider, use, useMutation, useStore, useDeferred };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Thanks GuCier!
|
||||
*
|
||||
* https://stackoverflow.com/a/65008608/12294756
|
||||
*/
|
||||
export default function useOnScreen(ref: RefObject<HTMLElement>) {
|
||||
const [isIntersecting, setIntersecting] = useState(false);
|
||||
const observer = useRef<IntersectionObserver>();
|
||||
|
||||
useEffect(() => {
|
||||
observer.current = new IntersectionObserver(([entry]) =>
|
||||
setIntersecting(entry.isIntersecting),
|
||||
);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
observer.current?.observe(ref.current);
|
||||
return () => observer.current?.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return isIntersecting;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export function Blitzkit(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
width="426"
|
||||
height="426"
|
||||
viewBox="0 0 426 426"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6H426V229C426 234.79 422.876 240.58 416.628 244.997Z"
|
||||
fill="url(#paint0_linear_513_245)"
|
||||
/>
|
||||
<path
|
||||
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6H426V229C426 234.79 422.876 240.58 416.628 244.997Z"
|
||||
fill="url(#paint1_linear_513_245)"
|
||||
/>
|
||||
<path
|
||||
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z"
|
||||
fill="#D9D9D9"
|
||||
/>
|
||||
<path
|
||||
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z"
|
||||
fill="url(#paint2_linear_513_245)"
|
||||
/>
|
||||
<path
|
||||
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z"
|
||||
fill="url(#paint3_linear_513_245)"
|
||||
/>
|
||||
<g filter="url(#filter0_d_513_245)">
|
||||
<path
|
||||
d="M218.056 168.108L311.596 231.945L331.832 217.599L310.153 202.23L309.86 195.31L356.236 200.298L380.102 183.378L309.049 176.111L307.355 136L284.968 151.871L286.867 185.722L238.292 153.762L218.056 168.108Z"
|
||||
fill="url(#paint4_linear_513_245)"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M213.905 307L91 219.714L137.393 186.767C150.906 177.17 164.124 171.539 177.048 169.875C189.971 168.211 201.919 171.275 212.893 179.069C219.702 183.905 223.718 189.006 224.941 194.372C226.165 199.658 225.302 204.506 222.354 208.918L222.692 209.158C229.978 206.301 237.935 205.286 246.563 206.113C255.247 206.9 263.613 210.151 271.66 215.866C282.803 223.779 287.398 232.825 285.446 243.004C283.551 253.142 275.96 262.929 262.672 272.366L213.905 307ZM242.999 260.08L221.456 275.38L184.905 249.422L206.448 234.123C213.007 229.465 219.473 227.23 225.846 227.42C232.22 227.529 238.193 229.562 243.764 233.519C249.392 237.516 252.31 241.797 252.521 246.364C252.732 250.85 249.558 255.422 242.999 260.08ZM186.937 223.64L167.77 237.252L135.693 214.472L154.861 200.859C161.363 196.241 167.435 193.887 173.076 193.798C178.717 193.708 184.07 195.462 189.135 199.059C194.256 202.696 196.809 206.557 196.796 210.643C196.726 214.69 193.44 219.022 186.937 223.64Z"
|
||||
fill="url(#paint5_linear_513_245)"
|
||||
/>
|
||||
<path
|
||||
d="M184.681 109.963C181.602 107.766 180.042 105.326 180 102.641C179.973 99.947 181.629 97.409 184.968 95.0273C188.143 92.7627 191.804 91.6061 195.95 91.5574C200.097 91.5088 204.484 93.1343 209.11 96.4339L209.91 97.005C214.92 100.578 217.431 104.507 217.445 108.793C217.459 113.059 214.606 117.3 208.886 121.517L208.208 122L203.24 118.456L203.856 118.017C207.537 115.391 209.494 112.697 209.727 109.934C209.945 107.162 207.961 104.282 203.773 101.295L202.541 100.826C199.557 98.6983 197.046 97.4926 195.007 97.2094C192.967 96.9067 191.181 97.302 189.649 98.3953C188.212 99.4202 187.623 100.504 187.883 101.646C188.157 102.778 189.218 104.003 191.066 105.321C192.804 106.561 194.494 107.279 196.136 107.474C197.779 107.669 199.325 107.25 200.776 106.215C202.158 105.229 202.815 104.077 202.746 102.759C202.664 101.432 201.829 100.202 200.241 99.0693L201.986 97.8247C204.614 99.6991 206.229 101.808 206.832 104.151C207.434 106.493 206.38 108.631 203.671 110.564C200.852 112.575 197.731 113.512 194.31 113.375C190.888 113.238 187.679 112.101 184.681 109.963Z"
|
||||
fill="url(#paint6_linear_513_245)"
|
||||
/>
|
||||
<path
|
||||
d="M232.267 104.781L218.47 79.4488L203.957 89.8006L198.721 86.0665L219.844 71L224.094 74.0312L239 99.9786L232.267 104.781Z"
|
||||
fill="url(#paint7_linear_513_245)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_513_245"
|
||||
x="72.6"
|
||||
y="60.6"
|
||||
width="325.902"
|
||||
height="272.8"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="8" />
|
||||
<feGaussianBlur stdDeviation="9.2" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_513_245"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_513_245"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_513_245"
|
||||
x1="127.008"
|
||||
y1="227.288"
|
||||
x2="174.926"
|
||||
y2="391.583"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#0F3F7D" />
|
||||
<stop offset="1" stop-color="#10284C" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_513_245"
|
||||
x1="213"
|
||||
y1="196.6"
|
||||
x2="213"
|
||||
y2="379.6"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#10243E" />
|
||||
<stop offset="1" stop-color="#0F1B2D" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_513_245"
|
||||
x1="213"
|
||||
y1="46"
|
||||
x2="213"
|
||||
y2="347.2"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#10243E" />
|
||||
<stop offset="1" stop-color="#0F1B2D" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_513_245"
|
||||
x1="127.008"
|
||||
y1="96.51"
|
||||
x2="241.48"
|
||||
y2="334.974"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#0F3F7D" />
|
||||
<stop offset="1" stop-color="#10284C" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_513_245"
|
||||
x1="339.524"
|
||||
y1="58.5884"
|
||||
x2="423.41"
|
||||
y2="176.706"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.34" stop-color="#EAF6FF" />
|
||||
<stop offset="0.64" stop-color="#97CBFF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_513_245"
|
||||
x1="339.524"
|
||||
y1="58.5884"
|
||||
x2="423.41"
|
||||
y2="176.706"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.34" stop-color="#EAF6FF" />
|
||||
<stop offset="0.64" stop-color="#97CBFF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint6_linear_513_245"
|
||||
x1="197.441"
|
||||
y1="87.3302"
|
||||
x2="217.462"
|
||||
y2="115.4"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.34" stop-color="#EAF6FF" />
|
||||
<stop offset="0.64" stop-color="#97CBFF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_513_245"
|
||||
x1="197.441"
|
||||
y1="87.3302"
|
||||
x2="217.462"
|
||||
y2="115.4"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.34" stop-color="#EAF6FF" />
|
||||
<stop offset="0.64" stop-color="#97CBFF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export function BlitzkitButtonGray(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
width="426"
|
||||
height="426"
|
||||
viewBox="0 0 426 426"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g clip-path="url(#clip0_600_256)">
|
||||
<path
|
||||
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6L213 77.5L426 196.6V229C426 234.79 422.876 240.58 416.628 244.997Z"
|
||||
fill="#9E9E9E"
|
||||
/>
|
||||
<path
|
||||
d="M200.456 257.38L221.999 242.08C228.557 237.422 231.732 232.85 231.521 228.364C231.31 223.797 228.392 219.516 222.764 215.519C217.193 211.562 211.22 209.529 204.846 209.42C198.473 209.23 192.007 211.465 185.448 216.123L163.905 231.422L200.456 257.38Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M146.77 219.252L165.937 205.64C172.44 201.022 175.726 196.69 175.796 192.643C175.809 188.557 173.256 184.696 168.135 181.059C163.07 177.462 157.717 175.708 152.076 175.798C146.435 175.887 140.363 178.241 133.861 182.859L114.693 196.472L146.77 219.252Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M235.625 52.6262L416.628 180.603C429.124 189.438 429.124 203.762 416.628 212.597L235.625 340.574C223.13 349.409 202.87 349.409 190.375 340.574L9.37173 212.597C-3.12391 203.762 -3.12391 189.438 9.37172 180.603L190.375 52.6262C202.87 43.7913 223.13 43.7913 235.625 52.6262ZM290.596 213.945L197.056 150.108L217.292 135.762L265.867 167.722L263.968 133.871L286.355 118L288.049 158.111L359.102 165.378L335.236 182.298L288.86 177.31L289.153 184.23L310.832 199.599L290.596 213.945ZM70 201.714L192.905 289L241.672 254.366C254.96 244.929 262.551 235.142 264.446 225.004C266.398 214.825 261.803 205.779 250.66 197.866C242.613 192.151 234.247 188.9 225.563 188.113C216.935 187.286 208.978 188.301 201.692 191.158L201.354 190.918C204.302 186.506 205.165 181.658 203.941 176.372C202.718 171.006 198.702 165.905 191.893 161.069C180.919 153.275 168.971 150.211 156.048 151.875C143.124 153.539 129.906 159.17 116.393 168.767L70 201.714Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_600_256">
|
||||
<rect width="426" height="426" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export function BlitzkitButtonWatermark(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
width="426"
|
||||
height="426"
|
||||
viewBox="0 0 426 426"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g opacity="0.1" clipPath="url(#clip0_513_262)">
|
||||
<path
|
||||
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6L213 54.5L426 196.6V229C426 234.79 422.876 240.58 416.628 244.997Z"
|
||||
fill="#8D8D8D"
|
||||
/>
|
||||
<path
|
||||
d="M195.007 94.2094C196.778 94.4555 198.907 95.398 201.392 97.0371C202.235 97.8936 202.687 98.8011 202.746 99.7593C202.815 101.077 202.158 102.229 200.776 103.215C199.325 104.25 197.779 104.669 196.136 104.474C194.494 104.279 192.804 103.561 191.066 102.321C189.218 101.003 188.157 99.7783 187.883 98.6459C187.623 97.5038 188.212 96.4202 189.649 95.3953C191.181 94.302 192.967 93.9067 195.007 94.2094Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M221.456 275.38L242.999 260.08C249.557 255.422 252.732 250.85 252.521 246.364C252.31 241.797 249.392 237.516 243.764 233.519C238.193 229.562 232.22 227.529 225.846 227.42C219.473 227.23 213.007 229.465 206.448 234.123L184.905 249.422L221.456 275.38Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M167.77 237.252L186.937 223.64C193.44 219.022 196.726 214.69 196.796 210.643C196.809 206.557 194.256 202.696 189.135 199.059C184.07 195.462 178.717 193.708 173.076 193.798C167.435 193.887 161.363 196.241 154.861 200.859L135.693 214.472L167.77 237.252Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37172 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603ZM218.47 76.4488L232.267 101.781L239 96.9786L224.094 71.0312L219.844 68L198.721 83.0665L203.957 86.8006L218.47 76.4488ZM184.681 106.963C181.602 104.766 180.042 102.326 180 99.6413C179.973 96.947 181.629 94.409 184.968 92.0273C188.143 89.7627 191.804 88.6061 195.95 88.5574C200.097 88.5088 204.484 90.1343 209.11 93.4339L209.91 94.005C214.92 97.578 217.431 101.507 217.445 105.793C217.459 110.059 214.606 114.3 208.886 118.517L208.208 119L203.24 115.456L203.856 115.017C207.537 112.391 209.494 109.697 209.727 106.934C209.886 104.918 208.879 102.845 206.707 100.715C206.753 100.859 206.794 101.004 206.832 101.151C207.434 103.493 206.38 105.631 203.671 107.564C200.852 109.575 197.731 110.512 194.31 110.375C190.888 110.238 187.679 109.101 184.681 106.963ZM311.596 231.945L218.056 168.108L238.292 153.762L286.867 185.722L284.968 151.871L307.355 136L309.049 176.111L380.102 183.378L356.236 200.298L309.86 195.31L310.153 202.23L331.832 217.599L311.596 231.945ZM91 219.714L213.905 307L262.672 272.366C275.96 262.929 283.551 253.142 285.446 243.003C287.398 232.825 282.803 223.779 271.66 215.866C263.613 210.151 255.247 206.9 246.563 206.113C237.935 205.286 229.978 206.301 222.692 209.158L222.354 208.918C225.302 204.506 226.165 199.658 224.941 194.372C223.718 189.006 219.702 183.905 212.893 179.069C201.919 171.275 189.971 168.211 177.048 169.875C164.124 171.539 150.906 177.17 137.393 186.767L91 219.714Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_513_262">
|
||||
<rect width="426" height="426" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
export function BlitzkitWide(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
width="62"
|
||||
height="15"
|
||||
viewBox="0 0 62 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M0.204102 14V1.20312H5.45996C6.9541 1.20312 8.11719 1.48145 8.94922 2.03809C9.78125 2.59473 10.1973 3.43848 10.1973 4.56934C10.1973 5.27246 10.0449 5.85547 9.74023 6.31836C9.43555 6.78125 9.06055 7.0918 8.61523 7.25V7.28516C9.16602 7.44336 9.62891 7.77734 10.0039 8.28711C10.3848 8.79688 10.5752 9.47656 10.5752 10.3262C10.5752 11.4863 10.127 12.3887 9.23047 13.0332C8.33398 13.6777 7.15625 14 5.69727 14H0.204102ZM3.64062 11.6973H5.48633C6.03711 11.6973 6.44141 11.5508 6.69922 11.2578C6.96289 10.9648 7.09473 10.5664 7.09473 10.0625C7.09473 9.57031 6.96289 9.17773 6.69922 8.88477C6.44141 8.5918 6.03711 8.44531 5.48633 8.44531H3.64062V11.6973ZM3.64062 6.35352H5.2666C5.80566 6.35352 6.19238 6.23047 6.42676 5.98438C6.66699 5.73828 6.78711 5.39258 6.78711 4.94727C6.78711 4.50195 6.66699 4.15625 6.42676 3.91016C6.19238 3.66406 5.80566 3.54102 5.2666 3.54102H3.64062V6.35352ZM12.1924 14V0.631836H15.5674V14H12.1924ZM17.5449 14V4.82422H20.9375V14H17.5449ZM17.3164 2.37207C17.3164 1.88574 17.4834 1.47852 17.8174 1.15039C18.1514 0.816406 18.626 0.649414 19.2412 0.649414C19.8623 0.649414 20.3369 0.816406 20.665 1.15039C20.999 1.47852 21.166 1.88574 21.166 2.37207C21.166 2.86426 20.999 3.27734 20.665 3.61133C20.3311 3.94531 19.8535 4.1123 19.2324 4.1123C18.6172 4.1123 18.1426 3.94531 17.8086 3.61133C17.4805 3.27734 17.3164 2.86426 17.3164 2.37207ZM21.9395 7.22363V4.82422H28.0215V7.22363H21.9395ZM23.126 10.9502V5.89648L23.1523 5.65918V2.67969H26.4746V10.7568C26.4746 11.1904 26.5479 11.4834 26.6943 11.6357C26.8467 11.7822 27.04 11.8555 27.2744 11.8555C27.3916 11.8555 27.5117 11.8408 27.6348 11.8115C27.7578 11.7822 27.8691 11.7441 27.9688 11.6973V13.9736C27.8398 14.0439 27.6406 14.1055 27.3711 14.1582C27.1016 14.2168 26.7998 14.2461 26.4658 14.2461C25.458 14.2461 24.6494 13.9824 24.04 13.4551C23.4307 12.9219 23.126 12.0869 23.126 10.9502ZM28.8125 14V11.9609L32.4688 7.29395H28.8916V4.82422H36.9424V6.81934L33.2334 11.5566H37.2324V14H28.8125ZM38.8057 14V1.20312H42.2773V5.72949L46.1094 1.20312H49.7305L42.2773 9.68457V14H38.8057ZM46.373 14L42.2861 8.17285L44.4834 5.65918L50.5391 14H46.373ZM50.9961 14V4.82422H54.3887V14H50.9961ZM50.7676 2.37207C50.7676 1.88574 50.9346 1.47852 51.2686 1.15039C51.6025 0.816406 52.0771 0.649414 52.6924 0.649414C53.3135 0.649414 53.7881 0.816406 54.1162 1.15039C54.4502 1.47852 54.6172 1.88574 54.6172 2.37207C54.6172 2.86426 54.4502 3.27734 54.1162 3.61133C53.7822 3.94531 53.3047 4.1123 52.6836 4.1123C52.0684 4.1123 51.5938 3.94531 51.2598 3.61133C50.9316 3.27734 50.7676 2.86426 50.7676 2.37207ZM55.3906 7.22363V4.82422H61.4727V7.22363H55.3906ZM56.5771 10.9502V5.89648L56.6035 5.65918V2.67969H59.9258V10.7568C59.9258 11.1904 59.999 11.4834 60.1455 11.6357C60.2979 11.7822 60.4912 11.8555 60.7256 11.8555C60.8428 11.8555 60.9629 11.8408 61.0859 11.8115C61.209 11.7822 61.3203 11.7441 61.4199 11.6973V13.9736C61.291 14.0439 61.0918 14.1055 60.8223 14.1582C60.5527 14.2168 60.251 14.2461 59.917 14.2461C58.9092 14.2461 58.1006 13.9824 57.4912 13.4551C56.8818 12.9219 56.5771 12.0869 56.5771 10.9502Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,13 @@
|
||||
"AT-SPG": "TD"
|
||||
},
|
||||
|
||||
"tank_class_medium": {
|
||||
"lightTank": "Light",
|
||||
"mediumTank": "Medium",
|
||||
"heavyTank": "Heavy",
|
||||
"AT-SPG": "Tank destroyer"
|
||||
},
|
||||
|
||||
"tree_type": {
|
||||
"researchable": "Tech tree",
|
||||
"premium": "Premium",
|
||||
|
||||
@@ -26,8 +26,8 @@ export interface App {
|
||||
policiesAgreementIndex: number;
|
||||
}
|
||||
|
||||
export const { Provider, use, useMutation, useStore } = createNextSafeStore(
|
||||
() =>
|
||||
export const { Provider, use, useMutation, useStore, useDeferred } =
|
||||
createNextSafeStore(() =>
|
||||
create<App>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -38,4 +38,4 @@ export const { Provider, use, useMutation, useStore } = createNextSafeStore(
|
||||
{ name: 'app', merge: (a, b) => merge(b, a) },
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Vector3 } from 'three';
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import { XP_MULTIPLIERS } from '../app/tools/tankopedia/[id]/components/Model/TechTreeSection';
|
||||
import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
|
||||
import { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent';
|
||||
import { ModelDefinition } from '../core/blitzkit/modelDefinitions';
|
||||
@@ -28,6 +29,7 @@ interface TankopediaEphemeral {
|
||||
}
|
||||
| { type: ArmorType.External }
|
||||
);
|
||||
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
||||
}
|
||||
|
||||
export interface ShotLayerBase {
|
||||
@@ -97,6 +99,7 @@ export const { Provider, use, useMutation, useStore } = createNextSafeStore(
|
||||
skills: {},
|
||||
model,
|
||||
controlsEnabled: true,
|
||||
xpMultiplier: 1,
|
||||
})),
|
||||
),
|
||||
);
|
||||
Reference in new issue
Block a user