Merge branch 'dev'

This commit is contained in:
TrèsAbhi committed 2024-06-05 14:06:42 -05:00
commit 16ebea659a
81 files changed
+4379 -2507

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
'use client';
import {
AlertDialog,
Button,
Flex,
Heading,
Link,
Text,
} from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { extendAuth } from '../../core/blitz/extendAuth';
import { logout } from '../../core/blitz/logout';
import isDev from '../../core/blitzkit/isDev';
import { isLocalhost } from '../../core/blitzkit/isLocalhost';
import { CURRENT_POLICIES_AGREEMENT_INDEX, useApp } from '../../stores/app';
const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000;
export function Checks() {
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const login = useApp((state) => state.login);
const policiesAgreementIndex = useApp(
(state) => state.policiesAgreementIndex,
);
const pathname = usePathname();
const isLegal = pathname.startsWith('/legal');
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
useEffect(() => {
setShowDevBuildAlert(
isDev() &&
!isLocalhost() &&
Date.now() - useApp.getState().devBuildAgreementTime >=
DEV_BUILD_AGREEMENT_COOLDOWN,
);
}, []);
useEffect(() => {
setShowPoliciesAgreement(
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX && !isLegal,
);
}, [policiesAgreementIndex, isLegal]);
useEffect(() => {
if (!login) return;
const expiresIn = login.expiresAt - Date.now() / 1000;
const expiresInDays = expiresIn / 60 / 60 / 24;
if (expiresInDays < 0) {
logout();
} else if (expiresInDays < 7) {
extendAuth();
}
});
return (
<>
<AlertDialog.Root open={showDevBuildAlert}>
<AlertDialog.Content>
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
<AlertDialog.Description>
This version may have a lot of issues. Report issues to{' '}
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
the official Discord server
</a>
. Also consider using{' '}
<a href="https://blitzkit.app/">the more stable release version</a>.
You will be asked again in 8 days.
</AlertDialog.Description>
<Flex justify="end">
<Button
variant="solid"
onClick={() => {
setShowDevBuildAlert(false);
useApp.setState({ devBuildAgreementTime: Date.now() });
}}
>
Continue
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
{showPoliciesAgreement && (
<Flex
align="end"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'var(--color-overlay)',
zIndex: 2,
}}
>
<Flex
p="8"
style={{
width: '100%',
background: 'var(--color-panel-solid)',
}}
justify="center"
>
<Flex
align="start"
gap="4"
style={{
maxWidth: 640 * 2,
}}
direction="column"
>
<Flex direction="column" gap="2">
<Heading>
{policiesAgreementIndex === -1
? "BlitzKit's policies"
: "BlitzKit's policies have changed"}
</Heading>
<Text>
This website utilizes cookies to perform analytics and
personalize your experience. You can learn more through{' '}
<Link href="/docs/legal/privacy-policy">
our privacy policy
</Link>
. You may opt out of personalized advertizement by visiting{' '}
<Link href="https://www.google.com/settings/ads">
Google Ad Settings
</Link>
. By using BlitzKit, you also agree to our{' '}
<Link href="/docs/legal/terms-of-service">
terms of service
</Link>
.
</Text>
</Flex>
<Button
onClick={() => {
useApp.setState({
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX,
});
}}
>
I Agree
</Button>
</Flex>
</Flex>
</Flex>
)}
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { Flex, Heading, Text } from '@radix-ui/themes';
import { imgur } from '../../core/blitzkit/imgur';
export function Hero() {
return (
<Flex
align="center"
justify="center"
style={{
position: 'relative',
background: `url(${imgur('rUPie9G', { format: 'jpeg' })})`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}}
px="4"
py="9"
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(var(--color-panel-translucent), var(--color-background))',
}}
/>
<Flex
direction="column"
gap="4"
align="center"
justify="center"
position="relative"
width="100%"
>
<Flex direction="column" align="center">
<Heading size="9" weight="bold" align="center">
BlitzKit
</Heading>
<Text color="gray" align="center" size="4">
Everything World of Tanks Blitz
</Text>
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,38 @@
import { Table } from '@radix-ui/themes';
import Markdown from 'markdown-to-jsx';
import { use } from 'react';
import { Link } from '../../../../components/Link';
import PageWrapper from '../../../../components/PageWrapper';
import isDev from '../../../../core/blitzkit/isDev';
export default function Page({
params,
}: {
params: { document: string; directory: string };
}) {
const markdown = use(
fetch(
`https://raw.githubusercontent.com/tresabhi/blitzkit/${isDev() ? 'dev' : 'main'}/docs/${params.directory}/${params.document}.md`,
).then((response) => response.text()),
);
return (
<PageWrapper>
<Markdown
options={{
overrides: {
a: { component: Link },
table: { component: Table.Root },
thead: { component: Table.Header },
tbody: { component: Table.Body },
tr: { component: Table.Row },
th: { component: Table.ColumnHeaderCell },
td: { component: Table.Cell },
},
}}
>
{markdown}
</Markdown>
</PageWrapper>
);
}
+8
View File
@@ -0,0 +1,8 @@
*,
html {
scroll-behavior: smooth;
}
.radix-themes {
--default-font-family: var(--font-inter);
}
+13 -48
View File
@@ -1,23 +1,20 @@
'use client';
import { AlertDialog, Button, Flex, Theme } from '@radix-ui/themes';
import { Flex, Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css';
import { config } from 'dotenv';
import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation';
import { ReactNode, Suspense, useEffect, useState } from 'react';
import { ReactNode, Suspense } from 'react';
import { Footer } from '../components/Footer';
import { Loader } from '../components/Loader';
import Navbar from '../components/Navbar';
import Navbar, { NAVBAR_HEIGHT } from '../components/Navbar';
import { Party3 } from '../components/Party3';
import isDev from '../core/blitzkit/isDev';
import { isLocalhost } from '../core/blitzkit/isLocalhost';
import { useApp } from '../stores/app';
import { Checks } from './components/Checks';
import './layout.css';
config();
const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000;
interface RootLayoutProps {
children: ReactNode;
}
@@ -25,23 +22,13 @@ interface RootLayoutProps {
const robotoFlex = Roboto_Flex({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-flex',
});
export default function RootLayout({ children }: RootLayoutProps) {
const pathname = usePathname();
const isEmbed = pathname.split('/')[1] === 'embeds';
const isRoot = pathname === '/';
const darkMode = useApp((state) => state.darkMode);
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
useEffect(() => {
setShowDevBuildAlert(
isDev() &&
!isLocalhost() &&
Date.now() - useApp.getState().devBuildAgreementTime >=
DEV_BUILD_AGREEMENT_COOLDOWN,
);
}, []);
return (
<html lang="en" className={robotoFlex.className}>
@@ -68,45 +55,23 @@ export default function RootLayout({ children }: RootLayoutProps) {
}}
>
<Theme
appearance={darkMode ? 'dark' : 'light'}
appearance="dark"
panelBackground="solid"
radius="full"
accentColor="amber"
suppressHydrationWarning
suppressContentEditableWarning
>
<Flex
direction="column"
style={{ minHeight: '100vh', paddingTop: isEmbed ? undefined : 64 }}
style={{
minHeight: '100vh',
paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
}}
>
{!isEmbed && <Navbar />}
<AlertDialog.Root open={showDevBuildAlert}>
<AlertDialog.Content>
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
<AlertDialog.Description>
This version may have a lot of issues. Report issues to{' '}
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
the official Discord server
</a>
. Also consider using{' '}
<a href="https://blitzkit.app/">
the more stable release version
</a>
. You will be asked again in 8 days.
</AlertDialog.Description>
<Flex justify="end">
<Button
variant="solid"
onClick={() => {
setShowDevBuildAlert(false);
useApp.setState({ devBuildAgreementTime: Date.now() });
}}
>
Continue
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<Checks />
<Suspense
fallback={
-15
View File
@@ -1,15 +0,0 @@
import { styleVariants } from '@vanilla-extract/css';
import { theme } from '../stitches.config';
export const tool = styleVariants({
enabled: {
boxShadow: `inset 0 -200px 128px -128px ${theme.colors.appBackground1}`,
':hover': {
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
},
},
disabled: {
boxShadow: `inset 0 -192px 128px -128px ${theme.colors.appBackground1}`,
},
});
+136 -78
View File
@@ -1,91 +1,149 @@
'use client';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, Link as LinkRadix } from '@radix-ui/themes';
import { Box, Button, Flex, Grid, Heading, Text } from '@radix-ui/themes';
import Link from 'next/link';
import PageWrapper from '../components/PageWrapper';
import { TOOLS } from '../constants/tools';
import { PatreonIcon } from '../icons/Patreon';
import { theme } from '../stitches.config';
import * as styles from './page.css';
import { ImgurSize, imgur } from '../core/blitzkit/imgur';
import { Hero } from './components/Hero';
export default function Page() {
return (
<PageWrapper>
<Flex
direction="column"
gap="5"
justify="center"
align="center"
style={{ flex: 1 }}
>
<LinkRadix href="https://www.patreon.com/tresabhi" target="_blank">
<Button
color="ruby"
style={{
boxShadow: `0 0 8px ${theme.colors.solidBackground_ruby}f0`,
}}
>
<PatreonIcon style={{ width: '1em', height: '1em' }} /> Consider
donating on Patreon
</Button>
</LinkRadix>
<>
<Hero />
<Flex gap="3" wrap="wrap" align="center" justify="center">
{TOOLS.map((tool) => (
<Link
href={tool.href ?? `/tools/${tool.id}`}
target={tool.href ? '_blank' : undefined}
className={
tool.disabled ? styles.tool.disabled : styles.tool.enabled
}
style={{
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
cursor: tool.disabled ? 'default' : 'pointer',
opacity: tool.disabled ? 0.25 : 1,
height: 128,
minWidth: 256,
flex: 1,
borderRadius: 8,
textDecoration: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: 16,
transition: `box-shadow ${theme.durations.regular}`,
}}
onClick={(event) => {
if (tool.disabled) event.preventDefault();
}}
>
<span
<PageWrapper size={1028}>
<Grid
p="4"
gap="4"
columns={{
initial: undefined,
sm: '2',
}}
flow="row-dense"
>
{TOOLS.map((tool) => {
const size = tool.significant
? ImgurSize.HugeThumbnail
: ImgurSize.LargeThumbnail;
return (
<Flex
key={tool.id}
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
borderRadius: 'var(--radius-2)',
overflow: 'hidden',
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
height: tool.significant ? 'min(320px, 50vh)' : '256px',
}}
gridColumn={{
initial: undefined,
sm: tool.significant ? '1 / 3' : undefined,
}}
>
{tool.title}
<CaretRightIcon style={{ width: '1em', height: '1em' }} />
</span>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{tool.disabled ? 'Coming soon!' : tool.description}
</span>
</Link>
))}
</Flex>
</Flex>
</PageWrapper>
<Link
href={tool.href ?? `/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 })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
px={{
initial: '6',
md: tool.significant ? '8' : '6',
}}
py={{
initial: '4',
sm: tool.significant ? '6' : '4',
}}
gap="4"
align="center"
justify="between"
width="100%"
direction={{
initial: 'column',
sm: 'row',
}}
style={{
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(64px)',
WebkitBackdropFilter: 'blur(64px)',
}}
>
<Flex
direction="column"
justify="center"
align={{
initial: 'center',
sm: 'start',
}}
>
<Heading
align={{
initial: 'center',
sm: 'left',
}}
size={{
initial: '6',
sm: tool.significant ? '7' : '5',
}}
weight="medium"
>
{tool.title}
</Heading>
<Text
align={{
initial: 'center',
sm: 'left',
}}
size={{
initial: '3',
sm: tool.significant ? '4' : '3',
}}
color="gray"
>
{tool.description}
</Text>
</Flex>
<Button
size={{
initial: undefined,
sm: tool.significant ? '3' : undefined,
}}
color={tool.button.color}
style={{
cursor: 'inherit',
}}
>
{tool.button.text}
<CaretRightIcon />
</Button>
</Flex>
</Link>
</Flex>
);
})}
</Grid>
</PageWrapper>
<div style={{ flex: 1 }} />
</>
);
}
-15
View File
@@ -6,25 +6,10 @@ import { useApp } from '../../stores/app';
export default function Page() {
const developerMode = useApp((state) => state.developerMode);
const darkMode = useApp((state) => state.darkMode);
return (
<PageWrapper justify="center" align="center">
<Flex direction="column" gap="4">
<Flex gap="3" direction="column">
<Heading size="5">Appearance</Heading>
<Flex align="center" gap="2">
<Text>Dark mode (experimental)</Text>
<Switch
checked={darkMode}
onCheckedChange={(checked) =>
useApp.setState({ darkMode: checked })
}
/>
</Flex>
</Flex>
<Flex gap="3" direction="column">
<Heading size="5">Advanced</Heading>
File diff suppressed because it is too large. Load diff
+4 -6
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { TOOLS } from '../../constants/tools';
import { ImgurSize, imgur } from '../../core/blitzkit/imgur';
import { theme } from '../../stitches.config';
interface ToolsLayoutProps {
@@ -24,10 +25,7 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
{isToolRoot && (
<>
<title>{tool.title}</title>
<meta
property="og:image"
content={`/assets/banners/${toolId}.webp`}
/>
<meta property="og:image" content={imgur(tool.image)} />
{tool.pageDescription !== undefined && (
<>
@@ -59,7 +57,7 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
<div
style={{
position: 'absolute',
backgroundImage: `url(/assets/banners/${toolId}.webp)`,
backgroundImage: `url(${imgur(tool!.image, { format: 'jpeg', size: ImgurSize.HugeThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '100%',
@@ -67,7 +65,7 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
top: 0,
left: 0,
zIndex: -1,
filter: 'blur(4px)',
// filter: 'blur(4px)',
transform: 'scale(calc((128 + 8) / 128))',
}}
/>
@@ -3,7 +3,7 @@ import {
CaretRightIcon,
SymbolIcon,
} from '@radix-ui/react-icons';
import { Button, Flex, TextField } from '@radix-ui/themes';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { range } from 'lodash';
import { useEffect, useRef } from 'react';
import { infiniteSpin } from './index.css';
@@ -42,7 +42,7 @@ export function PageTurner({
return (
<Flex justify="center" wrap="wrap" gap="2">
<Flex gap="2">
<Button
<IconButton
variant="soft"
onClick={() => onPageChange(Math.max(page - 1, 0), true)}
disabled={page === 0 || leftLoading}
@@ -52,7 +52,7 @@ export function PageTurner({
) : (
<CaretLeftIcon />
)}
</Button>
</IconButton>
<TextField.Root
type="number"
@@ -75,7 +75,7 @@ export function PageTurner({
<TextField.Slot>Page</TextField.Slot>
<TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root>
<Button
<IconButton
variant="soft"
onClick={() => {
onPageChange(Math.min(page + 1, pages - 1), true);
@@ -87,7 +87,7 @@ export function PageTurner({
) : (
<CaretRightIcon />
)}
</Button>
</IconButton>
</Flex>
</Flex>
);
+89 -32
View File
@@ -15,6 +15,7 @@ import {
IconButton,
Link,
Select,
Spinner,
Table,
Text,
TextField,
@@ -24,16 +25,16 @@ import { use, useEffect, useMemo, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { UNLOCALIZED_REGION_NAMES_SHORT } from '../../../constants/regions';
import {
generateStats,
prettifyStats,
Stat,
STAT_KEYS,
STAT_NAMES,
Stat,
generateStats,
prettifyStats,
sumStats,
} from '../../../core/blitz/generateStats';
import {
getAccountInfo,
IndividualAccountInfo,
getAccountInfo,
} from '../../../core/blitz/getAccountInfo';
import getTankStats from '../../../core/blitz/getTankStats';
import { idToRegion } from '../../../core/blitz/idToRegion';
@@ -52,7 +53,12 @@ import mutateSession, {
} from '../../../stores/session';
import { IndividualTankStats } from '../../../types/tanksStats';
export default function Page() {
export default function Page({
searchParams,
}: {
searchParams: { id?: string };
}) {
const [loaded, setLoaded] = useState(false);
const wideFormat = useWideFormat(640);
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedTankAvearges = use(tankAverages);
@@ -105,6 +111,7 @@ export default function Page() {
: undefined,
[delta],
);
const [showLinkOverridePrompt, setShowLinkOverridePrompt] = useState(true);
const search = debounce(async () => {
if (!input.current) return;
@@ -113,6 +120,32 @@ export default function Page() {
setSearching(false);
}, 500);
async function track(id: number) {
const region = idToRegion(id);
const player = await getAccountInfo(region, id);
const stats = await getTankStats(region, id);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
setShowSearch(false);
mutateSession((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as SessionTracking).player = {
id,
region,
since: Date.now(),
stats,
};
input.current.value = player.nickname;
});
}
useEffect(() => {
async function update() {
if (!session.tracking) return;
@@ -130,8 +163,24 @@ export default function Page() {
return () => clearInterval(interval);
}, [session.tracking && session.player.id]);
useEffect(() => {
setLoaded(true);
if (searchParams.id && !session.tracking) {
track(Number(searchParams.id));
}
}, []);
if (!loaded) {
return (
<PageWrapper align="center" justify="center">
<Spinner />
</PageWrapper>
);
}
return (
<PageWrapper>
<PageWrapper color="blue">
<AlertDialog.Root
open={showCCInaccessibilityPrompt}
onOpenChange={setShowCCInaccessibilityPrompt}
@@ -154,6 +203,39 @@ export default function Page() {
</AlertDialog.Content>
</AlertDialog.Root>
{searchParams.id &&
session.tracking &&
Number(searchParams.id) !== session.player.id && (
<AlertDialog.Root
open={showLinkOverridePrompt}
onOpenChange={setShowLinkOverridePrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>
You are currently tracking {accountInfo?.nickname}
</AlertDialog.Title>
<AlertDialog.Description>
Would you like to stop tracking {accountInfo?.nickname} and
start tracking a new player?
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button
variant="solid"
onClick={() => {
setShowLinkOverridePrompt(false);
track(Number(searchParams.id));
}}
>
Proceed
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)}
{!session.tracking && (
<Flex
style={{ flex: 1 }}
@@ -222,32 +304,7 @@ export default function Page() {
<Button
key={player.account_id}
variant="ghost"
onClick={async () => {
const stats = await getTankStats(
player.region,
player.account_id,
);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
setShowSearch(false);
mutateSession((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as SessionTracking).player = {
id: player.account_id,
region: player.region,
since: Date.now(),
stats,
};
input.current.value = player.nickname;
});
}}
onClick={() => track(player.account_id)}
>
{player.nickname} (
{UNLOCALIZED_REGION_NAMES_SHORT[player.region]})
@@ -149,7 +149,7 @@ export function Modules() {
)
) {
draft.protagonist!.gun = draft.protagonist!.turret.guns.at(-1)!;
draft.protagonist!.shell = draft.protagonist!.gun.shells.at(-1)!;
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
}
} else if (unlock.type === 'gun') {
const gunInTurret = draft.protagonist!.turret.guns.find(
@@ -157,7 +157,7 @@ export function Modules() {
);
if (gunInTurret) {
draft.protagonist!.gun = gunInTurret;
draft.protagonist!.shell = gunInTurret.shells.at(-1)!;
draft.protagonist!.shell = gunInTurret.shells[0];
} else {
// TODO: warn somehow?
const suitableTurret = draft.protagonist!.tank.turrets.find(
@@ -169,7 +169,7 @@ export function Modules() {
draft.protagonist!.turret = suitableTurret;
draft.protagonist!.gun = gunInSuitableTurret;
draft.protagonist!.shell = gunInSuitableTurret.shells.at(-1)!;
draft.protagonist!.shell = gunInSuitableTurret.shells[0];
}
} else if (unlock.type === 'engine') {
draft.protagonist!.engine = draft.protagonist!.tank.engines.find(
@@ -185,7 +185,7 @@ export function Modules() {
function tree(type: ModuleType, unlocks: Unlock[]) {
return (
<Flex gap="2" justify="between" style={{ width: '100%' }}>
<Flex gap="2">
{unlocks.map((unlock, index) => {
const first = index === 0;
const last = index === unlocks.length - 1;
@@ -338,8 +338,7 @@ export function Modules() {
draft.protagonist!.tank.turrets.at(-1)!;
draft.protagonist!.gun =
draft.protagonist!.turret.guns.at(-1)!;
draft.protagonist!.shell =
draft.protagonist!.gun.shells.at(-1)!;
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
draft.protagonist!.engine =
draft.protagonist!.tank.engines.at(-1)!;
draft.protagonist!.track =
@@ -11,8 +11,10 @@ import {
import { debounce } from 'lodash';
import { Fragment, use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils';
import { Link } from '../../../../../../components/Link';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { asset } from '../../../../../../core/blitzkit/asset';
import { coefficient } from '../../../../../../core/blitzkit/coefficient';
import { equipmentDefinitions } from '../../../../../../core/blitzkit/equipmentDefinitions';
@@ -101,13 +103,7 @@ export function Characteristics() {
const hasImprovedVerticalStabilizer = useEquipment(122);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
shell.type === 'ap'
? 0.08
: shell.type === 'ap_cr'
? 0.05
: shell.type === 'hc'
? 0.13
: 0.08,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
@@ -189,12 +185,9 @@ export function Characteristics() {
<Info
indent
name={
<a
target="_blank"
href="https://tresabhi.github.io/blitzkit/guide/dpm.html"
>
<Link target="_blank" href="/docs/guide/dpm">
What's the difference?
</a>
</Link>
}
/>
</>
@@ -311,7 +304,7 @@ export function Characteristics() {
<InfoWithDelta name="Normalization" decimals={0} unit="°">
{stats.shellNormalization}
</InfoWithDelta>
{shell.type !== 'he' && (
{!isExplosive(shell.type) && (
<InfoWithDelta
name="Ricochet"
decimals={0}
@@ -25,7 +25,7 @@ export function HeroSection() {
justify="center"
pt={wideFormat ? '0' : '6'}
style={{
background: `linear-gradient(${wideFormat ? -90 : 180}deg, ${theme.colors.appBackground1}, ${theme.colors.appBackground2})`,
background: 'var(--color-surface)',
position: 'relative',
height: wideFormat ? undefined : 'calc(75vh - 64px)',
}}
@@ -1,16 +0,0 @@
import { Heading, Text } from '@radix-ui/themes';
import PageWrapper from '../../../../../../components/PageWrapper';
import { useDuel } from '../../../../../../stores/duel';
export function HistoricalSection() {
const tank = useDuel((state) => state.protagonist!.tank);
if (!tank.description) return null;
return (
<PageWrapper noFlex1>
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</PageWrapper>
);
}
@@ -2,7 +2,6 @@ import { PerspectiveCamera } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { Suspense, use, useEffect, useRef } from 'react';
import { Armor } from '../../../../../../components/Armor';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkit/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
@@ -131,6 +130,9 @@ export function TankSandbox() {
draft.shot = undefined;
});
}}
style={{
userSelect: 'none',
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
@@ -140,7 +142,6 @@ export function TankSandbox() {
<Lighting />
<TankModel />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
);
@@ -133,11 +133,11 @@ export function TechTreeSection() {
style: {
overflow: 'hidden',
marginTop: 16,
backgroundColor: 'var(--color-surface)',
},
}}
size={tank.ancestors && tank.successors ? 880 : 480}
gap="0"
highlight
>
{/* <Flex justify="center">
<Heading>Tech tree</Heading>
@@ -6,12 +6,14 @@ import {
GearIcon,
} from '@radix-ui/react-icons';
import {
Box,
Button,
Dialog,
DropdownMenu,
Flex,
Heading,
IconButton,
Strong,
Switch,
Tabs,
Text,
@@ -74,6 +76,31 @@ export function Options() {
return (
<>
{isFullScreen && (
<Box
pl="4"
style={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translateY(-50%)',
}}
>
<Text
size={{
initial: '3',
xs: '5',
}}
color="gray"
style={{
writingMode: 'vertical-lr',
}}
>
<Strong>BlitzKit</Strong> Tankopedia
</Text>
</Box>
)}
<RotationInputs />
{mode === 'armor' && (
@@ -93,7 +120,10 @@ export function Options() {
color={thisShell.id === antagonistShell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
size="3"
size={{
initial: '2',
sm: '3',
}}
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius: shellIndex === 0 ? undefined : 0,
@@ -111,13 +141,18 @@ export function Options() {
mutateDuel((draft) => {
draft.antagonist!.shell = thisShell;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt={thisShell.name}
width={20}
height={20}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
))}
@@ -131,26 +166,37 @@ export function Options() {
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}}
size="3"
size={{
initial: '2',
sm: '3',
}}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Calibrated Shells"
width={20}
height={20}
src={asset('icons/equipment/103.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
<IconButton
color={hasEnhancedArmor ? undefined : 'gray'}
variant="soft"
size="3"
size={{
initial: '2',
sm: '3',
}}
style={{
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
@@ -162,13 +208,18 @@ export function Options() {
? 0
: -1;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Enhanced Armor"
width={20}
height={20}
src={asset('icons/equipment/110.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
</Flex>
@@ -1,6 +1,6 @@
import { ThreeEvent, useThree } from '@react-three/fiber';
import { memo, useEffect, useRef } from 'react';
import { Euler, Group, Vector3 } from 'three';
import { Euler, Group, Vector2, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../constants/axis';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
@@ -142,6 +142,8 @@ export const TankModel = memo(() => {
node.name ===
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentTurret;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
@@ -152,6 +154,7 @@ export const TankModel = memo(() => {
event.stopPropagation();
if (isTurret) {
position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
@@ -172,9 +175,14 @@ export const TankModel = memo(() => {
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch,
yaw + event.movementX * (Math.PI / canvas.width),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
@@ -215,6 +223,8 @@ export const TankModel = memo(() => {
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun || isCurrentMantlet;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
@@ -230,8 +240,11 @@ export const TankModel = memo(() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
@@ -242,9 +255,13 @@ export const TankModel = memo(() => {
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - event.movementY * (Math.PI / canvas.height),
yaw + event.movementX * (Math.PI / canvas.width),
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
@@ -1,26 +0,0 @@
import { Button } from '@radix-ui/themes';
import { ComponentProps } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
interface QuickEquipmentButtonProps extends ComponentProps<typeof Button> {
equipment: number;
active: boolean;
}
export function QuickEquipmentButton({
equipment,
active,
...props
}: QuickEquipmentButtonProps) {
return (
<Button variant="ghost" {...props}>
<img
alt={`Equipment ${equipment}`}
style={{ opacity: active ? 1 : 0.5 }}
src={asset(`icons/equipment/${equipment}.webp`)}
width={24}
height={24}
/>
</Button>
);
}
@@ -4,7 +4,10 @@ import { use, useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkit/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import {
modelTransformEvent,
ModelTransformEventData,
} from '../../../../../../core/blitzkit/modelTransform';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
@@ -13,8 +16,8 @@ import * as styles from './index.css';
export function RotationInputs() {
const protagonist = useDuel((state) => state.protagonist!);
const awaitedModelDefinitions = use(modelDefinitions);
const turretYawInput = useRef<HTMLInputElement>(null);
const gunPitchInput = useRef<HTMLInputElement>(null);
const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
@@ -24,13 +27,30 @@ export function RotationInputs() {
const isFullScreen = useFullScreen();
useEffect(() => {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}, [protagonist.yaw]);
useEffect(() => {
gunPitchInput.current!.value = (
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}, [protagonist.pitch]);
useEffect(() => {
function handleTransformEvent({ pitch, yaw }: ModelTransformEventData) {
if (!pitchInput.current || !yawInput.current) return;
pitchInput.current.value = (-radToDeg(pitch)).toFixed(1);
if (yaw === undefined) return;
yawInput.current.value = radToDeg(yaw).toFixed(1);
}
modelTransformEvent.on(handleTransformEvent);
return () => {
modelTransformEvent.off(handleTransformEvent);
};
}, []);
return (
<Flex
@@ -53,11 +73,9 @@ export function RotationInputs() {
textAlign: 'right',
}}
onBlur={() => {
const value = Number(turretYawInput.current!.value);
const value = Number(yawInput.current!.value);
if (isNaN(value)) {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
@@ -72,15 +90,15 @@ export function RotationInputs() {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
yawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
onFocus={() => yawInput.current?.focus()}
ref={yawInput}
>
<TextField.Slot
style={{
@@ -100,9 +118,9 @@ export function RotationInputs() {
textAlign: 'right',
}}
onBlur={() => {
const value = Number(gunPitchInput.current!.value);
const value = Number(pitchInput.current!.value);
if (isNaN(value)) {
gunPitchInput.current!.value = (
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
@@ -119,17 +137,17 @@ export function RotationInputs() {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
pitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
onFocus={() => pitchInput.current?.focus()}
ref={pitchInput}
>
<TextField.Slot
style={{
@@ -1,42 +0,0 @@
import { Flex, Heading, Link } from '@radix-ui/themes';
import { use } from 'react';
import PageWrapper from '../../../../../components/PageWrapper';
import { videoDefinitions } from '../../../../../core/blitzkit/videos';
import { useDuel } from '../../../../../stores/duel';
export function Videos() {
const tank = useDuel((state) => state.protagonist!.tank);
const awaitedVideoDefinitions = use(videoDefinitions);
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? [];
if (videos.length === 0) return null;
return (
<PageWrapper noFlex1>
<Flex direction="column" gap="4" align="start">
<Heading size="6">Review videos</Heading>
<Flex gap="3" wrap="wrap">
{videos.map((video) => (
<Link
key={video}
href={`https://www.youtube.com/watch?v=${video}`}
target="_blank"
>
<img
alt="Video thumbnail"
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`}
style={{
aspectRatio: '16 / 9',
objectFit: 'cover',
height: 128,
borderRadius: 8,
}}
/>
</Link>
))}
</Flex>
</Flex>
</PageWrapper>
);
}
+42 -1
View File
@@ -1,7 +1,10 @@
import { Flex, Heading, Link, Text } from '@radix-ui/themes';
import { ReactNode } from 'react';
import PageWrapper from '../../../../components/PageWrapper';
import { tankDefinitions } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
import { videoDefinitions } from '../../../../core/blitzkit/videos';
import strings from '../../../../lang/en-US.json';
interface TankopediaLayoutProps {
@@ -15,11 +18,13 @@ export default async function TankopediaLayout({
}: TankopediaLayoutProps) {
const id = Number(params.id);
const awaitedTankDefinitions = await tankDefinitions;
const awaitedVideoDefinitions = await videoDefinitions;
const tank = awaitedTankDefinitions[id];
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations as Record<string, string>)[tank.nation]
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${strings.common.tank_class_short[tank.class]}`;
const description = `Statistics, armor profiles, and equipment for ${tank.name}`;
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? [];
return (
<>
@@ -30,6 +35,42 @@ export default async function TankopediaLayout({
<meta property="og:description" content={description} />
{children}
<PageWrapper noFlex1>
<Flex direction="column" gap="4" align="start">
<Flex direction="column">
<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"
>
<img
alt="Video thumbnail"
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`}
style={{
aspectRatio: '16 / 9',
objectFit: 'cover',
height: 128,
borderRadius: 8,
}}
/>
</Link>
))}
</Flex>
</Flex>
</PageWrapper>
<PageWrapper noFlex1>
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</PageWrapper>
</>
);
}
+13 -4
View File
@@ -7,10 +7,8 @@ import { mutateDuel, useDuel } from '../../../../stores/duel';
import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
import { CharacteristicsSection } from './components/Model/CharacteristicsSection';
import { HeroSection } from './components/Model/HeroSection';
import { HistoricalSection } from './components/Model/HistoricalSection';
import { TankopediaPlug } from './components/Model/TankopediaPlug';
import { TechTreeSection } from './components/Model/TechTreeSection';
import { Videos } from './components/Videos';
export default function Page({ params }: { params: { id: string } }) {
const initialId = parseInt(params.id);
@@ -57,6 +55,19 @@ export default function Page({ params }: { params: { id: string } }) {
};
}, [initialId, assigned]);
useEffect(() => {
const unsubscribe = useDuel.subscribe(
(state) => state.protagonist?.tank.id,
() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
},
);
return unsubscribe;
}, []);
return (
<PageWrapper noPadding noMaxWidth color="purple" size={1600}>
{assigned && (
@@ -66,8 +77,6 @@ export default function Page({ params }: { params: { id: string } }) {
<CharacteristicsSection />
<TechTreeSection />
{/* <TankopediaSeparator /> */}
<Videos />
<HistoricalSection />
</>
)}
</PageWrapper>
@@ -1,5 +1,5 @@
import { CaretLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, TextField } from '@radix-ui/themes';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import mutateTankopediaPersistent, {
@@ -21,7 +21,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
return (
<Flex align="center" justify="center" gap="2">
<Button
<IconButton
variant="soft"
disabled={page === 0}
onClick={() => {
@@ -31,7 +31,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
}}
>
<CaretLeftIcon />
</Button>
</IconButton>
<TextField.Root
defaultValue={1}
type="number"
@@ -61,7 +61,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot>
</TextField.Root>
<Button
<IconButton
variant="soft"
disabled={Math.floor(searchedList.length / tanksPerPage) === page}
onClick={() => {
@@ -74,7 +74,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
}}
>
<CaretRightIcon />
</Button>
</IconButton>
</Flex>
);
}
@@ -5,7 +5,7 @@ import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinit
interface CompactSearchResultRowProps {
tanks: TankDefinition[];
onSelect: (tank: TankDefinition) => void;
onSelect?: (tank: TankDefinition) => void;
}
export function CompactSearchResultRow({
@@ -0,0 +1,175 @@
import { slateDark } from '@radix-ui/colors';
import { Card, Flex, Inset, Link, Text } from '@radix-ui/themes';
import { asset } from '../../../../../../core/blitzkit/asset';
import { TankDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
} from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../../core/blitzkit/tankIcon';
import { theme } from '../../../../../../stitches.config';
import * as styles from '../../../page.css';
import { CompactSearchResultRow } from './CompactSearchResultRow';
interface ResultsProps {
compact?: boolean;
results: TankDefinition[];
onSelect?: (tank: TankDefinition) => void;
}
export function Results({ compact, results, onSelect }: ResultsProps) {
const firstChunk = compact
? results.slice(0, Math.ceil(results.length / 2))
: [];
const secondChunk = compact
? results.slice(Math.ceil(results.length / 2))
: [];
return (
<Flex
wrap="wrap"
gap="2"
style={{
justifyContent:
compact && firstChunk.length > 0 && secondChunk.length > 0
? 'space-around'
: 'center',
}}
>
{!compact &&
results.map((tank) => (
<Link
key={tank.id}
className={styles.listing}
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
minWidth: 256,
}}
>
<Card
key={tank.id}
style={{
cursor: 'pointer',
height: 64,
width: '100%',
}}
>
<Inset
key={tank.id}
style={{
minHeight: 64,
position: 'relative',
display: 'flex',
}}
>
<img
alt={tank.name}
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
alt={tank.name}
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
alt={tank.name}
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Link>
))}
{compact && firstChunk.length > 0 && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)}
{compact && secondChunk.length > 0 && (
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)}
</Flex>
);
}
@@ -0,0 +1,337 @@
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu } from '@radix-ui/themes';
import mutateTankopediaPersistent, {
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../../../../stores/tankopedia';
export function Sort() {
const sort = useTankopediaPersistent((state) => state.sort);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Meta</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.tier'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.tier';
});
}}
>
Tier
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.name'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.name';
});
}}
>
Name
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dpm'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dpm';
});
}}
>
DPM
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.reload'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.reload';
});
}}
>
Reload
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.caliber'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.caliber';
});
}}
>
Caliber
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.standardPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.standardPenetration';
});
}}
>
Standard penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.premiumPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.premiumPenetration';
});
}}
>
Premium penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.damage'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.damage';
});
}}
>
Damage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.shellVelocity'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.shellVelocity';
});
}}
>
Shell velocity
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.aimTime'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.aimTime';
});
}}
>
Aim time
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionStill';
});
}}
>
Dispersion still
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionMoving';
});
}}
>
Dispersion moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunDepression'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunDepression';
});
}}
>
Gun depression
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunElevation'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunElevation';
});
}}
>
Gun elevation
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Maneuverability</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.forwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.forwardsSpeed';
});
}}
>
Forwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.backwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.backwardsSpeed';
});
}}
>
Backwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.power'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.power';
});
}}
>
Power
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.powerToWeight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.powerToWeight';
});
}}
>
Power to weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.weight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.weight';
});
}}
>
Weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.traverseSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.traverseSpeed';
});
}}
>
Traverse speed
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Survivability</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.health'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.health';
});
}}
>
Health
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.viewRange'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.viewRange';
});
}}
>
View range
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageStill';
});
}}
>
Camouflage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageMoving';
});
}}
>
Camouflage moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageShooting'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageShooting';
});
}}
>
Camouflage shooting
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.volume'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.volume';
});
}}
>
Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
@@ -1,6 +1,4 @@
import { slateDark } from '@radix-ui/colors';
import {
CaretDownIcon,
CaretRightIcon,
MagnifyingGlassIcon,
TrashIcon,
@@ -8,20 +6,17 @@ import {
import {
AlertDialog,
Button,
Card,
DropdownMenu,
Flex,
Inset,
Spinner,
Text,
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import Link from 'next/link';
import { use, useEffect, useMemo, useRef, useState } from 'react';
import { isMobileOnly } from 'react-device-detect';
import { TreeType } from '../../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
import { asset } from '../../../../../core/blitzkit/asset';
import { modelDefinitions } from '../../../../../core/blitzkit/modelDefinitions';
import { normalizeBoundingBox } from '../../../../../core/blitzkit/normalizeBoundingBox';
import { resolveDpm } from '../../../../../core/blitzkit/resolveDpm';
@@ -31,24 +26,15 @@ import {
TankDefinition,
tanksDefinitionsArray,
} from '../../../../../core/blitzkit/tankDefinitions';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../core/blitzkit/tankIcon';
import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox';
import { theme } from '../../../../../stitches.config';
import mutateTankopediaPersistent, {
SORT_NAMES,
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../../../stores/tankopedia';
import * as styles from '../../page.css';
import { PageTurner } from '../PageTurner';
import { CompactSearchResultRow } from './components/CompactSearchResultRow';
import { Options } from './components/Options';
import { Results } from './components/Results';
import { Sort } from './components/Sort';
interface TankSearchProps {
compact?: boolean;
@@ -63,12 +49,13 @@ export function TankSearch({
onSelect = () => {},
onSelectAll,
}: TankSearchProps) {
const tanksPerPage = compact ? 16 : 96;
const tanksPerPage = (compact ? 16 : 96) * (isMobileOnly ? 0.5 : 1);
const nations = use(NATIONS);
const filters = useTankopediaPersistent((state) => state.filters);
const sort = useTankopediaPersistent((state) => state.sort);
const awaitedTanks = use(tanksDefinitionsArray);
const awaitedModelDefinitions = use(modelDefinitions);
const [loaded, setLoaded] = useState(false);
const defaultSortedTanks = useMemo(
() =>
awaitedTanks
@@ -414,13 +401,6 @@ export function TankSearch({
page * tanksPerPage,
(page + 1) * tanksPerPage,
);
const firstChunk = searchResultsPageSlice.slice(
0,
Math.ceil(searchResultsPageSlice.length / 2),
);
const secondChunk = searchResultsPageSlice.slice(
Math.ceil(searchResultsPageSlice.length / 2),
);
useEffect(() => {
mutateTankopediaPersistent((draft) => {
@@ -431,6 +411,18 @@ export function TankSearch({
});
}, [searchResults]);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded) {
return (
<Flex justify="center">
<Spinner />
</Flex>
);
}
return (
<>
<Flex direction="column" gap="4">
@@ -472,332 +464,7 @@ export function TankSearch({
)}
</TextField.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Meta</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.tier'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.tier';
});
}}
>
Tier
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.name'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.name';
});
}}
>
Name
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dpm'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dpm';
});
}}
>
DPM
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.reload'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.reload';
});
}}
>
Reload
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.caliber'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.caliber';
});
}}
>
Caliber
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.standardPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.standardPenetration';
});
}}
>
Standard penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.premiumPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.premiumPenetration';
});
}}
>
Premium penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.damage'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.damage';
});
}}
>
Damage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.shellVelocity'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.shellVelocity';
});
}}
>
Shell velocity
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.aimTime'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.aimTime';
});
}}
>
Aim time
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionStill';
});
}}
>
Dispersion still
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionMoving';
});
}}
>
Dispersion moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunDepression'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunDepression';
});
}}
>
Gun depression
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunElevation'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunElevation';
});
}}
>
Gun elevation
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
Maneuverability
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.forwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.forwardsSpeed';
});
}}
>
Forwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.backwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.backwardsSpeed';
});
}}
>
Backwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.power'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.power';
});
}}
>
Power
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.powerToWeight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.powerToWeight';
});
}}
>
Power to weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.weight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.weight';
});
}}
>
Weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.traverseSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.traverseSpeed';
});
}}
>
Traverse speed
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Survivability</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.health'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.health';
});
}}
>
Health
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.viewRange'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.viewRange';
});
}}
>
View range
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageStill';
});
}}
>
Camouflage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageMoving';
});
}}
>
Camouflage moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageShooting'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageShooting';
});
}}
>
Camouflage shooting
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.volume'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.volume';
});
}}
>
Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Sort />
<Button
color="red"
@@ -873,148 +540,11 @@ export function TankSearch({
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)}
<Flex
wrap="wrap"
gap="2"
style={{
justifyContent:
compact && firstChunk.length > 0 && secondChunk.length > 0
? 'space-around'
: 'center',
}}
>
{!compact &&
searchResultsPageSlice.map((tank) => (
<Link
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
minWidth: 256,
}}
>
<Card
key={tank.id}
className={styles.listing}
style={{
cursor: 'pointer',
height: 64,
width: '100%',
}}
>
<Inset
key={tank.id}
style={{
minHeight: 64,
position: 'relative',
display: 'flex',
}}
>
<img
alt={tank.name}
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
alt={tank.name}
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
alt={tank.name}
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Link>
))}
{compact && firstChunk.length > 0 && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)}
{compact && secondChunk.length > 0 && (
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)}
</Flex>
<Results
compact={compact}
results={searchResultsPageSlice}
onSelect={onSelect}
/>
{searchResultsPageSlice.length > tanksPerPage / 4 && (
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
@@ -13,6 +13,7 @@ import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
import { useDuel } from '../../../stores/duel';
import { useTankopediaPersistent } from '../../../stores/tankopedia';
import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
import { ShotDisplay } from './ShotDisplay';
export const CoreArmorScene = memo(() => {
const wrapper = useRef<Group>(null);
@@ -135,86 +136,94 @@ export const CoreArmorScene = memo(() => {
).applyAxisAngle(I_HAT, Math.PI / 2);
return (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
<>
<ShotDisplay />
if (!isVisible || spaced || thickness === undefined) return null;
return (
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={turretContainer}>
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
tankModelDefinition.armor,
armorId,
);
if (!isVisible || spaced || thickness === undefined) return null;
return (
<group key={node.uuid} position={turretOrigin}>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={gunContainer}>
<group ref={turretContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
turretModelDefinition.armor,
armorId,
);
if (!isVisible || spaced || thickness === undefined) return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<CoreArmorSceneComponent thickness={thickness} node={node} />
<group key={node.uuid} position={turretOrigin}>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (!isVisible || spaced || thickness === undefined)
return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<CoreArmorSceneComponent
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
</group>
</group>
</group>
</group>
</>
);
});
@@ -5,6 +5,7 @@ import { degToRad } from 'three/src/math/MathUtils';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../core/blitzkit/tankDefinitions';
@@ -60,7 +61,9 @@ export function CoreArmorSceneComponent({
useEffect(() => {
async function handleShellChange(shell: ShellDefinition) {
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
@@ -96,25 +99,17 @@ export function CoreArmorSceneComponent({
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = useDuel.getState();
const penetration = resolveNearPenetration(
duel.antagonist!.shell.penetration,
);
const shell = duel.antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells
? penetration *
(duel.antagonist!.shell.type === 'ap'
? 1.08
: duel.antagonist!.shell.type === 'ap_cr'
? 1.05
: duel.antagonist!.shell.type === 'hc'
? 1.13
: 1.08)
: penetration;
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(useDuel.getState().antagonist!.shell);
+337 -265
View File
@@ -1,27 +1,18 @@
import { Card, Flex, Text } from '@radix-ui/themes';
import { Card, Flex, Inset, Separator, Text } from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useEffect, useRef } from 'react';
import { Mesh } from 'three';
import { ComponentProps, useRef } from 'react';
import { Euler, Mesh, Quaternion } from 'three';
import { radToDeg } from 'three/src/math/MathUtils';
import { canSplash } from '../../../core/blitz/canSplash';
import { isExplosive } from '../../../core/blitz/isExplosive';
import { NaNFallback } from '../../../core/math/NaNFallback';
import { normalToEuler } from '../../../core/math/normalToEuler';
import { AngledPenetration } from '../../../icons/AngledPenetration';
import { Block } from '../../../icons/Block';
import { NominalPenetration } from '../../../icons/NominalPenetration';
import { Ricochet } from '../../../icons/Ricochet';
import { useDuel } from '../../../stores/duel';
import { J_HAT } from '../../../constants/axis';
import {
ShotLayer,
ShotLayerBase,
ShotLayerGap,
ShotLayerNonExternal,
mutateTankopediaTemporary,
ShotStatus,
useTankopediaTemporary,
} from '../../../stores/tankopedia';
import { ArmorType } from './SpacedArmorScene';
import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
const layerTypeNames: Record<ArmorType | 'null', string> = {
[ArmorType.Core]: 'Core',
@@ -30,297 +21,378 @@ const layerTypeNames: Record<ArmorType | 'null', string> = {
null: 'Gap',
};
const externalLayerNames: Record<ExternalModuleVariant, string> = {
gun: 'Gun',
track: 'Tracks',
};
const shotStatusNames: Record<ShotStatus, string> = {
blocked: 'Blocked',
ricochet: 'Ricochet',
penetration: 'penetration',
splash: 'splash',
};
const shotStatusColors: Record<
ShotStatus,
ComponentProps<typeof Text>['color']
> = {
blocked: 'red',
penetration: 'green',
ricochet: 'yellow',
splash: 'orange',
};
const SURFACE_POINT_SIZE = 12;
const LENGTH_INFINITY = 4;
const TRACER_THICK = 1 / 64;
const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2;
function LayerEntry({
layerIndex,
shotStatusColor,
layerName,
layer,
angle,
}: {
layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color'];
layerName: string;
layer: ShotLayer;
angle?: number;
}) {
return (
<Flex align="center" gap="2">
<Text
size="1"
color="gray"
style={{
width: 12,
}}
>
{layerIndex}
</Text>
<Text size="2" color={shotStatusColor} style={{ width: 47 }}>
{layerName}
</Text>
{layer.type === null && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.distance * 1000).toLocaleString()}
<Text size="1">mm</Text>
</Text>
<Text size="2" color="gray">
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text>
</Text>
)}
{(layer.type === ArmorType.Core || layer.type === ArmorType.Spaced) && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 80,
}}
>
{Math.round(layer.thicknessAngled).toLocaleString()}
<Text size="1">{`mm${angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`}`}</Text>
</Text>
<Text size="2" color="gray">
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text> nominal
</Text>
</>
)}
</Flex>
);
}
export function ShotDisplay() {
const shot = useTankopediaTemporary((state) => state.shot);
const preMidPointTracer = useRef<Mesh>(null);
const postMidPointTracer = useRef<Mesh>(null);
const hasMultipleLayers = shot?.layers.some(
(layer, index) => index !== 0 && layer.type !== null,
);
const { shell } = useDuel.getState().antagonist!;
const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const midPointLayer = (shot?.layers.findLast(
(layer) => layer.type !== null && layer.status === 'ricochet',
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
const firstLayer = shot?.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
const lastLayer = shot?.layers.at(-1)!;
const distanceFromSpacedArmor = shot
? firstLayer.point.distanceTo(shot.point)
: undefined;
const preMidPointLayers = shot?.layers.slice(
0,
shot.layers.indexOf(midPointLayer as ShotLayer),
);
const postMidPointLayers = shot?.layers.slice(
shot.layers.indexOf(midPointLayer as ShotLayer),
shot.layers.length,
);
const preMidPointGap = shot
? preMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) + LENGTH_INFINITY
: undefined;
const preMidPointThickness = shot
? preMidPointLayers!.reduce(
(accumulator, layer) =>
accumulator +
(layer.type === null
? 0
: layer.type === ArmorType.External
? layer.thickness
: layer.thicknessAngled),
0,
)
: undefined;
const postMidPointGap = shot
? postMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) || LENGTH_INFINITY
: undefined;
const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
useFrame(({ clock }) => {
if (!shot || !preMidPointTracer.current) return;
if (!shot || !inTracer.current) return;
const t1 = clock.elapsedTime % 1;
const t2 = (clock.elapsedTime + 0.5) % 1;
preMidPointTracer.current.position.set(0, (1 - t1) * preMidPointGap!, 0);
preMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
inTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
inTracer.current.position.set(0, (1 - t1) * inLength, 0);
if (!postMidPointTracer.current) return;
if (!outTracer.current) return;
postMidPointTracer.current.position.set(0, t2 * postMidPointGap!, 0);
postMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
});
useEffect(() => {
function resetShot() {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}
const unsubscribes = [
useDuel.subscribe((state) => state.protagonist?.tank, resetShot),
useDuel.subscribe((state) => state.antagonist?.tank, resetShot),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
outTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
outTracer.current.position.set(0, t2 * outLength, 0);
});
if (!shot) return null;
const inLength =
shot.in.layers.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) + LENGTH_INFINITY;
const outLength =
shot.out && shot.out.layers.length > 0
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
(shot.out.layers.at(-1) as ShotLayerNonExternal).point,
)
: LENGTH_INFINITY;
const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null,
) as ShotLayerNonExternal;
const outTitleColor = shot.out
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
return (
<>
<group
position={midPointLayer.point}
rotation={normalToEuler(midPointLayer.shellNormal)}
<group>
<Html
position={inLast.point}
style={{
pointerEvents: 'none',
transform: 'translateY(-50%)',
}}
>
<mesh position={[0, preMidPointGap! / 2, 0]}>
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
<meshBasicMaterial depthTest={false} />
<Card ml="9" style={{ width: 300 }}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold">
{shot.in.status !== 'blocked' &&
shot.in.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.in.status]} `}
</Text>
<Flex direction="column">
{shot.in.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
const layerIndex =
layer.type === null
? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor =
shot.in.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Core ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</Flex>
{shot.out && shot.out.layers.length > 0 && (
<>
<Inset side="x">
<Flex align="center" gap="2">
<Separator
style={{
flex: 1,
}}
/>
<Text size="1" color="gray">
ricochet (-25% penetration)
</Text>
<Separator
style={{
flex: 1,
}}
/>
</Flex>
</Inset>
<Text color={outTitleColor} weight="bold">
{shot.out.status !== 'blocked' &&
shot.out.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.out.status]} `}
</Text>
<Flex direction="column">
{shot.out.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
const shiftedIndex = index + shot.out!.layers.length;
const layerIndex =
layer.type === null
? undefined
: `${(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) + 1}.`;
const shotStatusColor =
shot.out!.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Core ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</>
)}
</Flex>
</Card>
</Html>
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
if (layer.type === null) return null;
const shotStatusColor =
shot.in.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<Html
position={layer.point}
key={index}
style={{
pointerEvents: 'none',
transform: 'translateY(-50%)',
}}
>
<Flex
style={{
marginLeft: -SURFACE_POINT_SIZE / 2,
}}
>
<div
style={{
width: SURFACE_POINT_SIZE,
height: SURFACE_POINT_SIZE,
backgroundColor: shotStatusColor,
opacity: 0.75,
borderRadius: SURFACE_POINT_SIZE / 2,
}}
/>
</Flex>
</Html>
);
})}
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
)}
>
<mesh position={[0, inLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
/>
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
<mesh ref={inTracer} position={[0, inLength / 2, 0]} renderOrder={3}>
<cylinderGeometry
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
/>
<meshBasicMaterial
color={
lastLayer.status === 'blocked'
? 0xff4040
: lastLayer.status === 'penetration'
? 0x00ff00
: 0xffff00
}
color={inTitleColor}
depthTest={false}
transparent
/>
</mesh>
</group>
{midPointLayer.status === 'ricochet' && (
{shot.out && (
<group
position={midPointLayer.point}
rotation={normalToEuler(
midPointLayer.shellNormal
.clone()
.reflect(midPointLayer.surfaceNormal)
.multiplyScalar(-1),
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(
J_HAT,
shot.out.surfaceNormal.clone(),
),
)}
>
<mesh position={[0, postMidPointGap! / 2, 0]}>
<mesh position={[0, outLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN, TRACER_THIN, postMidPointGap]}
args={[TRACER_THIN / 2, TRACER_THIN / 2, outLength]}
/>
<meshBasicMaterial depthTest={false} />
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh
position={[0, postMidPointGap! / 2, 0]}
ref={postMidPointTracer}
ref={outTracer}
position={[0, outLength / 2, 0]}
renderOrder={3}
>
<cylinderGeometry
args={[TRACER_THICK, TRACER_THICK, postMidPointGap]}
args={[TRACER_THICK / 2, TRACER_THICK / 2, outLength]}
/>
<meshBasicMaterial
color={outTitleColor}
depthTest={false}
transparent
/>
<meshBasicMaterial color={0xffff00} />
</mesh>
</group>
)}
{shot.layers.map((layer, index) => {
if (layer.type === null) return;
const shellRotation = normalToEuler(layer.shellNormal);
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
const trueStatus =
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
return (
<>
<group
key={`${index}-line`}
position={layer.point}
rotation={shellRotation}
>
<Html
center
style={{
pointerEvents: 'none',
}}
>
<div
style={{
backgroundColor:
trueStatus === 'penetration'
? '#00ff0080'
: trueStatus === 'blocked'
? '#ff000080'
: '#ffff0080',
borderRadius: 8,
border: 'none',
padding: 4,
width: hasMultipleLayers ? undefined : 8,
height: hasMultipleLayers ? undefined : 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{hasMultipleLayers && (
<Text
size="2"
style={{
width: '100%',
display: 'block',
textAlign: 'center',
color: trueStatus === 'ricochet' ? 'black' : undefined,
}}
>
<b>{layer.index + 1}</b>
</Text>
)}
</div>
</Html>
</group>
</>
);
})}
<Html
position={midPointLayer.point}
style={{
transform: 'translateY(-50%)',
marginLeft: 32,
pointerEvents: 'none',
}}
>
<Card style={{ width: 256 + 32 }}>
<Flex direction="column">
<Text>
{lastLayer.status[0].toUpperCase()}
{lastLayer.status.slice(1)}
{lastLayer.status === 'penetration' &&
`: ${Math.round(shell.damage.armor)}hp`}
{lastLayer.status === 'blocked' &&
shell.type === 'he' &&
`: ${NaNFallback(
Math.max(
0,
Math.round(
0.5 *
shell.damage.armor *
(1 -
distanceFromSpacedArmor! / shell.explosionRadius!) -
1.1 *
((midPointLayer as ShotLayerNonExternal)
.thicknessAngled +
preMidPointThickness!),
),
),
0,
)}hp`}
</Text>
{shot.layers.map((layer, index) => {
if (layer.type === null && (!explosive || splashing)) return null;
return (
<Flex key={index} align="center" gap="2">
<Flex>
{hasMultipleLayers && (
<Text size="2" style={{ width: 16 }}>
{layer.type === null ? null : `${layer.index + 1}.`}
</Text>
)}
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
</Flex>
{layer.type !== null && (
<>
<Flex align="center" style={{ width: 64 }}>
<NominalPenetration width={16} height={16} />
<Text size="2">{Math.round(layer.thickness)}mm</Text>
</Flex>
{layer.type !== ArmorType.External && (
<Flex align="center">
{layer.status === 'ricochet' ? (
<Ricochet width={16} height={16} />
) : layer.status === 'blocked' ? (
<Block width={16} height={16} />
) : (
<AngledPenetration width={16} height={16} />
)}
<Text size="2">
{Math.round(layer.thicknessAngled)}mm (
{Math.round(radToDeg(layer.angle))}°)
</Text>
</Flex>
)}
</>
)}
{layer.type === null && (
<Text size="2">
{Math.round(layer.distance * 1000)}mm (-
{Math.round(Math.min(100, layer.distance * 50))}%
penetration)
</Text>
)}
</Flex>
);
})}
</Flex>
</Card>
</Html>
</>
</group>
);
}
@@ -259,9 +259,10 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
</group>
{modelNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const gunString = `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString)
: node.name === gunString;
const isVisible = isCurrentGun;
if (!isVisible) return null;
@@ -8,6 +8,7 @@ import {
} from 'three';
import { ArmorUserData, ExternalModuleVariant } from '../..';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
@@ -66,24 +67,17 @@ export function SpacedArmorSubExternal({
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = useDuel.getState();
const penetration = resolveNearPenetration(
duel.antagonist!.shell.penetration,
);
const shell = duel.antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells
? penetration *
(duel.antagonist!.shell.type === 'ap'
? 1.08
: duel.antagonist!.shell.type === 'ap_cr'
? 1.05
: duel.antagonist!.shell.type === 'hc'
? 1.13
: 1.08)
: penetration;
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(useDuel.getState().antagonist!.shell);
@@ -7,7 +7,9 @@ import {
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { ArmorUserData } from '../..';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
@@ -53,7 +55,9 @@ export function SpacedArmorSubSpaced({
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
@@ -80,16 +84,10 @@ export function SpacedArmorSubSpaced({
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells
? penetration *
(duel.antagonist!.shell.type === 'ap'
? 1.08
: duel.antagonist!.shell.type === 'ap_cr'
? 1.05
: duel.antagonist!.shell.type === 'hc'
? 1.13
: 1.08)
: penetration;
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(useDuel.getState().antagonist!.shell);
@@ -10,16 +10,17 @@ import {
Vector3,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { ShellType } from '../../../../core/blitzkit/tankDefinitions';
import { useDuel } from '../../../../stores/duel';
import {
Shot,
ShotLayerBase,
useTankopediaPersistent,
ShotLayerNonExternal,
useTankopediaTemporary,
} from '../../../../stores/tankopedia';
import { ArmorType } from '../SpacedArmorScene';
@@ -78,6 +79,275 @@ export function SpacedArmorSceneComponent({
}: SpacedArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
async function shoot(
point: Vector3,
intersections: Intersection[],
allowRicochet: boolean,
remainingPenetrationInput?: number,
) {
const { antagonist, protagonist } = useDuel.getState();
const shell = antagonist!.shell;
const cameraNormal = camera.position.clone().sub(point).normalize();
const shot: Shot = {
damage: -1,
containsGaps: shell.type === ShellType.HEAT,
in: {
surfaceNormal: cameraNormal,
layers: [],
status: 'blocked',
},
};
const hasCalibratedShells = await hasEquipment(
103,
antagonist!.tank.equipment,
antagonist!.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
110,
protagonist!.tank.equipment,
protagonist!.equipmentMatrix,
);
const penetration =
resolveNearPenetration(shell.penetration) *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = intersections.filter(
(intersection) => 'type' in intersection.object.userData,
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = [];
for (const intersection of filteredIntersections) {
if (intersection.object.userData.type === ArmorType.External) {
if (
!encounteredExternalModuleVariants.includes(
intersection.object.userData.variant,
)
) {
noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push(
intersection.object.userData.variant,
);
}
} else {
noDuplicateIntersections.push(intersection);
if (intersection.object.userData.type === ArmorType.Core) {
break;
}
}
}
let remainingPenetration = remainingPenetrationInput ?? penetration;
let index = 0;
for (const intersection of noDuplicateIntersections) {
const layer = intersection.object.userData;
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()),
);
if (shell.type === ShellType.HEAT && index > 0) {
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
const distance = lastLayer.point.distanceTo(intersection.point);
remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
type: null,
distance,
status: blocked ? 'blocked' : 'penetration',
});
if (blocked) break;
}
if (layer.type === ArmorType.External) {
const thickness = layer.thickness * thicknessCoefficient;
remainingPenetration -= thickness;
const blocked =
remainingPenetration <= 0 || shell.type === ShellType.HE;
shot.in.layers.push({
type: ArmorType.External,
index,
thickness,
point: intersection.point,
surfaceNormal,
status:
shell.type === ShellType.HE
? 'blocked'
: blocked
? 'blocked'
: 'penetration',
variant: layer.variant,
});
} else {
const thickness = layer.thickness;
const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
const angle = surfaceNormal.angleTo(cameraNormal);
const threeCalibersRule =
shell.caliber > thickness * 3 || index > 0 || !allowRicochet;
const twoCalibersRule = shell.caliber > thickness * 2;
const normalization = degToRad(shell.normalization ?? 0);
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
: normalization;
const finalThickness =
thickness / Math.cos(Math.max(0, angle - finalNormalization));
if (!threeCalibersRule && angle >= ricochet) {
remainingPenetration *= 0.75;
shot.in.layers.push({
type: layer.type,
index,
thickness,
thicknessAngled: finalThickness,
point: intersection.point,
surfaceNormal,
status: 'ricochet',
angle,
});
break;
} else {
remainingPenetration -= finalThickness;
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
type: layer.type,
index,
thickness,
thicknessAngled: finalThickness,
point: intersection.point,
surfaceNormal: intersection.face!.normal,
status:
shell.type === ShellType.HE && layer.type !== ArmorType.Core
? 'blocked'
: blocked
? 'blocked'
: 'penetration',
angle,
});
}
}
if (shell.type !== ShellType.HE && remainingPenetration <= 0) {
break;
}
index++;
}
const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal | undefined;
const firstLayer = shot.in.layers[0] as ShotLayerBase | undefined;
if (!lastLayer || !firstLayer) {
return null;
} else {
if (shell.type === ShellType.HE) {
const totalSpacedArmorThickness = shot.in.layers.reduce(
(acc, layer) => {
if (layer.type === null || layer.type === ArmorType.Core) {
return acc;
}
if (layer.type === ArmorType.External) {
return acc + layer.thickness;
}
return acc + layer.thicknessAngled;
},
0,
);
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
lastLayer.point,
);
const finalDamage = Math.max(
0,
0.5 *
shell.damage.armor *
(1 - distanceFromSpacedArmor / shell.explosionRadius!) -
1.1 *
(lastLayer.thicknessAngled +
Math.min(penetration, totalSpacedArmorThickness)),
);
/**
* Above is some peculiar math. We're multiplying 1.1 by the
* thickness of the core plate plus the total spaced armor
* thickness capped to the penetration itself. This is because
* the shader is normalized by the penetration value; hence,
* the total spaced armor thickness cannot ever be thicker
* than the penetration. This is a precision error on the
* shader-side and I do not intend on fixing it. Instead,
* for parity's sake, I am capping the value here.
*/
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
shot.damage = finalDamage;
} else {
shot.in.status = 'penetration';
shot.damage = shell.damage.armor;
}
} else {
if (lastLayer.status === 'blocked') {
shot.in.status = 'blocked';
shot.damage = 0;
} else if (lastLayer.status === 'penetration') {
shot.in.status = 'penetration';
shot.damage = shell.damage.armor;
} else {
const caster = new Raycaster();
// https://math.stackexchange.com/a/13263/1222875
const shellNormal = cameraNormal.clone().multiplyScalar(-1);
const ricochetNormal = shellNormal
.clone()
.sub(
lastLayer.surfaceNormal
.clone()
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)),
);
caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects(
scene.children,
true,
);
const outShot = await shoot(
lastLayer.point,
ricochetIntersections,
false,
remainingPenetration,
);
shot.in.status = 'ricochet';
if (outShot === null) {
shot.damage = 0;
shot.out = {
surfaceNormal: ricochetNormal,
layers: [],
status: 'ricochet',
};
} else {
shot.damage = outShot.damage;
shot.out = outShot.in;
shot.out.surfaceNormal = ricochetNormal;
}
}
}
}
return shot;
}
return (
<>
{props.type === ArmorType.Core &&
@@ -94,250 +364,11 @@ export function SpacedArmorSceneComponent({
async onClick(event) {
event.stopPropagation();
const duel = useDuel.getState();
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
duel.antagonist!.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const shell = useDuel.getState().antagonist!.shell;
const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const penetration =
resolveNearPenetration(shell.penetration) *
(hasCalibratedShells
? duel.antagonist!.shell.type === 'ap'
? 1.08
: duel.antagonist!.shell.type === 'ap_cr'
? 1.05
: duel.antagonist!.shell.type === 'hc'
? 1.13
: 1.08
: 1);
const shot: Shot = {
layers: [],
point: event.point,
}; // TODO: type this
let remainingPenetration = penetration;
const shellNormal = camera.position
.clone()
.sub(event.point)
.normalize();
pushLogs(
shellNormal,
filterIntersections(event.intersections),
0,
);
function filterIntersections(intersectionsRaw: Intersection[]) {
const showSpacedArmor =
useTankopediaPersistent.getState().model.visual
.showSpacedArmor;
const intersectionsTyped = intersectionsRaw.filter(
({ object }) => object.userData.type !== undefined,
) as ArmorMeshIntersection[];
const intersectionsFiltered = intersectionsTyped.filter(
({ object }) =>
showSpacedArmor
? true
: object.userData.type === ArmorType.Core,
);
const intersectionsWithDuplicates = intersectionsFiltered.slice(
0,
intersectionsFiltered.findIndex(
({ object }) => object.userData.type === ArmorType.Core,
) + 1,
);
const firstGunIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === ArmorType.External &&
object.userData.variant === 'gun',
);
const firstTrackIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === ArmorType.External &&
object.userData.variant === 'track',
);
let intersections = intersectionsWithDuplicates;
if (firstGunIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === ArmorType.External &&
object.userData.variant === 'gun'
? index === firstGunIntersectionIndex
: true;
});
}
if (firstTrackIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === ArmorType.External &&
object.userData.variant === 'track'
? index === firstTrackIntersectionIndex
: true;
});
}
return intersections;
}
function pushLogs(
shellNormal: Vector3,
intersections: ArmorMeshIntersection[],
startingIndex: number,
) {
let layerIndex = startingIndex;
let loopIndex = 0;
if (layerIndex !== 0 && intersections.length > 0) {
const distance = (
shot.layers.at(-1) as ShotLayerBase
).point.distanceTo(intersections[0].point);
if (explosive && !splashing)
remainingPenetration -=
0.5 * remainingPenetration * distance;
const blocked = remainingPenetration < 0;
shot.layers.push({
type: null,
status: blocked ? 'blocked' : 'penetration',
distance,
});
if (blocked) return;
}
for (const intersection of intersections) {
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()),
);
const thickness =
thicknessCoefficient *
intersection.object.userData.thickness;
if (layerIndex !== 0 && startingIndex === 0) {
const previousIntersection = intersections[layerIndex - 1];
const distance =
intersection.distance - previousIntersection.distance;
if (explosive && !splashing)
remainingPenetration -=
0.5 * remainingPenetration * distance;
const wasted = remainingPenetration < 0;
shot.layers.push({
type: null,
status: wasted ? 'blocked' : 'penetration',
distance,
});
if (wasted && !splashing) return;
}
if (
intersection.object.userData.type === ArmorType.External
) {
remainingPenetration -= thickness;
const blocked = remainingPenetration < 0;
shot.layers.push({
type: ArmorType.External,
index: layerIndex,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness: intersection.object.userData.thickness,
status: blocked ? 'blocked' : 'penetration',
});
if (splashing) {
remainingPenetration = Math.max(0, remainingPenetration);
} else if (blocked) {
break;
}
} else {
const type = intersection.object.userData.type;
const angle = surfaceNormal.angleTo(shellNormal);
const ricochet = degToRad(shell.ricochet ?? 90);
const normalization = degToRad(shell.normalization ?? 0);
const threeCalibersRule =
shell.caliber > thickness * 3 || layerIndex > 0;
const twoCalibersRule = shell.caliber > thickness * 2;
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) /
(2.0 * thickness)
: normalization;
const finalThickness =
thickness === 0
? 0
: thickness /
Math.cos(Math.max(0, angle - finalNormalization));
if (!threeCalibersRule && angle >= ricochet) {
shot.layers.push({
type,
index: layerIndex,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness,
thicknessAngled: finalThickness,
angle,
status: 'ricochet',
});
remainingPenetration *= 0.75;
const raycaster = new Raycaster();
const newShellNormal = shellNormal
.clone()
.reflect(surfaceNormal);
const newCameraNormal = newShellNormal
.clone()
.multiplyScalar(-1);
raycaster.set(intersection.point, newCameraNormal);
pushLogs(
newShellNormal,
filterIntersections(
raycaster.intersectObjects(scene.children, true),
),
layerIndex + 1,
);
break;
} else {
remainingPenetration -= finalThickness;
const blocked = remainingPenetration < 0;
shot.layers.push({
type,
index: layerIndex,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness,
thicknessAngled: finalThickness,
angle,
status: blocked ? 'blocked' : 'penetration',
});
}
}
layerIndex++;
loopIndex++;
}
}
const shot = (await shoot(
event.point,
event.intersections,
true,
))!;
useTankopediaTemporary.setState({ shot });
},
+2
View File
@@ -28,6 +28,8 @@ export function ConsumablesManager({
wrap="wrap"
style={{
maxWidth: 288,
borderRadius: 4,
overflow: 'hidden',
}}
>
{consumables.map((consumable, index) => {
+14
View File
@@ -0,0 +1,14 @@
import { style } from '@vanilla-extract/css';
export const containerInner = style({
justifyContent: 'space-between',
width: '100%',
maxWidth: 1024,
padding: '0 16px',
'@media': {
'screen and (max-width: 640px)': {
justifyContent: 'center',
},
},
});
+86 -53
View File
@@ -1,70 +1,103 @@
import { Card, Code, Flex, Link, Spinner, Text, Theme } from '@radix-ui/themes';
import { Suspense } from 'react';
import packageJSON from '../../../package.json';
import { useWideFormat } from '../../hooks/useWideFormat';
import { WoTBVersion } from './components/WoTBVersion';
'use client';
import { Flex, Heading, Text, Theme } from '@radix-ui/themes';
import packageJson from '../../../package.json';
import { Link } from '../Link';
export function Footer() {
const wideFormat = useWideFormat(640);
return (
<Theme radius="none">
<Card>
<Flex justify="center" align="center" gap="3">
<Flex
align="center"
justify={wideFormat ? 'between' : 'center'}
wrap="wrap"
style={{
width: '100%',
maxWidth: 800,
padding: '0 16px',
}}
>
<Flex gap="1" justify="center" align="center">
<Text color="gray" size="2">
BlitzKit
<Flex
justify="center"
gap="3"
p="6"
style={{
backgroundColor: 'var(--color-panel)',
}}
>
<Flex
align="center"
justify="between"
maxWidth="640px"
flexGrow="1"
gap="6"
position="relative"
direction={{
initial: 'column',
sm: 'row',
}}
>
<Flex direction="column">
<Heading>
BlitzKit{' '}
<Text color="gray" size="1" weight="regular">
{packageJson.version}
</Text>
</Heading>
<Code color="gray" size="2">
{packageJSON.version}
</Code>
<Text color="gray" size="2">
for WoTB
</Text>
<Suspense fallback={<Spinner />}>
<WoTBVersion />
</Suspense>
</Flex>
<Text color="gray">Everything World of Tanks Blitz</Text>
</Flex>
<Flex gap="6">
<Flex
justify="center"
align="center"
gap="2"
style={{
maxWidth: 800,
padding: '0 16px',
direction="column"
gap="1"
align={{
initial: 'end',
sm: 'start',
}}
>
<Link size="2" href="https://tresabhi.github.io/" target="_blank">
Made by TrèsAbhi
</Link>
<Text color="gray">•</Text>
<Link
size="2"
href="https://tresabhi.github.io/blitzkit/guide/credits.html"
target="_blank"
<Heading size="3">About</Heading>
<Flex
direction="column"
align={{
initial: 'end',
sm: 'start',
}}
>
Full credits
</Link>
<Link href="/docs/about/credits" color="gray">
Credits
</Link>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
color="gray"
>
Discord
</Link>
<Link
href="https://github.com/tresabhi/blitzkit"
target="_blank"
color="gray"
>
GitHub
</Link>
{/* patreon supporters? */}
</Flex>
</Flex>
<Flex direction="column" gap="1">
<Heading size="3">Legal</Heading>
<Flex direction="column">
<Link href="/docs/legal/privacy-policy" color="gray">
Privacy policy
</Link>
<Link href="/docs/legal/terms-of-service" color="gray">
Terms of service
</Link>
<Link
href="https://www.google.com/settings/ads"
target="_blank"
color="gray"
>
Ad settings
</Link>
</Flex>
</Flex>
</Flex>
</Flex>
</Card>
</Flex>
</Theme>
);
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { LinkProps, Link as RadixLink } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
export function Link({ href, onClick, ...props }: LinkProps) {
const router = useRouter();
return (
<RadixLink
href={href}
onClick={(event) => {
event.preventDefault();
if (href) router.push(href);
onClick?.(event);
}}
{...props}
/>
);
}
@@ -23,6 +23,11 @@ export function ConsumableButton({
return (
<GenericTankComponentButton
icon={asset(`icons/consumables/${consumable}.webp`)}
style={{
flex: 1,
minWidth: 'fit-content',
borderRadius: 0,
}}
{...props}
>
{(cooldown || duration) && (
+2 -2
View File
@@ -63,7 +63,7 @@ export function ModuleManager({
const draft = { ...modules };
draft.turret = turret;
draft.gun = draft.turret.guns.at(-1)!;
draft.shell = draft.gun.shells.at(-1)!;
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setTurretMenuOpen(false);
@@ -106,7 +106,7 @@ export function ModuleManager({
onClick={() => {
const draft = { ...modules };
draft.gun = gun;
draft.shell = draft.gun.shells.at(-1)!;
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setGunMenuOpen(false);
@@ -33,7 +33,7 @@ export function EverythingSearch({
width: '100%',
zIndex: 1,
}}
></Card>
/>
</TextField.Slot>
)}
</TextField.Root>
+54
View File
@@ -0,0 +1,54 @@
import { style, styleVariants } from '@vanilla-extract/css';
export const hamburgerButton = style({
'@media': {
'screen and (min-width: 592px)': {
display: 'none',
},
},
});
const navbarBase = style({
position: 'fixed',
top: 0,
left: 0,
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)`,
':hover': {
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
},
});
export const toolTexts = style({
'@media': {
'screen and (max-width: 592px)': {
display: 'none',
},
},
});
+180 -181
View File
@@ -1,230 +1,229 @@
import {
Cross1Icon,
DiscordLogoIcon,
GearIcon,
HamburgerMenuIcon,
PersonIcon,
} from '@radix-ui/react-icons';
import {
Box,
Button,
Dialog,
Flex,
Heading,
IconButton,
Link as LinkRadix,
Popover,
Spinner,
Text,
} from '@radix-ui/themes';
import Link from 'next/link';
import { Suspense, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { Suspense, useState } from 'react';
import { REGIONS, UNLOCALIZED_REGION_NAMES } from '../../constants/regions';
import { TOOLS } from '../../constants/tools';
import { authURL } from '../../core/blitz/authURL';
import { extendAuth } from '../../core/blitz/extendAuth';
import { logout } from '../../core/blitz/logout';
import { useWideFormat } from '../../hooks/useWideFormat';
import { BlitzkitWide } from '../../icons/BlitzkitWide';
import { PatreonIcon } from '../../icons/Patreon';
import { theme } from '../../stitches.config';
import { useApp } from '../../stores/app';
import { Link } from '../Link';
import { LoggedIn } from './components/LoggedIn';
import * as styles from './index.css';
export const NAVBAR_HEIGHT = 64;
export default function Navbar() {
const wideFormat = useWideFormat(480);
const login = useApp((state) => state.login);
useEffect(() => {
if (!login) return;
const expiresIn = login.expiresAt - Date.now() / 1000;
const expiresInDays = expiresIn / 60 / 60 / 24;
if (expiresInDays < 0) {
logout();
} else if (expiresInDays < 7) {
extendAuth();
}
});
const plugs = (
<>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DiscordLogoIcon width={16} height={16} />
</Link>
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link>
</>
);
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname();
const login = useApp((state) => state.login!);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: 64 + 1, // extra pixel for frost bleeding
zIndex: 2,
padding: '1rem',
background: theme.colors.appBackground1_alpha,
borderBottom: theme.borderStyles.nonInteractive,
backdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
}}
<Box
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']}
>
<Flex
justify="center"
style={{
height: '100%',
}}
>
<Flex direction="column" align="center" pt="2">
<Flex
justify="between"
align="center"
style={{ maxWidth: 800, width: '100%', padding: '0 16px' }}
gap="4"
style={{ maxWidth: 1024, width: '100%', padding: '0 16px' }}
gap="8"
>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<HamburgerMenuIcon />
</IconButton>
</Popover.Trigger>
<Flex align="center" gap="4">
<IconButton
className={styles.hamburgerButton}
size="4"
variant="ghost"
color="gray"
onClick={() => setShowHamburgerMenu((state) => !state)}
>
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<Popover.Content>
<Flex direction="column" gap="4">
{!wideFormat && <Flex gap="2">{plugs}</Flex>}
<Link
onClick={() => setShowHamburgerMenu(false)}
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BlitzkitWide />
</Link>
</Flex>
<Flex direction="column" gap="2">
<Heading size="4">Tools</Heading>
<Flex justify="center" gap="4" className={styles.toolTexts}>
{TOOLS.filter((tool) => !tool.href).map((tool) => {
const selected = pathname.startsWith(`/tools/${tool.id}`);
<Flex direction="column" gap="1" pl="4">
{TOOLS.map((tool, index) => {
if (tool.href || tool.disabled) return null;
return (
<Link
key={tool.id}
color="gray"
size="2"
highContrast={selected}
underline={selected ? 'always' : 'hover'}
href={`/tools/${tool.id}`}
>
{tool.title}
</Link>
);
})}
</Flex>
return (
<LinkRadix
key={tool.id}
tabIndex={-1}
href={`/tools/${tool.id}`}
onClick={(event) => event.preventDefault()}
>
<Link
href={`/tools/${tool.id}`}
key={index}
style={{
textDecoration: 'none',
color: 'inherit',
}}
>
{tool.title}
</Link>
</LinkRadix>
);
})}
</Flex>
</Flex>
</Flex>
</Popover.Content>
</Popover.Root>
<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
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BlitzkitWide />
</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>
<div style={{ flex: 1 }} />
{wideFormat && plugs}
<Link href="/settings">
<Flex style={{ width: '100%', height: '100%' }} justify="center">
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</Flex>
</Link>
{login && (
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<PersonIcon />
<Link href="/settings" onClick={() => setShowHamburgerMenu(false)}>
<Flex style={{ width: '100%', height: '100%' }} justify="center">
<IconButton size="4" variant="ghost" color="gray">
<GearIcon />
</IconButton>
</Popover.Trigger>
</Flex>
</Link>
<Popover.Content align="center">
<Flex direction="column" gap="2">
<Suspense fallback={<Spinner />}>
<LoggedIn />
</Suspense>
</Flex>
</Popover.Content>
</Popover.Root>
)}
{login && (
<Popover.Root>
<Popover.Trigger>
<IconButton size="4" variant="ghost" color="gray">
<PersonIcon />
</IconButton>
</Popover.Trigger>
{/* TODO: re-enable in the future */}
{!login && false && (
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">Log in</Button>
</Dialog.Trigger>
<Popover.Content align="center">
<Flex direction="column" gap="2">
<Suspense fallback={<Spinner />}>
<LoggedIn />
</Suspense>
</Flex>
</Popover.Content>
</Popover.Root>
)}
<Dialog.Content
{!login && false && (
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">Log in</Button>
</Dialog.Trigger>
<Dialog.Content
style={{
maxWidth: 360,
}}
>
<Flex direction="column" gap="2" align="center">
<Heading size="3">Choose your region</Heading>
<Flex gap="4" align="center" justify="center" wrap="wrap">
{REGIONS.map((region) => (
<Link
onClick={() => setShowHamburgerMenu(false)}
key={region}
href={authURL(
region,
typeof window === 'undefined'
? undefined
: location.href,
)}
>
{UNLOCALIZED_REGION_NAMES[region]}
</Link>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
</Flex>
<Flex mt="6" gap="3" justify="center" wrap="wrap">
{TOOLS.filter((tool) => !tool.href).map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.id}`}
className={styles.toolCard}
style={{
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
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={{
maxWidth: 360,
color: theme.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Flex direction="column" gap="2" align="center">
<Heading size="3">Choose your region</Heading>
<Flex gap="4" align="center" justify="center" wrap="wrap">
{REGIONS.map((region) => (
<LinkRadix
key={region}
href={authURL(
region,
typeof window === 'undefined'
? undefined
: location.href,
)}
>
{UNLOCALIZED_REGION_NAMES[region]}
</LinkRadix>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
{tool.title}
</Text>
</Link>
))}
</Flex>
</Flex>
</div>
</Box>
);
}
+1 -7
View File
@@ -1,13 +1,11 @@
import { Flex, FlexProps, Theme } from '@radix-ui/themes';
import { ComponentProps } from 'react';
import { theme } from '../stitches.config';
type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor'];
size?: number | string;
noPadding?: boolean;
noMaxWidth?: boolean;
highlight?: boolean;
containerProps?: ComponentProps<typeof Theme>;
noFlex1?: boolean;
};
@@ -20,23 +18,19 @@ export default function PageWrapper({
noPadding = false,
noMaxWidth = false,
noFlex1 = false,
highlight = false,
containerProps,
...props
}: PageWrapperProps) {
return (
<Theme
{...containerProps}
accentColor={color}
style={{
flex: noFlex1 ? undefined : 1,
display: 'flex',
flexDirection: 'column',
background: highlight
? `linear-gradient(-90deg, ${theme.colors.appBackground1}, ${theme.colors.appBackground2})`
: undefined,
...containerProps?.style,
}}
{...containerProps}
>
<Flex
direction="column"
+2
View File
@@ -1,3 +1,5 @@
'use client';
import { useEffect } from 'react';
export function Party3() {
@@ -1,10 +1,19 @@
import { Button } from '@radix-ui/themes';
import { ComponentProps } from 'react';
interface Tool {
id: string;
image: string;
title: string;
description: string;
disabled?: boolean;
href?: string;
pageDescription?: string;
button: {
text: string;
color: ComponentProps<typeof Button>['color'];
};
significant?: boolean;
}
export const TOOLS: Tool[] = [
@@ -15,6 +24,7 @@ export const TOOLS: Tool[] = [
// pageDescription:
// 'Periodical player statistics and tanks played in World of Tanks Blitz',
// disabled: true,
// image: '8gJGkKP'
// },
{
id: 'tankopedia',
@@ -22,38 +32,70 @@ export const TOOLS: Tool[] = [
description: 'Blitz tank encyclopedia',
pageDescription:
'Statistics, armor, and more for all tanks in World of Tanks Blitz',
image: 'D8j6GZx',
button: {
text: 'Find your tank',
color: 'purple',
},
significant: true,
},
{
id: 'rating',
title: 'Rating leaderboard',
description: 'Full rating gamemode leaderboard',
title: 'Rating',
description: 'Full rating leaderboard',
pageDescription:
'Live and archived full rating leaderboard for World of Tanks Blitz',
},
{
id: 'session',
title: 'Session tracker',
description: 'Live session performance tracker',
pageDescription:
'Track your stats in real time as you play World of Tanks Blitz',
image: 'WEyMZH3',
button: {
text: 'View leaderboard',
color: 'orange',
},
},
{
id: 'compare',
title: 'Compare',
description: 'Compare tank statistics and loadouts',
description: 'Compare tank statistics',
pageDescription:
'Compare tanks statistics and loadouts in World of Tanks Blitz',
image: 'O6VNl6e',
button: {
text: 'Compare tanks',
color: 'crimson',
},
},
{
// TODO: rename to tracker
id: 'session',
title: 'Session',
description: 'Live session stats',
pageDescription:
'Track your stats in real time as you play World of Tanks Blitz',
image: 'HdG9sTf',
button: {
text: 'Start tracking',
color: 'blue',
},
},
{
id: 'discord',
title: 'Discord bot',
description: 'Stats and tools right in Discord',
description: 'Stats right in Discord',
href: 'https://discord.com/application-directory/1097673957865443370',
image: '0bWE5hC',
button: {
text: 'Install now',
color: 'indigo',
},
},
{
id: 'more',
title: 'More coming soon',
description: 'This site is being actively developed',
description: "What we're making",
href: 'https://discord.gg/nDt7AjGJQH',
image: '1nPm6VI',
button: {
text: 'Join Discord',
color: 'indigo',
},
},
];
-7
View File
@@ -1,7 +0,0 @@
import { ShellDefinition } from '../blitzkit/tankDefinitions';
export function canRicochet(
type: ShellDefinition['type'],
): type is Exclude<ShellDefinition['type'], 'he'> {
return type !== 'he';
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { ShellDefinition } from '../blitzkit/tankDefinitions';
import { ShellDefinition, ShellType } from '../blitzkit/tankDefinitions';
export function canSplash(type: ShellDefinition['type']): type is 'he' {
return type === 'he';
export function canSplash(type: ShellDefinition['type']): type is ShellType.HE {
return type === ShellType.HE;
}
+12
View File
@@ -1,5 +1,17 @@
import { Region } from '../../constants/regions';
export const MIN_IDS: Record<Region, number> = {
asia: 20e8,
com: 10e8,
eu: 5e8,
};
export const MAX_IDS: Record<Region, number> = {
asia: 31e8 - 1,
com: MIN_IDS.asia - 1,
eu: MIN_IDS.com - 1,
};
export function idToRegion(id: number): Region {
if (id >= 42e8) {
throw new Error('bot id');
+3 -3
View File
@@ -1,7 +1,7 @@
import { ShellDefinition } from '../blitzkit/tankDefinitions';
import { ShellDefinition, ShellType } from '../blitzkit/tankDefinitions';
export function isExplosive(
type: ShellDefinition['type'],
): type is 'he' | 'hc' {
return type === 'he' || type === 'hc';
): type is ShellType.HEAT | ShellType.HE {
return type === ShellType.HEAT || type === ShellType.HE;
}
@@ -0,0 +1,16 @@
import { ShellType } from '../blitzkit/tankDefinitions';
export function resolvePenetrationCoefficient(
calibratedShells: boolean,
type: ShellType,
) {
if (!calibratedShells) return 1;
return type === 'ap'
? 1.08
: type === 'ap_cr'
? 1.05
: type === 'hc'
? 1.13
: 1.08;
}
@@ -0,0 +1,12 @@
import { AverageDefinitionsAllStats } from '.';
import { emptyAllStats } from '../../blitzstars/getStatsInPeriod/constants';
export const emptyAverageDefinitionsAllStats: AverageDefinitionsAllStats = {
...emptyAllStats,
battle_life_time: 0,
};
export const averageDefinitionsAllStatsKeys = Object.keys(
emptyAverageDefinitionsAllStats,
) as (keyof AverageDefinitionsAllStats)[];
@@ -0,0 +1,28 @@
import { AllStats } from '../../blitz/getAccountInfo';
import { asset } from '../asset';
import { fetchCdonLz4 } from '../fetchCdonLz4';
export interface AverageDefinitionsAllStats extends AllStats {
battle_life_time: number;
}
export interface AverageDefinitions {
[id: number]: {
samples: number;
// average
mu: AverageDefinitionsAllStats;
// standard deviation
sigma: AverageDefinitionsAllStats;
// correlation coefficient
r: AverageDefinitionsAllStats;
// least squares regression line slope
m: AverageDefinitionsAllStats;
// least squares regression line y-intercept
b: AverageDefinitionsAllStats;
};
}
export const averageDefinitions = fetchCdonLz4<AverageDefinitions>(
asset('definitions/averages.cdon.lz4'),
);
+50
View File
@@ -0,0 +1,50 @@
export enum ImgurSize {
/**
* The default size
*/
Default = '',
/**
* Unknown size but really really small
*/
UltraSmall = 'ss',
/**
* 90x90
*/
SmallSquare = 's',
/**
* 160x160
*/
BigSquare = 'b',
/**
* 160x160
*/
SmallThumbnail = 't',
/**
* 320x320
*/
MediumThumbnail = 'm',
/**
* 640x640
*/
LargeThumbnail = 'l',
/**
* 1024x1024
*/
HugeThumbnail = 'h',
}
interface ImgurOptions {
format: 'jpeg' | 'png';
size: ImgurSize;
}
const defaultImgurOptions: ImgurOptions = {
format: 'png',
size: ImgurSize.Default,
};
export function imgur(id: string, imgurOptions?: Partial<ImgurOptions>) {
const options = { ...defaultImgurOptions, ...imgurOptions };
return `https://i.imgur.com/${id}${options.size}.${options.format}`;
}
+15 -15
View File
@@ -1,5 +1,6 @@
import { EquipmentMatrix } from '../../stores/duel';
import { resolveNearPenetration } from '../blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../blitz/resolvePenetrationCoefficient';
import { coefficient } from './coefficient';
import { degressiveStat } from './degressiveStat';
import { EquipmentDefinitions } from './equipmentDefinitions';
@@ -151,13 +152,7 @@ export function tankCharacteristics(
coefficient([hasAdrenaline && gun.type === 'regular', 0.17]);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
shell.type === 'ap'
? 0.08
: shell.type === 'ap_cr'
? 0.05
: shell.type === 'hc'
? 0.13
: 0.08,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const healthCoefficient = coefficient(
[hasSandbagArmor, 0.03],
@@ -251,10 +246,9 @@ export function tankCharacteristics(
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const camouflageSumMoving = sum(
const camouflageCoefficientMoving = coefficient(
[
hasCamouflageNet,
tank.class === 'heavyTank'
? 0.02
: tank.class === 'mediumTank'
@@ -268,7 +262,7 @@ export function tankCharacteristics(
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
);
const camouflageSumStill = sum(
const camouflageCoefficientStill = coefficient(
[
hasCamouflageNet,
tank.class === 'heavyTank'
@@ -440,14 +434,20 @@ export function tankCharacteristics(
const health = (tank.health + turret.health) * healthCoefficient;
const fireChance = engine.fireChance * fireChanceCoefficient;
const viewRange = turret.viewRange * viewRangeCoefficient;
const camouflageStill = tank.camouflage.still + camouflageSumStill;
const camouflageMoving = tank.camouflage.moving + camouflageSumMoving;
const camouflageStill = tank.camouflage.still * camouflageCoefficientStill;
const camouflageMoving =
tank.class === 'lightTank'
? tank.camouflage.still * camouflageCoefficientStill
: tank.camouflage.moving * camouflageCoefficientMoving;
const camouflageShootingStill =
tank.camouflage.still * gun.camouflageLoss + camouflageSumStill;
tank.camouflage.still * gun.camouflageLoss * camouflageCoefficientStill;
const camouflageShootingMoving =
tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving;
(tank.class === 'lightTank'
? tank.camouflage.still * camouflageCoefficientStill
: tank.camouflage.moving * camouflageCoefficientMoving) *
gun.camouflageLoss;
const camouflageCaughtOnFire =
tank.camouflage.onFire * tank.camouflage.still + camouflageSumStill;
tank.camouflage.onFire * tank.camouflage.still * camouflageCoefficientStill;
const width = size[2];
const height = size[0];
const length = size[1];
+6 -1
View File
@@ -5,7 +5,12 @@ import { asset } from '../asset';
import { fetchCdonLz4 } from '../fetchCdonLz4';
import { TIERS } from './constants';
export type ShellType = 'ap' | 'ap_cr' | 'hc' | 'he';
export enum ShellType {
AP = 'ap',
APCR = 'ap_cr',
HEAT = 'hc',
HE = 'he',
}
export type CrewMember =
| 'commander'
+51
View File
@@ -0,0 +1,51 @@
import { times } from 'lodash';
import { ReadStream, WriteStream } from './buffer';
export class DidsReadStream extends ReadStream {
dids() {
this.magic();
this.header();
return this.body();
}
magic() {
const magic = this.utf8(4);
if (magic !== 'DIDS') {
throw new Error(`Invalid DIDS magic number: "${magic}"`);
}
}
header() {
return {
version: this.uint16(),
};
}
body() {
const count = this.uint32();
return times(count, () => this.uint32());
}
}
export class DidsWriteStream extends WriteStream {
dids(ids: number[]) {
this.magic();
this.header();
this.body(ids);
return this;
}
magic() {
this.utf8('DIDS');
}
header() {
this.uint16(1);
}
body(ids: number[]) {
this.uint32(ids.length);
ids.forEach((id) => this.uint32(id));
}
}
+3
View File
@@ -1,5 +1,8 @@
import { useEffect, useState } from 'react';
/**
* @deprecated DO NOT USE; CAUSES NEXT JS HYDRATION ISSUES
*/
export function useWideFormat(size = 880) {
function hasWideFormat() {
return typeof window !== 'undefined' && window.innerWidth > size;
+180
View File
@@ -0,0 +1,180 @@
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
fill-rule="evenodd"
clip-rule="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>
);
}
+4 -2
View File
@@ -12,16 +12,18 @@ interface Login {
export interface App {
devBuildAgreementTime: number;
developerMode: boolean;
darkMode: boolean;
login?: Login;
policiesAgreementIndex: number;
}
export const CURRENT_POLICIES_AGREEMENT_INDEX = 0;
export const useApp = create<App>()(
persist(
(set) => ({
devBuildAgreementTime: 0,
developerMode: false,
darkMode: true,
policiesAgreementIndex: -1,
}),
{ name: 'app', merge: (a, b) => merge(b, a) },
),
+17 -3
View File
@@ -5,6 +5,7 @@ import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { ENVIRONMENTS } from '../app/tools/tankopedia/[id]/components/Lighting';
import { ArmorType } from '../components/Armor/components/SpacedArmorScene';
import { ExternalModuleVariant } from '../components/Armor/components/SpacedArmorSceneComponent';
import { TankClass, TreeType } from '../components/Tanks';
import { Tier } from '../core/blitzkit/tankDefinitions';
@@ -50,13 +51,13 @@ export interface ShotLayerBase {
index: number;
thickness: number;
point: Vector3;
shellNormal: Vector3;
surfaceNormal: Vector3;
status: 'blocked' | 'penetration' | 'ricochet';
}
interface ShotLayerExternal extends ShotLayerBase {
type: ArmorType.External;
variant: ExternalModuleVariant;
}
export interface ShotLayerNonExternal extends ShotLayerBase {
@@ -74,10 +75,23 @@ export interface ShotLayerGap {
export type ShotLayer = ShotLayerExternal | ShotLayerNonExternal | ShotLayerGap;
export type Shot = {
point: Vector3;
layers: ShotLayer[];
containsGaps: boolean;
damage: number;
in: {
surfaceNormal: Vector3;
status: ShotStatus;
layers: ShotLayer[];
};
out?: {
surfaceNormal: Vector3;
status: ShotStatus;
layers: ShotLayer[];
};
};
export type ShotStatus = 'penetration' | 'blocked' | 'ricochet' | 'splash';
export type ArmorPiercingLayer =
| {
distance: number;