Merge branch 'main' into dev-embed

This commit is contained in:
TrèsAbhi committed 2024-08-26 22:02:10 -05:00
commit e99b125517
120 files changed
+3253 -2798

No files matched your search

+21 -12
View File
@@ -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,
};
}
});
});
}
+4 -3
View File
@@ -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
View File
@@ -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 />}
+1 -1
View File
@@ -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>
);
}
-322
View File
@@ -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" />} */}
</>
);
}
-110
View File
@@ -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>
);
}
@@ -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>
+14 -17
View File
@@ -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>
);
}
+14 -4
View File
@@ -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>
+5 -2
View File
@@ -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>
);
}
-3
View File
@@ -1,13 +1,10 @@
import PageWrapper from '../../../components/PageWrapper';
import * as TankFilters from '../../../stores/tankFilters';
import { TankSearch } from './components/TankSearch';
export default function Page() {
return (
<TankFilters.Provider>
<PageWrapper color="purple" size={1028}>
<TankSearch />
</PageWrapper>
</TankFilters.Provider>
);
}
@@ -22,8 +22,7 @@ export function ArmorPlateDisplay() {
(state) => state.highlightArmor,
);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const developerMode = App.use((state) => state.developerMode);
const devThicknessInput = useRef<HTMLInputElement>(null);
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const duelStore = Duel.useStore();
const input = useRef<HTMLInputElement>(null);
const tankopediaEphemeralStore = TankopediaEphemeral.useStore();
@@ -7,7 +7,6 @@ import { useArmor } from '../../../hooks/useArmor';
import { useTankModelDefinition } from '../../../hooks/useTankModelDefinition';
import { useTankTransform } from '../../../hooks/useTankTransform';
import * as Duel from '../../../stores/duel';
import * as TankopediaPersistent from '../../../stores/tankopediaPersistent';
import { ModelTankWrapper } from './ModelTankWrapper';
import { PrimaryArmorSceneComponent } from './PrimaryArmorSceneComponent';
@@ -29,13 +28,6 @@ export const PrimaryArmorScene = memo(() => {
const hullOrigin = correctZYTuple(trackModelDefinition.origin);
const turretOrigin = correctZYTuple(tankModelDefinition.turretOrigin);
const gunOrigin = correctZYTuple(turretModelDefinition.gunOrigin);
const tankopediaPersistentStore = TankopediaPersistent.useStore();
console.log(
tankModelDefinition.armor,
turretModelDefinition.armor,
gunModelDefinition.armor,
);
useTankTransform(protagonist, turretContainer, gunContainer);
@@ -140,8 +140,8 @@ export function SpacedArmorSceneComponent({
if (
!allowRicochet &&
!noDuplicateIntersections.some((intersection) => {
intersection.object.userData.type === ArmorType.Primary;
noDuplicateIntersections.every((intersection) => {
intersection.object.userData.type !== ArmorType.Primary;
})
) {
return null;
+9 -18
View File
@@ -1,37 +1,31 @@
import { Flex } from '@radix-ui/themes';
import { Flex, FlexProps } from '@radix-ui/themes';
import { ConsumableEntry } from '../core/blitzkit/consumableDefinitions';
import { ConsumableButton } from './ModuleButtons/ConsumableButton';
interface ConsumablesManagerProps {
type ConsumablesManagerProps = FlexProps & {
consumables: ConsumableEntry[];
selected: number[];
onChange?: (consumables: number[]) => void;
onConsumablesChange?: (consumables: number[]) => void;
disabled?: boolean;
timers?: boolean;
hasHighEndConsumables?: boolean;
hasConsumableDeliverySystem?: boolean;
cooldownBooster?: number;
}
};
export function ConsumablesManager({
consumables,
selected,
onChange,
onConsumablesChange,
disabled,
timers,
hasConsumableDeliverySystem,
cooldownBooster,
hasHighEndConsumables,
...props
}: ConsumablesManagerProps) {
return (
<Flex
wrap="wrap"
style={{
maxWidth: 288,
borderRadius: 4,
overflow: 'hidden',
}}
>
<Flex wrap="wrap" gap="2" {...props}>
{consumables.map((consumable, index) => {
const isSelected = selected.some((id) => id === consumable.id);
@@ -53,14 +47,11 @@ export function ConsumablesManager({
: undefined
}
key={consumable.id}
first={index === 0}
last={index === consumables.length - 1}
rowChild
disabled={disabled && !isSelected}
consumable={consumable.id}
selected={isSelected}
onClick={() => {
if (!onChange) return;
if (!onConsumablesChange) return;
const draft = [...selected];
@@ -70,7 +61,7 @@ export function ConsumablesManager({
draft.push(consumable.id);
}
onChange(draft);
onConsumablesChange(draft);
}}
/>
);
-2
View File
@@ -30,8 +30,6 @@ export function CrewSkillManager({
<GenericTankComponentButton
key={skill}
special={!permanentSkills.includes(skill)}
first
last
selected={level > 0}
discriminator={
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
+8 -4
View File
@@ -23,8 +23,10 @@ export function EquipmentManager({
<Flex key={columnIndex}>
<EquipmentButton
equipment={equipment[0]}
first
rowChild
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === -1}
onClick={() => {
const draft = cloneDeep(matrix);
@@ -40,8 +42,10 @@ export function EquipmentManager({
/>
<EquipmentButton
equipment={equipment[1]}
last
rowChild
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
selected={matrix[rowIndex][columnIndex] === 1}
onClick={() => {
const draft = cloneDeep(matrix);
+2 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { forwardRef } from 'react';
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, onClick, ...props }, ref) => {
({ href, onClick, target, ...props }, ref) => {
const router = useRouter();
return (
@@ -15,7 +15,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
onClick={(event) => {
event.preventDefault();
if (href) {
if (event.ctrlKey || event.metaKey) {
if (event.ctrlKey || event.metaKey || target === '_blank') {
window.open(href);
} else {
router.push(href);
@@ -1,6 +1,8 @@
import { ClockIcon, ReloadIcon } from '@radix-ui/react-icons';
import { Flex, Text } from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { consumableDefinitions } from '../../core/blitzkit/consumableDefinitions';
import { useDelta } from '../../hooks/useDelta';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
@@ -17,23 +19,22 @@ export function ConsumableButton({
duration,
...props
}: ConsumableButtonProps) {
const awaitedConsumableDefinitions = use(consumableDefinitions);
const cooldownDelta = useDelta(cooldown ?? 0);
const durationDelta = useDelta(duration ?? 0);
return (
<GenericTankComponentButton
style={{ width: '6rem' }}
tooltip={awaitedConsumableDefinitions[consumable].name}
icon={asset(`icons/consumables/${consumable}.webp`)}
style={{
flex: 1,
minWidth: 'fit-content',
borderRadius: 0,
}}
{...props}
>
{(cooldown || duration) && (
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
<Flex direction="column" gap="1" ml="-2" mr="2">
{duration && (
<Text
size="1"
color={
durationDelta > 0
? 'green'
@@ -50,6 +51,7 @@ export function ConsumableButton({
)}
{cooldown && (
<Text
size="1"
color={
cooldownDelta < 0
? 'green'
@@ -1,4 +1,6 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { equipmentDefinitions } from '../../core/blitzkit/equipmentDefinitions';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
@@ -7,8 +9,11 @@ interface EquipmentButtonProps extends TankComponentButtonProps {
}
export function EquipmentButton({ equipment, ...props }: EquipmentButtonProps) {
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
return (
<GenericTankComponentButton
tooltip={awaitedEquipmentDefinitions.equipments[equipment].name}
icon={asset(`icons/equipment/${equipment}.webp`)}
{...props}
/>
@@ -1,4 +1,6 @@
import { use } from 'react';
import { asset } from '../../core/blitzkit/asset';
import { provisionDefinitions } from '../../core/blitzkit/provisionDefinitions';
import { GenericTankComponentButton } from './GenericTankComponentButton';
import { TankComponentButtonProps } from './TankComponentButton';
@@ -7,8 +9,11 @@ interface ProvisionButtonProps extends TankComponentButtonProps {
}
export function ProvisionButton({ provision, ...props }: ProvisionButtonProps) {
const awaitedProvisionDefinitions = use(provisionDefinitions);
return (
<GenericTankComponentButton
tooltip={awaitedProvisionDefinitions[provision].name}
icon={asset(`icons/provisions/${provision}.webp`)}
{...props}
/>
@@ -1,34 +1,30 @@
import { Button, Text } from '@radix-ui/themes';
import { Button, Text, Tooltip } from '@radix-ui/themes';
import { ComponentProps, ReactNode } from 'react';
export interface TankComponentButtonProps
extends ComponentProps<typeof Button> {
selected?: boolean;
first?: boolean;
last?: boolean;
rowChild?: boolean;
children?: ReactNode;
discriminator?: ReactNode;
disabled?: boolean;
banner?: string;
special?: boolean;
tooltip?: string;
}
export function TankComponentButton({
variant,
selected,
first = false,
last = false,
rowChild,
discriminator,
special,
children,
banner,
disabled,
style,
tooltip,
...props
}: TankComponentButtonProps) {
return (
const button = (
<Button
radius="medium"
color={selected ? (special ? 'amber' : undefined) : 'gray'}
@@ -38,11 +34,6 @@ export function TankComponentButton({
minWidth: 48,
height: 40,
position: 'relative',
borderTopLeftRadius: first ? undefined : 0,
borderTopRightRadius: last ? undefined : 0,
borderBottomLeftRadius: first ? undefined : 0,
borderBottomRightRadius: last ? undefined : 0,
margin: rowChild ? -0.5 : 'unset',
overflow: 'hidden',
cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined,
@@ -84,4 +75,7 @@ export function TankComponentButton({
)}
</Button>
);
if (tooltip) return <Tooltip content={tooltip}>{button}</Tooltip>;
return button;
}
-12
View File
@@ -41,9 +41,6 @@ export function ModuleManager({
<ModuleButton
module="turret"
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
first
last
rowChild
onClick={() => setTurretMenuOpen(true)}
/>
@@ -85,9 +82,6 @@ export function ModuleManager({
<ModuleButton
module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
first
last
rowChild
onClick={() => setGunMenuOpen(true)}
/>
@@ -128,9 +122,6 @@ export function ModuleManager({
<ModuleButton
module="engine"
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
first
last
rowChild
onClick={() => setEngineMenuOpen(true)}
/>
@@ -170,9 +161,6 @@ export function ModuleManager({
<ModuleButton
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
first
last
rowChild
onClick={() => setTrackMenuOpen(true)}
/>
@@ -1,42 +0,0 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Card, TextField } from '@radix-ui/themes';
import { ComponentProps, useState } from 'react';
export function EverythingSearch({
style,
...props
}: ComponentProps<typeof TextField.Root>) {
const [search, setSearch] = useState('');
return (
<>
<TextField.Root
value={search}
placeholder="Search tanks, players, clans..."
style={{ position: 'relative', ...style }}
onChange={(event) => {
setSearch(event.target.value.trim());
}}
{...props}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
{search.length > 0 && (
<TextField.Slot>
<Card
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
left: 0,
width: '100%',
zIndex: 1,
}}
/>
</TextField.Slot>
)}
</TextField.Root>
</>
);
}
+51 -38
View File
@@ -1,56 +1,69 @@
import { style, styleVariants } from '@vanilla-extract/css';
const MIN_EXPANDED_TOOLS = 752;
const MIN_WIDTH = 640 + 128;
const HEIGHT = 48;
export const hamburger = style({
display: 'inline-flex',
export const hamburgerButton = style({
'@media': {
[`screen and (min-width: ${MIN_EXPANDED_TOOLS}px)`]: {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'none',
},
},
});
const navbarBase = style({
position: 'fixed',
export const tools = style({
display: 'none',
'@media': {
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
display: 'flex',
},
},
});
export const navbar = style({
height: HEIGHT,
marginBottom: -1, // 1px overlap with content to bleed color
position: 'sticky',
top: 0,
left: 0,
zIndex: 1,
});
const navbarExpanderBase = style({
transitionDuration: '250ms',
height: '1000px',
width: '100%',
zIndex: 2,
padding: '1rem',
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
transition: 'max-height 0.2s ease-in-out',
overflow: 'hidden',
});
export const navbar = styleVariants({
expanded: [
navbarBase,
{
maxHeight: '100%',
},
],
collapsed: [
navbarBase,
{
maxHeight: 65,
},
],
});
export const toolCard = style({
boxShadow: `inset 0 -200px 128px -128px var(--color-overlay)`,
backdropFilter: 'blur(4rem) brightness(0.75)',
WebkitBackdropFilter: 'blur(4rem) brightness(0.75)',
':hover': {
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
},
});
export const toolTexts = style({
'@media': {
[`screen and (max-width: ${MIN_EXPANDED_TOOLS}px)`]: {
display: 'none',
[`screen and (min-width: ${MIN_WIDTH}px)`]: {
maxHeight: HEIGHT,
},
},
});
export const navbarExpander = styleVariants({
false: [
navbarExpanderBase,
{
maxHeight: HEIGHT,
},
],
true: [
navbarExpanderBase,
{
maxHeight: '100vh',
overscrollBehavior: 'contain',
},
],
});
export const navbarContent = style({
width: '100%',
maxHeight: '100vh',
});
+281 -268
View File
@@ -12,302 +12,315 @@ import {
Button,
Dialog,
Flex,
Grid,
IconButton,
Inset,
Popover,
ScrollArea,
Separator,
Text,
} from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { Fragment, useState } from 'react';
import { REGIONS } from '../../constants/regions';
import { TOOLS } from '../../constants/tools';
import { homeTool, TOOLS } from '../../constants/tools';
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
import { imgur, ImgurSize } from '../../core/blitzkit/imgur';
import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl';
import { PatreonIcon } from '../../icons/Patreon';
import { WargamingIcon } from '../../icons/Wargaming';
import strings from '../../lang/en-US.json';
import { theme } from '../../stitches.config';
import * as App from '../../stores/app';
import { Link } from '../Link';
import * as styles from './index.css';
export const NAVBAR_HEIGHT = 64;
export default function Navbar() {
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname();
const logins = App.use((state) => state.logins);
const filteredTools = TOOLS.filter((tool) => !tool.href);
const mutateApp = App.useMutation();
const tools = [homeTool, ...TOOLS];
return (
<Box
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']}
style={{ zIndex: 3 }}
>
<Flex direction="column" align="center">
<Flex
position="absolute"
top="34px"
justify="between"
align="center"
style={{
maxWidth: 1024,
width: '100%',
padding: '0 16px',
transform: 'translateY(-50%)',
}}
gap="8"
>
<Flex align="center" gap="4">
<IconButton
className={styles.hamburgerButton}
size="4"
variant="ghost"
color="gray"
onClick={() => setShowHamburgerMenu((state) => !state)}
>
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<Link
onClick={() => setShowHamburgerMenu(false)}
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<b>BlitzKit</b>
<Text color="gray" size="2" trim="both">
.app
</Text>
</Link>
</Flex>
<Flex justify="center" gap="4" className={styles.toolTexts}>
{filteredTools.map((tool) => {
const selected = pathname.startsWith(`/tools/${tool.id}`);
return (
<Link
key={tool.id}
color="gray"
size="2"
highContrast={selected}
underline={selected ? 'always' : 'hover'}
href={`/tools/${tool.id}`}
>
{tool.title}
</Link>
);
})}
</Flex>
<Flex align="center" gap="4">
<Link
onClick={() => setShowHamburgerMenu(false)}
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DiscordLogoIcon width={16} height={16} />
</Link>
<Link
onClick={() => setShowHamburgerMenu(false)}
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link>
<Link
href="/settings"
onClick={() => setShowHamburgerMenu(false)}
color="gray"
highContrast
>
<Flex style={{ width: '100%', height: '100%' }} justify="center">
<GearIcon />
</Flex>
</Link>
<Popover.Root>
<Popover.Trigger>
<PersonIcon style={{ cursor: 'pointer' }} />
</Popover.Trigger>
<Popover.Content
align="end"
width="320px"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{(logins.patreon || logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Logged in accounts
</Text>
{logins.patreon && (
<Flex align="center" gap="2" justify="center">
<PatreonIcon width={15} height={15} />
<Text>Patreon</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.patreon = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
{logins.wargaming && (
<Flex align="center" gap="2" justify="center">
<WargamingIcon width={15} height={15} />
<Text>Wargaming</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.wargaming = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
</Flex>
)}
{!logins.patreon !== !logins.wargaming && (
<Inset side="x">
<Separator my="4" size="4" />
</Inset>
)}
{(!logins.patreon || !logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Log in with...
</Text>
{!logins.patreon && (
<Link style={{ width: '100%' }} href={patreonLoginUrl()}>
<Button style={{ width: '100%' }} color="tomato">
<PatreonIcon width={15} height={15} /> Patreon
</Button>
</Link>
)}
{!logins.wargaming && (
<Dialog.Root>
<Dialog.Trigger>
<Button color="red">
<WargamingIcon width={15} height={15} /> Wargaming
</Button>
</Dialog.Trigger>
<Dialog.Content width="fit-content">
<Flex direction="column" gap="4" align="center">
<Text color="gray">Choose your region</Text>
<Flex gap="2" wrap="wrap">
{REGIONS.map((region) => (
<Dialog.Close key={region}>
<Link
href={
typeof window !== 'undefined'
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
)}`
: undefined
}
>
<Button color="red">
{strings.common.regions.normal[region]}
</Button>
</Link>
</Dialog.Close>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
)}
</Popover.Content>
</Popover.Root>
</Flex>
</Flex>
<Flex mt="9" gap="3" justify="center" wrap="wrap">
{filteredTools.map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.id}`}
className={styles.toolCard}
style={{
backgroundImage: `url(${imgur(tool.image, {
format: 'jpeg',
size: ImgurSize.MediumThumbnail,
})})`,
cursor: tool.disabled ? 'default' : 'pointer',
opacity: tool.disabled ? 0.25 : 1,
height: 64,
minWidth: 176,
flex: 1,
borderRadius: 8,
textDecoration: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: 8,
transition: `box-shadow ${theme.durations.regular}`,
}}
onClick={() => setShowHamburgerMenu(false)}
>
<Text
style={{
color: theme.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
<Flex className={styles.navbar}>
<Flex
className={styles.navbarExpander[`${showHamburgerMenu}`]}
onClick={() => setShowHamburgerMenu(false)}
direction="column"
gap="2"
>
<Flex justify="center">
<Flex flexGrow="1" maxWidth="1024px" p="3" align="center">
<Flex gap="3" align="center">
<IconButton
variant="ghost"
color="gray"
className={styles.hamburger}
onClick={(event) => {
event.stopPropagation();
setShowHamburgerMenu((state) => !state);
}}
>
{tool.title}
</Text>
</Link>
))}
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<Link
color="gray"
highContrast
href="/"
underline="hover"
weight="bold"
onClick={() => setShowHamburgerMenu(false)}
>
BlitzKit
</Link>
</Flex>
<Box flexGrow="1" />
<Flex
align="center"
gap="2"
justify="center"
className={styles.tools}
>
{TOOLS.map((tool, index) => {
const selected = pathname.startsWith(`/tools/${tool.id}`);
return (
<Fragment key={tool.title}>
{index > 0 && <Separator orientation="vertical" size="1" />}
<Link
onClick={() => setShowHamburgerMenu(false)}
color="gray"
highContrast={selected}
size="2"
href={`/tools/${tool.id}`}
underline={selected ? 'always' : 'hover'}
>
{tool.title}
</Link>
</Fragment>
);
})}
</Flex>
<Box flexGrow="1" />
<Flex align="center" gap="3">
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://discord.gg/nDt7AjGJQH"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<DiscordLogoIcon />
</Link>
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://www.patreon.com/tresabhi"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<PatreonIcon width="0.75em" height="0.75em" />
</Link>
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="/settings"
underline="none"
target="_blank"
onClick={() => setShowHamburgerMenu(false)}
>
<GearIcon />
</Link>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<PersonIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content
align="end"
width="320px"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{(logins.patreon || logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Logged in accounts
</Text>
{logins.patreon && (
<Flex align="center" gap="2" justify="center">
<PatreonIcon width={15} height={15} />
<Text>Patreon</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.patreon = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
{logins.wargaming && (
<Flex align="center" gap="2" justify="center">
<WargamingIcon width={15} height={15} />
<Text>Wargaming</Text>
<Button
color="red"
variant="ghost"
onClick={() =>
mutateApp((draft) => {
draft.logins.wargaming = undefined;
})
}
>
Logout
</Button>
</Flex>
)}
</Flex>
)}
{!logins.patreon !== !logins.wargaming && (
<Inset side="x">
<Separator my="4" size="4" />
</Inset>
)}
{(!logins.patreon || !logins.wargaming) && (
<Flex direction="column" gap="2">
<Text align="center" color="gray">
Log in with...
</Text>
{!logins.patreon && (
<Link
style={{ width: '100%' }}
href={patreonLoginUrl()}
>
<Button style={{ width: '100%' }} color="tomato">
<PatreonIcon width={15} height={15} /> Patreon
</Button>
</Link>
)}
{!logins.wargaming && (
<Dialog.Root>
<Dialog.Trigger>
<Button color="red">
<WargamingIcon width={15} height={15} /> Wargaming
</Button>
</Dialog.Trigger>
<Dialog.Content width="fit-content">
<Flex direction="column" gap="4" align="center">
<Text color="gray">Choose your region</Text>
<Flex gap="2" wrap="wrap">
{REGIONS.map((region) => (
<Dialog.Close key={region}>
<Link
href={
typeof window !== 'undefined'
? `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
`${location.origin}/auth/wargaming?return=${location.origin}${location.pathname}`,
)}`
: undefined
}
>
<Button color="red">
{strings.common.regions.normal[region]}
</Button>
</Link>
</Dialog.Close>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
)}
</Popover.Content>
</Popover.Root>
</Flex>
</Flex>
</Flex>
<ScrollArea>
<Grid
columns="repeat(auto-fill, minmax(192px, 1fr))"
flow="dense"
gap="2"
width="100%"
p="3"
pt="0"
>
{tools.map((tool) => (
<Flex
key={tool.id}
onClick={() => setShowHamburgerMenu(false)}
style={{
position: 'relative',
borderRadius: 'var(--radius-2)',
overflow: 'hidden',
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size: ImgurSize.MediumThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
aspectRatio: '2 / 1',
}}
>
<Link
href={tool.id.length === 0 ? '/' : `/tools/${tool.id}`}
style={{
display: 'flex',
width: '100%',
height: '100%',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
}}
>
<Box
flexGrow="1"
style={{
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size: ImgurSize.MediumThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
p="2"
align="center"
width="100%"
style={{
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
}}
>
<Text weight="medium">{tool.title}</Text>
</Flex>
</Link>
</Flex>
))}
</Grid>
</ScrollArea>
</Flex>
</Box>
</Flex>
);
}
+1 -4
View File
@@ -15,16 +15,13 @@ export function ProvisionsManager({
disabled,
}: ProvisionsManagerProps) {
return (
<Flex wrap="wrap">
<Flex wrap="wrap" gap="2">
{provisions.map((provision, index) => {
const isSelected = selected.includes(provision);
return (
<ProvisionButton
key={provision}
first={index === 0}
last={index === provisions.length - 1}
rowChild
disabled={disabled && !isSelected}
provision={provision}
selected={isSelected}
+2
View File
@@ -95,6 +95,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
type={spaced ? ArmorType.Spaced : ArmorType.Primary}
thickness={thickness}
node={node}
onPointerDown={(event) => event.stopPropagation()}
/>
);
})}
@@ -116,6 +117,7 @@ export const StaticArmor = memo<ArmorSceneProps>(({ thicknessRange }) => {
thickness={trackModelDefinition.thickness}
variant="track"
node={node}
onPointerDown={(event) => event.stopPropagation()}
/>
);
})}
+16
View File
@@ -0,0 +1,16 @@
import { ComponentProps } from 'react';
export function VerifiedIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="m344-60-76-128-144-32 14-148-98-112 98-112-14-148 144-32 76-128 136 58 136-58 76 128 144 32-14 148 98 112-98 112 14 148-144 32-76 128-136-58-136 58Zm34-102 102-44 104 44 56-96 110-26-10-112 74-84-74-86 10-112-110-24-58-96-102 44-104-44-56 96-110 24 10 112-74 86 74 84-10 114 110 24 58 96Zm102-318Zm-42 142 226-226-56-58-170 170-86-84-56 56 142 142Z" />
</svg>
);
}
+7 -15
View File
@@ -90,7 +90,7 @@ export const moreTool: Tool = {
image: '1nPm6VI',
button: {
text: 'Join Discord',
color: 'indigo',
color: 'plum',
},
};
@@ -119,27 +119,19 @@ export const tankPerformanceTool: Tool = {
image: 'vOKFB03',
};
export const embedTool: Tool = {
id: 'embed',
title: 'Embed',
description: 'Embeds for streaming',
pageDescription:
'Create embeds for streaming World of Tanks Blitz with extreme customization',
button: {
text: 'Create',
color: 'red',
},
image: 'Q0faRYg',
export const homeTool: Tool = {
id: '',
title: 'Home',
button: { color: 'blue', text: '' },
description: '',
image: 'rUPie9G',
};
export const TOOLS: Tool[] = [
tankopediaTool,
compareTool,
tankPerformanceTool,
playerStatsTool,
sessionTool,
embedTool,
ratingTool,
discordTool,
moreTool,
];
+5
View File
@@ -0,0 +1,5 @@
export const youtubers = [
{ id: 'UC9lE09jxmMgMoG13WrNHPmA', name: 'World of Tanks Blitz' },
{ id: 'UCrQ-Dy8lVsm11u8pCJ8W7Tw', name: 'HisRoyalFatness' },
{ id: 'UCxRpZqMElssTdPYDK3x7wyg', name: 'N90' },
];
+3 -2
View File
@@ -2,7 +2,8 @@ import { Locale } from 'discord.js';
import { go } from 'fuzzysort';
import markdownEscape from 'markdown-escape';
import { UserError } from '../../hooks/userError';
import { tankDefinitions, tankNames } from '../blitzkit/tankDefinitions';
import { tankDefinitions } from '../blitzkit/tankDefinitions';
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
import { tankNamesTechTreeOnly } from '../discord/autocompleteTanks';
import { translator } from '../localization/translator';
@@ -19,7 +20,7 @@ export default async function resolveTankId(
`${tank}`,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{
keys: ['combined'],
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 1,
},
);
@@ -52,7 +52,7 @@ export const averageDefinitions = fetch(asset('averages/manifest.json'), {
.then((response) => response.arrayBuffer())
.then((buffer) =>
decode<AverageDefinitions>(
'blitzkit.AverageDefinitions',
'blitzkit/blitzkit.AverageDefinitions',
new Uint8Array(buffer),
),
);
@@ -0,0 +1,16 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export type CamouflageDefinitions = Record<
number,
{
id: number;
name: string;
tankName?: string;
tankNameFull?: string;
}
>;
export const camouflageDefinitions = fetchCdonLz4<CamouflageDefinitions>(
asset('definitions/camouflages.cdon.lz4'),
);
@@ -7,41 +7,50 @@ export function checkConsumableProvisionInclusivity(
tank: TankDefinition,
gun: GunDefinition,
) {
const included = consumableProvision.include.every((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
const included =
!consumableProvision.gameMode &&
consumableProvision.include?.every((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
throw new SyntaxError('Category filtering found in include rule');
}
});
const excluded = consumableProvision.exclude?.some((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'category':
throw new SyntaxError('Category filtering found in include rule');
}
});
const excluded =
consumableProvision.gameMode ||
consumableProvision.exclude?.some((rule) => {
switch (rule.type) {
case 'tier':
return rule.min <= tank.tier && tank.tier <= rule.max;
case 'ids':
return rule.ids.includes(tank.id);
case 'ids':
return rule.ids.includes(tank.id);
case 'nation':
return rule.nations.includes(tank.nation);
case 'nation':
return rule.nations.includes(tank.nation);
case 'category':
return rule.categories.some((category) => {
switch (category) {
case 'clip':
return gun.type !== 'regular';
}
});
}
});
case 'category':
return rule.categories.some((category) => {
switch (category) {
case 'clip':
return gun.type !== 'regular';
}
});
}
});
return consumableProvision.include.length > 0 && included && !excluded;
return (
!consumableProvision.gameMode &&
consumableProvision.include?.length > 0 &&
included &&
!excluded
);
}
+11 -4
View File
@@ -22,14 +22,21 @@ export type TankFilterDefinition =
nations: string[];
};
export interface ConsumableEntry {
export type ConsumableEntry = {
id: number;
name: string;
cooldown: number;
duration?: number;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
} & (
| {
gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ConsumableDefinitions {
[key: string]: ConsumableEntry;
+12
View File
@@ -1,9 +1,21 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
interface GameMode {
name: string;
}
export interface GameDefinitions {
version: string;
nations: string[];
gameModes: Record<number, GameMode>;
roles: Record<
number,
{
provisions: number[];
consumables: number[];
}
>;
}
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
+2
View File
@@ -1,3 +1,4 @@
import { invalidate } from '@react-three/fiber';
import { EventManager } from './eventManager';
export interface ModelTransformEventData {
@@ -6,3 +7,4 @@ export interface ModelTransformEventData {
}
export const modelTransformEvent = new EventManager<ModelTransformEventData>();
modelTransformEvent.on(() => invalidate());
+11 -4
View File
@@ -2,13 +2,20 @@ import { asset } from './asset';
import { TankFilterDefinition } from './consumableDefinitions';
import { fetchCdonLz4 } from './fetchCdonLz4';
export interface ProvisionEntry {
export type ProvisionEntry = {
id: number;
name: string;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
crew?: number;
}
} & (
| {
gameMode: false;
include: TankFilterDefinition[];
exclude?: TankFilterDefinition[];
}
| {
gameMode: true;
}
);
export interface ProvisionDefinitions {
[key: string]: ProvisionEntry;
+23 -12
View File
@@ -8,21 +8,32 @@ export function resolveDpm(
intraClipCoefficient = 1,
) {
const alpha = shell.damage.armor * damageCoefficient;
let dps: number;
if (gun.type === 'regular') {
return (alpha / (reloadCoefficient * gun.reload)) * 60;
dps = alpha / (reloadCoefficient * gun.reload);
} else if (gun.type === 'autoLoader') {
return (
((alpha * gun.count) /
(gun.reload + (gun.count - 1) * gun.intraClip * intraClipCoefficient)) *
60
);
dps =
(alpha * gun.count) /
(gun.reload * reloadCoefficient +
(gun.count - 1) * gun.intraClip * intraClipCoefficient);
} else {
return (
((alpha * gun.count) /
(reloadCoefficient * gun.reload.reduce((a, b) => a + b, 0) +
(gun.count - 1) * gun.intraClip * intraClipCoefficient)) *
60
);
const mostOptimalShell = gun.reload.reduce<null | {
index: number;
reload: number;
}>((current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient +
(index > 0 ? gun.intraClip * intraClipCoefficient : 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
}, null)!;
dps = alpha / mostOptimalShell?.reload;
}
return dps * 60;
}
+22
View File
@@ -0,0 +1,22 @@
import { decode } from '../protobuf/decode';
import { asset } from './asset';
export interface Reviews {
reviews: Record<number, Review>;
}
interface Review {
last_updated: number;
videos?: Video[];
}
export interface Video {
id: string;
author: string;
}
export const reviews = fetch(asset('definitions/reviews.pb'))
.then((response) => response.arrayBuffer())
.then((buffer) =>
decode<Reviews>('reviews/blitzkit.Reviews', new Uint8Array(buffer)),
);
+58 -42
View File
@@ -138,8 +138,15 @@ export function tankCharacteristics(
0,
) + (hasImprovedVentilation ? 0.08 : 0);
const commanderMastery = crewMastery + provisionCrewBonus;
const crewMemberMastery = commanderMastery * 1.1;
const loaderMastery =
commanderMastery *
(tank.crew.some(({ type }) => type === 'loader') ? 1.1 : 1.05);
const gunnerMastery =
commanderMastery *
(tank.crew.some(({ type }) => type === 'gunner') ? 1.1 : 1.05);
const driverMastery =
commanderMastery *
(tank.crew.some(({ type }) => type === 'driver') ? 1.1 : 1.05);
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
const damageCoefficient =
coefficient([hasTungsten, 0.15]) *
@@ -150,7 +157,7 @@ export function tankCharacteristics(
);
const reloadCoefficient =
(coefficient([hasGunRammer, -0.05]) *
coefficient([true, degressiveStat(crewMemberMastery)])) /
coefficient([true, degressiveStat(loaderMastery)])) /
coefficient([hasAdrenaline && gun.type === 'regular', 0.17]);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
@@ -173,10 +180,10 @@ export function tankCharacteristics(
coefficient(
[hasEnhancedGunLayingDrive, -0.15],
[hasReticleCalibration, -0.3],
) * coefficient([true, degressiveStat(crewMemberMastery)]);
) * coefficient([true, degressiveStat(gunnerMastery)]);
const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.15], [hasReticleCalibration, -0.3]) *
coefficient([true, degressiveStat(crewMemberMastery)]);
coefficient([true, degressiveStat(gunnerMastery)]);
const dispersionDamagedCoefficient = coefficient([
hasVerticalStabilizer,
-0.12,
@@ -212,13 +219,13 @@ export function tankCharacteristics(
);
const turretTraverseCoefficient =
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
coefficient([true, progressiveStat(crewMemberMastery)]);
coefficient([true, progressiveStat(gunnerMastery)]);
const hullTraverseCoefficient =
coefficient(
[hasImprovedControl, 0.11],
[hasImprovedEnginePowerBoost, 0.01],
[true, crewSkills.virtuoso / 100],
) * coefficient([true, progressiveStat(crewMemberMastery)]);
) * coefficient([true, progressiveStat(driverMastery)]);
const resistanceHardCoefficient = coefficient([hasImprovedSuspension, -0.2]);
const resistanceMediumCoefficient = coefficient([
hasImprovedSuspension,
@@ -248,7 +255,18 @@ export function tankCharacteristics(
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const camouflageCoefficientMoving = coefficient(
const camouflageCoefficientMoving = sum(
[
true,
coefficient([
camouflage,
tank.class === 'AT-SPG'
? 0.04
: tank.class === 'heavyTank'
? 0.03
: 0.02,
]),
],
[
hasCamouflageNet,
tank.class === 'heavyTank'
@@ -259,12 +277,22 @@ export function tankCharacteristics(
? 0.07
: 0.1,
],
[
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
);
const camouflageCoefficientStill = coefficient(
const camouflageCoefficientStill = sum(
[
true,
coefficient(
[
camouflage,
tank.class === 'AT-SPG'
? 0.04
: tank.class === 'heavyTank'
? 0.03
: 0.02,
],
[true, crewSkills.camouflage * (3 / 100)],
),
],
[
hasCamouflageNet,
tank.class === 'heavyTank'
@@ -275,11 +303,6 @@ export function tankCharacteristics(
? 0.14
: 0.2,
],
[
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
[true, crewSkills.camouflage * (3 / 100)],
);
const size = normalizeBoundingBox(
@@ -305,24 +328,27 @@ export function tankCharacteristics(
reloadCoefficient,
intraClipCoefficient,
);
const dpmMaximum =
const intraClip =
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
const mostOptimalShellIndex =
gun.type === 'autoReloader'
? (
gun.count > 2
? gun.reload.at(-1)! < gun.reload.at(-2)!
: gun.reload.at(-1)! + gun.intraClip < gun.reload.at(-2)!
)
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
60
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient)) *
60
? gun.reload.reduce<null | { index: number; reload: number }>(
(current, reloadRaw, index) => {
const reload =
reloadRaw * reloadCoefficient + (index > 0 ? intraClip! : 0);
if (current === null || reload < current.reload) {
return { index, reload };
}
return current;
},
null,
)!.index
: undefined;
const damage = shell.damage.armor * damageCoefficient;
const dpmEffective =
gun.type === 'autoReloader'
? gun.reload.at(-1)! < gun.reload.at(-2)!
? gun.reload[0] > gun.reload[1] + gun.intraClip
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
@@ -343,24 +369,15 @@ export function tankCharacteristics(
damageCoefficient * shell.damage.armor * gun.reload.slice(1).length
: undefined;
const shells = gun.type === 'regular' ? 1 : gun.count;
const mostOptimalShellIndex =
gun.type === 'autoReloader'
? gun.reload.at(-1)! < gun.reload.at(-2)!
? gun.reload.length
: 1
: undefined;
const shellReloads =
gun.type === 'autoReloader'
? gun.reload.map((reload) => reload * reloadCoefficient)
: undefined;
const shellReload =
gun.type === 'autoReloader' ? undefined : gun.reload * reloadCoefficient;
const intraClip =
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
const caliber = shell.caliber;
const penetration =
resolveNearPenetration(shell.penetration) * penetrationCoefficient;
const damage = shell.damage.armor * damageCoefficient;
const clipDamage = gun.type === 'regular' ? undefined : gun.count * damage;
const moduleDamage = shell.damage.module * damageCoefficient;
const explosionRadius = shell.explosionRadius;
@@ -470,7 +487,6 @@ export function tankCharacteristics(
shellNormalization,
shellRicochet,
dpm,
dpmMaximum,
dpmEffective,
shells,
mostOptimalShellIndex,
+6 -20
View File
@@ -1,4 +1,3 @@
import { deburr } from 'lodash';
import { Vector2Tuple } from 'three';
import { TankClass, TreeType } from '../../../components/Tanks';
import { asset } from '../asset';
@@ -41,8 +40,10 @@ export type ModuleDefinition =
| GunDefinition;
export interface TankDefinition {
id: number;
roles: Record<number, number>;
description?: string;
fixedCamouflage?: boolean;
camouflages?: number[];
ancestors?: number[];
successors?: number[];
crew: Crew[];
@@ -59,7 +60,7 @@ export interface TankDefinition {
turrets: TurretDefinition[];
engines: EngineDefinition[];
tracks: TrackDefinition[];
price: TankDefinitionPrice;
price: TankPrice;
xp?: number;
speed: {
forwards: number;
@@ -101,7 +102,7 @@ export interface EngineDefinition {
weight: number;
unlocks?: Unlock[];
}
export type TankDefinitionPrice =
export type TankPrice =
| { type: 'credits'; value: number }
| { type: 'gold'; value: number };
export interface TurretDefinition {
@@ -172,24 +173,9 @@ export const tankDefinitions = fetchCdonLz4<TankDefinitions>(
asset('definitions/tanks.cdon.lz4'),
);
export const tankDefinitionsArray = tankDefinitions.then((tanks) =>
Object.values(tanks).map((tank) => tank),
export const tanksDefinitionsArray = tankDefinitions.then((tanks) =>
Object.values(tanks),
);
export const tankNames = tankDefinitionsArray.then((tanks) =>
Promise.all(
tanks.map(async (tank, index) => {
const { id } = (await tankDefinitionsArray)[index];
return {
id,
original: tank.name,
combined: `${tank.name}${deburr(tank.name)}${tank.nameFull ? `${tank.nameFull}${deburr(tank.nameFull)}` : ''}`,
treeType: tank.treeType,
};
}),
),
);
export type Tier = (typeof TIERS)[number];
export const flags: Record<string, string> = {
@@ -0,0 +1,26 @@
import { deburr } from 'lodash';
import { tanksDefinitionsArray } from '.';
import { camouflageDefinitions } from '../camouflageDefinitions';
export const tankNames = Promise.all([
tanksDefinitionsArray,
camouflageDefinitions,
]).then(([tanks, camouflages]) =>
Promise.all(
tanks.map(async (tank, index) => {
const { id } = (await tanksDefinitionsArray)[index];
return {
id,
name: tank.name,
nameFull: tank.nameFull,
searchableName: tank.nameFull ?? tank.name,
searchableNameDeburr: deburr(tank.nameFull ?? tank.name),
camouflages: tank.camouflages
?.map((id) => camouflages[id].name)
.join(' '),
treeType: tank.treeType,
};
}),
),
);
+7
View File
@@ -0,0 +1,7 @@
type Name =
| `color-${'background' | `panel-${'solid' | 'translucent'}` | 'surface' | 'overlay'}`
| `radius-${1 | 2 | 3 | 4 | 5 | 6 | 'full'}`;
export function Var(name: Name) {
return `var(--${name})`;
}
-32
View File
@@ -1,32 +0,0 @@
import { asset } from './asset';
export interface VideoDefinitions {
[id: number]: {
videos: string[];
lastUpdated: number;
};
}
export const videoDefinitions = fetch(asset('definitions/videos.csv'))
.then((response) => response.text())
.then((text) =>
text
.split('\n')
.map((line) => {
const [idString, lastUpdatedString, ...videos] = line
.split(',')
.filter(Boolean);
const id = Number(idString);
const lastUpdated = Number(lastUpdatedString);
return { id, videos, lastUpdated };
})
.filter(({ videos }) => videos.length > 0)
.reduce<VideoDefinitions>((accumulator, { id, videos, lastUpdated }) => {
accumulator[id] = {
videos,
lastUpdated,
};
return accumulator;
}, {}),
);
+7 -5
View File
@@ -4,7 +4,7 @@ import {
CacheType,
} from 'discord.js';
import { go } from 'fuzzysort';
import { tankNames } from '../blitzkit/tankDefinitions';
import { tankNames } from '../blitzkit/tankDefinitions/tankNames';
import {
DISCORD_CHOICES_MAX_NAME_SIZE,
OVERFLOW_SUFFIX,
@@ -29,18 +29,20 @@ export default async function autocompleteTanks(
focusedOption.value,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{
keys: ['combined'],
keys: ['searchableName', 'searchableNameDeburr', 'camouflages'],
limit: 10,
},
).map(async (item) => {
let name = item.obj.original;
let name = item.obj.searchableName;
if (name.length > DISCORD_CHOICES_MAX_NAME_SIZE) {
const overSize = name.length - DISCORD_CHOICES_MAX_NAME_SIZE;
name = `${item.obj.original.slice(
name = `${item.obj.searchableName.slice(
0,
item.obj.original.length - overSize - OVERFLOW_SUFFIX.length,
item.obj.searchableName.length -
overSize -
OVERFLOW_SUFFIX.length,
)}${OVERFLOW_SUFFIX}`;
}
+4
View File
@@ -2,5 +2,9 @@ import { lookup } from './lookup';
export async function encode(proto: string, data: any) {
const Message = await lookup(proto);
const verification = Message.verify(data);
if (verification !== null) throw new Error(verification);
return Message.encode(Message.create(data)).finish();
}
+4 -4
View File
@@ -2,13 +2,13 @@ import { Root } from 'protobufjs';
import { context } from '../blitzkit/context';
export async function lookup(proto: string) {
const name = proto.split('.')[0];
const [file, message] = proto.split('/');
const root = await new Root().load(
context === 'server'
? `public/protos/${name}.proto`
: `/protos/${name}.proto`,
? `public/protos/${file}.proto`
: `/protos/${file}.proto`,
{ keepCase: true },
);
return root.lookupType(proto);
return root.lookupType(message);
}
+23 -2
View File
@@ -1,5 +1,12 @@
import { Draft, produce } from 'immer';
import { createContext, ReactNode, useContext, useRef } from 'react';
import {
createContext,
ReactNode,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { StoreApi, useStore as useStoreZustand } from 'zustand';
type StoreProviderProps<InitData> = {
@@ -49,6 +56,20 @@ export function createNextSafeStore<Store extends StoreApi<unknown>, InitData>(
);
}
function useDeferred<Slice = Type>(
initial: Slice,
selector: (store: Type) => Slice = (state) => state as Slice,
) {
const [state, setState] = useState(initial);
const trueState = use(selector);
useEffect(() => {
setState(trueState);
}, [trueState]);
return state;
}
function useMutation() {
const counterStoreContext = useStore();
@@ -61,5 +82,5 @@ export function createNextSafeStore<Store extends StoreApi<unknown>, InitData>(
};
}
return { Provider, use, useMutation, useStore };
return { Provider, use, useMutation, useStore, useDeferred };
}
+26
View File
@@ -0,0 +1,26 @@
import { RefObject, useEffect, useRef, useState } from 'react';
/**
* Thanks GuCier!
*
* https://stackoverflow.com/a/65008608/12294756
*/
export default function useOnScreen(ref: RefObject<HTMLElement>) {
const [isIntersecting, setIntersecting] = useState(false);
const observer = useRef<IntersectionObserver>();
useEffect(() => {
observer.current = new IntersectionObserver(([entry]) =>
setIntersecting(entry.isIntersecting),
);
}, [ref]);
useEffect(() => {
if (!ref.current) return;
observer.current?.observe(ref.current);
return () => observer.current?.disconnect();
}, [ref]);
return isIntersecting;
}
-180
View File
@@ -1,180 +0,0 @@
import { ComponentProps } from 'react';
export function Blitzkit(props: ComponentProps<'svg'>) {
return (
<svg
width="426"
height="426"
viewBox="0 0 426 426"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6H426V229C426 234.79 422.876 240.58 416.628 244.997Z"
fill="url(#paint0_linear_513_245)"
/>
<path
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6H426V229C426 234.79 422.876 240.58 416.628 244.997Z"
fill="url(#paint1_linear_513_245)"
/>
<path
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z"
fill="#D9D9D9"
/>
<path
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z"
fill="url(#paint2_linear_513_245)"
/>
<path
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37173 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603Z"
fill="url(#paint3_linear_513_245)"
/>
<g filter="url(#filter0_d_513_245)">
<path
d="M218.056 168.108L311.596 231.945L331.832 217.599L310.153 202.23L309.86 195.31L356.236 200.298L380.102 183.378L309.049 176.111L307.355 136L284.968 151.871L286.867 185.722L238.292 153.762L218.056 168.108Z"
fill="url(#paint4_linear_513_245)"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M213.905 307L91 219.714L137.393 186.767C150.906 177.17 164.124 171.539 177.048 169.875C189.971 168.211 201.919 171.275 212.893 179.069C219.702 183.905 223.718 189.006 224.941 194.372C226.165 199.658 225.302 204.506 222.354 208.918L222.692 209.158C229.978 206.301 237.935 205.286 246.563 206.113C255.247 206.9 263.613 210.151 271.66 215.866C282.803 223.779 287.398 232.825 285.446 243.004C283.551 253.142 275.96 262.929 262.672 272.366L213.905 307ZM242.999 260.08L221.456 275.38L184.905 249.422L206.448 234.123C213.007 229.465 219.473 227.23 225.846 227.42C232.22 227.529 238.193 229.562 243.764 233.519C249.392 237.516 252.31 241.797 252.521 246.364C252.732 250.85 249.558 255.422 242.999 260.08ZM186.937 223.64L167.77 237.252L135.693 214.472L154.861 200.859C161.363 196.241 167.435 193.887 173.076 193.798C178.717 193.708 184.07 195.462 189.135 199.059C194.256 202.696 196.809 206.557 196.796 210.643C196.726 214.69 193.44 219.022 186.937 223.64Z"
fill="url(#paint5_linear_513_245)"
/>
<path
d="M184.681 109.963C181.602 107.766 180.042 105.326 180 102.641C179.973 99.947 181.629 97.409 184.968 95.0273C188.143 92.7627 191.804 91.6061 195.95 91.5574C200.097 91.5088 204.484 93.1343 209.11 96.4339L209.91 97.005C214.92 100.578 217.431 104.507 217.445 108.793C217.459 113.059 214.606 117.3 208.886 121.517L208.208 122L203.24 118.456L203.856 118.017C207.537 115.391 209.494 112.697 209.727 109.934C209.945 107.162 207.961 104.282 203.773 101.295L202.541 100.826C199.557 98.6983 197.046 97.4926 195.007 97.2094C192.967 96.9067 191.181 97.302 189.649 98.3953C188.212 99.4202 187.623 100.504 187.883 101.646C188.157 102.778 189.218 104.003 191.066 105.321C192.804 106.561 194.494 107.279 196.136 107.474C197.779 107.669 199.325 107.25 200.776 106.215C202.158 105.229 202.815 104.077 202.746 102.759C202.664 101.432 201.829 100.202 200.241 99.0693L201.986 97.8247C204.614 99.6991 206.229 101.808 206.832 104.151C207.434 106.493 206.38 108.631 203.671 110.564C200.852 112.575 197.731 113.512 194.31 113.375C190.888 113.238 187.679 112.101 184.681 109.963Z"
fill="url(#paint6_linear_513_245)"
/>
<path
d="M232.267 104.781L218.47 79.4488L203.957 89.8006L198.721 86.0665L219.844 71L224.094 74.0312L239 99.9786L232.267 104.781Z"
fill="url(#paint7_linear_513_245)"
/>
</g>
<defs>
<filter
id="filter0_d_513_245"
x="72.6"
y="60.6"
width="325.902"
height="272.8"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="8" />
<feGaussianBlur stdDeviation="9.2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_513_245"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_513_245"
result="shape"
/>
</filter>
<linearGradient
id="paint0_linear_513_245"
x1="127.008"
y1="227.288"
x2="174.926"
y2="391.583"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0F3F7D" />
<stop offset="1" stop-color="#10284C" />
</linearGradient>
<linearGradient
id="paint1_linear_513_245"
x1="213"
y1="196.6"
x2="213"
y2="379.6"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#10243E" />
<stop offset="1" stop-color="#0F1B2D" />
</linearGradient>
<linearGradient
id="paint2_linear_513_245"
x1="213"
y1="46"
x2="213"
y2="347.2"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#10243E" />
<stop offset="1" stop-color="#0F1B2D" />
</linearGradient>
<linearGradient
id="paint3_linear_513_245"
x1="127.008"
y1="96.51"
x2="241.48"
y2="334.974"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0F3F7D" />
<stop offset="1" stop-color="#10284C" />
</linearGradient>
<linearGradient
id="paint4_linear_513_245"
x1="339.524"
y1="58.5884"
x2="423.41"
y2="176.706"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.34" stop-color="#EAF6FF" />
<stop offset="0.64" stop-color="#97CBFF" />
</linearGradient>
<linearGradient
id="paint5_linear_513_245"
x1="339.524"
y1="58.5884"
x2="423.41"
y2="176.706"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.34" stop-color="#EAF6FF" />
<stop offset="0.64" stop-color="#97CBFF" />
</linearGradient>
<linearGradient
id="paint6_linear_513_245"
x1="197.441"
y1="87.3302"
x2="217.462"
y2="115.4"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.34" stop-color="#EAF6FF" />
<stop offset="0.64" stop-color="#97CBFF" />
</linearGradient>
<linearGradient
id="paint7_linear_513_245"
x1="197.441"
y1="87.3302"
x2="217.462"
y2="115.4"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.34" stop-color="#EAF6FF" />
<stop offset="0.64" stop-color="#97CBFF" />
</linearGradient>
</defs>
</svg>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { ComponentProps } from 'react';
export function BlitzkitButtonGray(props: ComponentProps<'svg'>) {
return (
<svg
width="426"
height="426"
viewBox="0 0 426 426"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clip-path="url(#clip0_600_256)">
<path
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6L213 77.5L426 196.6V229C426 234.79 422.876 240.58 416.628 244.997Z"
fill="#9E9E9E"
/>
<path
d="M200.456 257.38L221.999 242.08C228.557 237.422 231.732 232.85 231.521 228.364C231.31 223.797 228.392 219.516 222.764 215.519C217.193 211.562 211.22 209.529 204.846 209.42C198.473 209.23 192.007 211.465 185.448 216.123L163.905 231.422L200.456 257.38Z"
fill="white"
/>
<path
d="M146.77 219.252L165.937 205.64C172.44 201.022 175.726 196.69 175.796 192.643C175.809 188.557 173.256 184.696 168.135 181.059C163.07 177.462 157.717 175.708 152.076 175.798C146.435 175.887 140.363 178.241 133.861 182.859L114.693 196.472L146.77 219.252Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M235.625 52.6262L416.628 180.603C429.124 189.438 429.124 203.762 416.628 212.597L235.625 340.574C223.13 349.409 202.87 349.409 190.375 340.574L9.37173 212.597C-3.12391 203.762 -3.12391 189.438 9.37172 180.603L190.375 52.6262C202.87 43.7913 223.13 43.7913 235.625 52.6262ZM290.596 213.945L197.056 150.108L217.292 135.762L265.867 167.722L263.968 133.871L286.355 118L288.049 158.111L359.102 165.378L335.236 182.298L288.86 177.31L289.153 184.23L310.832 199.599L290.596 213.945ZM70 201.714L192.905 289L241.672 254.366C254.96 244.929 262.551 235.142 264.446 225.004C266.398 214.825 261.803 205.779 250.66 197.866C242.613 192.151 234.247 188.9 225.563 188.113C216.935 187.286 208.978 188.301 201.692 191.158L201.354 190.918C204.302 186.506 205.165 181.658 203.941 176.372C202.718 171.006 198.702 165.905 191.893 161.069C180.919 153.275 168.971 150.211 156.048 151.875C143.124 153.539 129.906 159.17 116.393 168.767L70 201.714Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_600_256">
<rect width="426" height="426" fill="white" />
</clipPath>
</defs>
</svg>
);
}
-44
View File
@@ -1,44 +0,0 @@
import { ComponentProps } from 'react';
export function BlitzkitButtonWatermark(props: ComponentProps<'svg'>) {
return (
<svg
width="426"
height="426"
viewBox="0 0 426 426"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g opacity="0.1" clipPath="url(#clip0_513_262)">
<path
d="M416.628 244.997L235.625 372.974C223.13 381.809 202.87 381.809 190.375 372.974L9.37173 244.997C3.1239 240.58 -3.95076e-06 234.79 0 229V196.6L213 54.5L426 196.6V229C426 234.79 422.876 240.58 416.628 244.997Z"
fill="#8D8D8D"
/>
<path
d="M195.007 94.2094C196.778 94.4555 198.907 95.398 201.392 97.0371C202.235 97.8936 202.687 98.8011 202.746 99.7593C202.815 101.077 202.158 102.229 200.776 103.215C199.325 104.25 197.779 104.669 196.136 104.474C194.494 104.279 192.804 103.561 191.066 102.321C189.218 101.003 188.157 99.7783 187.883 98.6459C187.623 97.5038 188.212 96.4202 189.649 95.3953C191.181 94.302 192.967 93.9067 195.007 94.2094Z"
fill="white"
/>
<path
d="M221.456 275.38L242.999 260.08C249.557 255.422 252.732 250.85 252.521 246.364C252.31 241.797 249.392 237.516 243.764 233.519C238.193 229.562 232.22 227.529 225.846 227.42C219.473 227.23 213.007 229.465 206.448 234.123L184.905 249.422L221.456 275.38Z"
fill="white"
/>
<path
d="M167.77 237.252L186.937 223.64C193.44 219.022 196.726 214.69 196.796 210.643C196.809 206.557 194.256 202.696 189.135 199.059C184.07 195.462 178.717 193.708 173.076 193.798C167.435 193.887 161.363 196.241 154.861 200.859L135.693 214.472L167.77 237.252Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M416.628 180.603L235.625 52.6262C223.13 43.7913 202.87 43.7913 190.375 52.6262L9.37172 180.603C-3.12391 189.438 -3.12391 203.762 9.37173 212.597L190.375 340.574C202.87 349.409 223.13 349.409 235.625 340.574L416.628 212.597C429.124 203.762 429.124 189.438 416.628 180.603ZM218.47 76.4488L232.267 101.781L239 96.9786L224.094 71.0312L219.844 68L198.721 83.0665L203.957 86.8006L218.47 76.4488ZM184.681 106.963C181.602 104.766 180.042 102.326 180 99.6413C179.973 96.947 181.629 94.409 184.968 92.0273C188.143 89.7627 191.804 88.6061 195.95 88.5574C200.097 88.5088 204.484 90.1343 209.11 93.4339L209.91 94.005C214.92 97.578 217.431 101.507 217.445 105.793C217.459 110.059 214.606 114.3 208.886 118.517L208.208 119L203.24 115.456L203.856 115.017C207.537 112.391 209.494 109.697 209.727 106.934C209.886 104.918 208.879 102.845 206.707 100.715C206.753 100.859 206.794 101.004 206.832 101.151C207.434 103.493 206.38 105.631 203.671 107.564C200.852 109.575 197.731 110.512 194.31 110.375C190.888 110.238 187.679 109.101 184.681 106.963ZM311.596 231.945L218.056 168.108L238.292 153.762L286.867 185.722L284.968 151.871L307.355 136L309.049 176.111L380.102 183.378L356.236 200.298L309.86 195.31L310.153 202.23L331.832 217.599L311.596 231.945ZM91 219.714L213.905 307L262.672 272.366C275.96 262.929 283.551 253.142 285.446 243.003C287.398 232.825 282.803 223.779 271.66 215.866C263.613 210.151 255.247 206.9 246.563 206.113C237.935 205.286 229.978 206.301 222.692 209.158L222.354 208.918C225.302 204.506 226.165 199.658 224.941 194.372C223.718 189.006 219.702 183.905 212.893 179.069C201.919 171.275 189.971 168.211 177.048 169.875C164.124 171.539 150.906 177.17 137.393 186.767L91 219.714Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_513_262">
<rect width="426" height="426" fill="white" />
</clipPath>
</defs>
</svg>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { ComponentProps } from 'react';
export function BlitzkitWide(props: ComponentProps<'svg'>) {
return (
<svg
width="62"
height="15"
viewBox="0 0 62 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M0.204102 14V1.20312H5.45996C6.9541 1.20312 8.11719 1.48145 8.94922 2.03809C9.78125 2.59473 10.1973 3.43848 10.1973 4.56934C10.1973 5.27246 10.0449 5.85547 9.74023 6.31836C9.43555 6.78125 9.06055 7.0918 8.61523 7.25V7.28516C9.16602 7.44336 9.62891 7.77734 10.0039 8.28711C10.3848 8.79688 10.5752 9.47656 10.5752 10.3262C10.5752 11.4863 10.127 12.3887 9.23047 13.0332C8.33398 13.6777 7.15625 14 5.69727 14H0.204102ZM3.64062 11.6973H5.48633C6.03711 11.6973 6.44141 11.5508 6.69922 11.2578C6.96289 10.9648 7.09473 10.5664 7.09473 10.0625C7.09473 9.57031 6.96289 9.17773 6.69922 8.88477C6.44141 8.5918 6.03711 8.44531 5.48633 8.44531H3.64062V11.6973ZM3.64062 6.35352H5.2666C5.80566 6.35352 6.19238 6.23047 6.42676 5.98438C6.66699 5.73828 6.78711 5.39258 6.78711 4.94727C6.78711 4.50195 6.66699 4.15625 6.42676 3.91016C6.19238 3.66406 5.80566 3.54102 5.2666 3.54102H3.64062V6.35352ZM12.1924 14V0.631836H15.5674V14H12.1924ZM17.5449 14V4.82422H20.9375V14H17.5449ZM17.3164 2.37207C17.3164 1.88574 17.4834 1.47852 17.8174 1.15039C18.1514 0.816406 18.626 0.649414 19.2412 0.649414C19.8623 0.649414 20.3369 0.816406 20.665 1.15039C20.999 1.47852 21.166 1.88574 21.166 2.37207C21.166 2.86426 20.999 3.27734 20.665 3.61133C20.3311 3.94531 19.8535 4.1123 19.2324 4.1123C18.6172 4.1123 18.1426 3.94531 17.8086 3.61133C17.4805 3.27734 17.3164 2.86426 17.3164 2.37207ZM21.9395 7.22363V4.82422H28.0215V7.22363H21.9395ZM23.126 10.9502V5.89648L23.1523 5.65918V2.67969H26.4746V10.7568C26.4746 11.1904 26.5479 11.4834 26.6943 11.6357C26.8467 11.7822 27.04 11.8555 27.2744 11.8555C27.3916 11.8555 27.5117 11.8408 27.6348 11.8115C27.7578 11.7822 27.8691 11.7441 27.9688 11.6973V13.9736C27.8398 14.0439 27.6406 14.1055 27.3711 14.1582C27.1016 14.2168 26.7998 14.2461 26.4658 14.2461C25.458 14.2461 24.6494 13.9824 24.04 13.4551C23.4307 12.9219 23.126 12.0869 23.126 10.9502ZM28.8125 14V11.9609L32.4688 7.29395H28.8916V4.82422H36.9424V6.81934L33.2334 11.5566H37.2324V14H28.8125ZM38.8057 14V1.20312H42.2773V5.72949L46.1094 1.20312H49.7305L42.2773 9.68457V14H38.8057ZM46.373 14L42.2861 8.17285L44.4834 5.65918L50.5391 14H46.373ZM50.9961 14V4.82422H54.3887V14H50.9961ZM50.7676 2.37207C50.7676 1.88574 50.9346 1.47852 51.2686 1.15039C51.6025 0.816406 52.0771 0.649414 52.6924 0.649414C53.3135 0.649414 53.7881 0.816406 54.1162 1.15039C54.4502 1.47852 54.6172 1.88574 54.6172 2.37207C54.6172 2.86426 54.4502 3.27734 54.1162 3.61133C53.7822 3.94531 53.3047 4.1123 52.6836 4.1123C52.0684 4.1123 51.5938 3.94531 51.2598 3.61133C50.9316 3.27734 50.7676 2.86426 50.7676 2.37207ZM55.3906 7.22363V4.82422H61.4727V7.22363H55.3906ZM56.5771 10.9502V5.89648L56.6035 5.65918V2.67969H59.9258V10.7568C59.9258 11.1904 59.999 11.4834 60.1455 11.6357C60.2979 11.7822 60.4912 11.8555 60.7256 11.8555C60.8428 11.8555 60.9629 11.8408 61.0859 11.8115C61.209 11.7822 61.3203 11.7441 61.4199 11.6973V13.9736C61.291 14.0439 61.0918 14.1055 60.8223 14.1582C60.5527 14.2168 60.251 14.2461 59.917 14.2461C58.9092 14.2461 58.1006 13.9824 57.4912 13.4551C56.8818 12.9219 56.5771 12.0869 56.5771 10.9502Z"
fill="white"
/>
</svg>
);
}
+7
View File
@@ -58,6 +58,13 @@
"AT-SPG": "TD"
},
"tank_class_medium": {
"lightTank": "Light",
"mediumTank": "Medium",
"heavyTank": "Heavy",
"AT-SPG": "Tank destroyer"
},
"tree_type": {
"researchable": "Tech tree",
"premium": "Premium",
+3 -3
View File
@@ -26,8 +26,8 @@ export interface App {
policiesAgreementIndex: number;
}
export const { Provider, use, useMutation, useStore } = createNextSafeStore(
() =>
export const { Provider, use, useMutation, useStore, useDeferred } =
createNextSafeStore(() =>
create<App>()(
persist(
(set) => ({
@@ -38,4 +38,4 @@ export const { Provider, use, useMutation, useStore } = createNextSafeStore(
{ name: 'app', merge: (a, b) => merge(b, a) },
),
),
);
);
+3
View File
@@ -3,6 +3,7 @@
import { Vector3 } from 'three';
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { XP_MULTIPLIERS } from '../app/tools/tankopedia/[id]/components/Model/TechTreeSection';
import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
import { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent';
import { ModelDefinition } from '../core/blitzkit/modelDefinitions';
@@ -28,6 +29,7 @@ interface TankopediaEphemeral {
}
| { type: ArmorType.External }
);
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
}
export interface ShotLayerBase {
@@ -97,6 +99,7 @@ export const { Provider, use, useMutation, useStore } = createNextSafeStore(
skills: {},
model,
controlsEnabled: true,
xpMultiplier: 1,
})),
),
);