mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-13 04:36:12 +01: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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user