feat: search page

This commit is contained in:
TrèsAbhi committed 2024-09-21 20:26:58 -05:00
1 parent 408b561b75
commit ffa1395b50
70 files changed
+492 -468

No files matched your search

@@ -1,37 +0,0 @@
import { MinusCircledIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
import { ExperimentIcon } from '../../../../../components/ExperimentIcon';
import * as Duel from '../../../../../stores/duel';
export function CalloutsSection() {
const tank = Duel.use((state) => state.protagonist.tank);
if (!tank.testing && !tank.deprecated) return null;
return (
<Flex direction="column" align="center" gap="2" mb="4">
{tank.deprecated && (
<Callout.Root color="red">
<Callout.Icon>
<MinusCircledIcon />
</Callout.Icon>
<Callout.Text>
This tank is deprecated and may display undefined behavior.
</Callout.Text>
</Callout.Root>
)}
{tank.testing && (
<Callout.Root color="red">
<Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon>
<Callout.Text>
Tanks in testing are subject to change and may not represent the
final product.
</Callout.Text>
</Callout.Root>
)}
</Flex>
);
}
@@ -2,19 +2,19 @@ import { TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react';
import { CalloutsSection } from '../../../../../../../website/src/components/Tankopedia/CalloutsSection';
import { CharacteristicsSection } from '../../../../../../../website/src/components/Tankopedia/CharacteristicsSection';
import { GameModeSection } from '../../../../../../../website/src/components/Tankopedia/GameModeSection';
import { HeroSection } from '../../../../../../../website/src/components/Tankopedia/HeroSection';
import { HistorySection } from '../../../../../../../website/src/components/Tankopedia/HistorySection';
import { MetaSection } from '../../../../../../../website/src/components/Tankopedia/MetaSection';
import { ShotDisplaySection } from '../../../../../../../website/src/components/Tankopedia/ShotDisplaySection';
import { TechTreeSection } from '../../../../../../../website/src/components/Tankopedia/TechTreeSection';
import { AdMidSectionResponsive } from '../../../../../components/AdMidSectionResponsive';
import { PageWrapper } from '../../../../../components/PageWrapper';
import { useAdExempt } from '../../../../../hooks/useAdExempt';
import * as Duel from '../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../stores/tankopediaEphemeral';
import { CalloutsSection } from './CalloutsSection';
import { HistorySection } from './HistorySection';
import { CharacteristicsSection } from './Model/CharacteristicsSection';
import { GameModeSection } from './Model/GameModeSection';
import { HeroSection } from './Model/HeroSection';
import { MetaSection } from './Model/MetaSection';
import { TechTreeSection } from './Model/TechTreeSection';
import { ShotDisplaySection } from './ShotDisplaySection';
import { VideoSection } from './VideoSection';
export function Content() {
@@ -1,16 +0,0 @@
import { Heading, Text } from '@radix-ui/themes';
import { PageWrapper } from '../../../../../components/PageWrapper';
import * as Duel from '../../../../../stores/duel';
export function HistorySection() {
const tank = Duel.use((state) => state.protagonist.tank);
if (!tank.description) return null;
return (
<PageWrapper noFlex1 align="center">
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</PageWrapper>
);
}
@@ -1,49 +0,0 @@
import { Flex, Heading } from '@radix-ui/themes';
import { Characteristics } from '../Characteristics';
import { Consumables } from '../Characteristics/components/Consumables';
import { Equipment } from '../Characteristics/components/Equipment';
import { Miscellaneous } from '../Characteristics/components/Miscellaneous';
import { Modules } from '../Characteristics/components/Modules';
import { Provisions } from '../Characteristics/components/Provisions';
import { Skills } from '../Characteristics/components/Skills';
export function CharacteristicsSection() {
return (
<Flex mt="4" px="4" style={{ boxSizing: 'border-box' }} gap="6">
<Flex
flexGrow="1"
gap="9"
justify="center"
align={{ initial: 'center', sm: 'start' }}
direction={{ initial: 'column', sm: 'row' }}
>
<Flex
direction="column"
gap="6"
maxWidth="21rem"
align={{ initial: 'center', sm: 'start' }}
>
<Heading>Configuration</Heading>
<Modules />
<Equipment />
<Provisions />
<Consumables />
<Skills />
<Miscellaneous />
</Flex>
<Flex
flexGrow="1"
maxWidth={{ initial: '20rem', md: '40rem' }}
direction="column"
gap="6"
width="100%"
>
<Heading align={{ initial: 'center', sm: 'left' }}>
Characteristics
</Heading>
<Characteristics />
</Flex>
</Flex>
</Flex>
);
}
@@ -1,121 +0,0 @@
import {
asset,
consumableDefinitions,
gameDefinitions,
provisionDefinitions,
} from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { use } from 'react';
import * as Duel from '../../../../../../stores/duel';
export function GameModeSection() {
const tank = Duel.use((state) => state.protagonist.tank);
const awaitedGameDefinitions = use(gameDefinitions);
const awaitedConsumableDefinitions = use(consumableDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const roles = Object.entries(tank.roles);
if (roles.length === 0) return null;
return (
<Flex direction="column" gap="4" align="center">
<Heading size="6">Game mode abilities</Heading>
<Flex justify="center" gap="4" wrap="wrap" px="4">
{roles.map(([key, value]) => {
const id = Number(key);
const gameMode = awaitedGameDefinitions.gameModes[id];
return (
<Flex
key={id}
width="16rem"
height="20rem"
overflow="hidden"
direction="column"
style={{
borderRadius: 'var(--radius-3)',
background: `url(${asset(
`icons/game_mode_banners/${id}.webp`,
)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<Box
flexGrow="1"
style={{
background: `url(${asset(
`icons/game_mode_banners/${id}.webp`,
)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
p="3"
direction="column"
height="98px"
flexShrink="0"
style={{
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
}}
align="center"
justify="center"
>
<Text size="4" weight="bold">
{gameMode.name}
</Text>
<Flex direction="column">
{awaitedGameDefinitions.roles[value].provisions.map((id) => {
const provisions = awaitedProvisionDefinitions[id];
return (
<Text color="gray" wrap="nowrap" key={id}>
<Flex align="center" gap="1">
<img
style={{
width: '1.5em',
height: '1.5em',
objectFit: 'contain',
}}
src={asset(`icons/provisions/${id}.webp`)}
alt={provisions.name}
/>
{provisions.name}
</Flex>
</Text>
);
})}
{awaitedGameDefinitions.roles[value].consumables.map((id) => {
const consumable = awaitedConsumableDefinitions[id];
return (
<Text color="gray" wrap="nowrap" key={id}>
<Flex align="center" gap="1">
<img
style={{
width: '1.5em',
height: '1.5em',
objectFit: 'contain',
}}
src={asset(`icons/consumables/${id}.webp`)}
alt={consumable.name}
/>
{consumable.name}
</Flex>
</Text>
);
})}
</Flex>
</Flex>
</Flex>
);
})}
</Flex>
</Flex>
);
}
@@ -1,134 +0,0 @@
import { resolveNearPenetration, tankDefinitions } from '@blitzkit/core';
import { Box, Flex, Heading, Text } from '@radix-ui/themes';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { ThicknessRange } from '../../../../../../components/Armor/components/StaticArmor';
import { classIcons } from '../../../../../../components/ClassIcon';
import { NAVBAR_HEIGHT } from '../../../../../../components/Navbar/index.css';
import { Var } from '../../../../../../core/radix/var';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import * as Duel from '../../../../../../stores/duel';
import { TankSandbox } from './TankSandbox';
import { TankSandboxLoader } from './TankSandboxLoader';
import { Options } from './components/Options';
export function HeroSection() {
const canvas = useRef<HTMLCanvasElement>(null);
const isFullScreen = useFullScreen();
const protagonist = Duel.use((state) => state.protagonist.tank);
const Icon = classIcons[protagonist.class];
const [dummyLoader, setDummyLoader] = useState(true);
const treeColor =
protagonist.treeType === 'collector'
? 'blue'
: protagonist.treeType === 'premium'
? 'amber'
: undefined;
const awaitedTankDefinitions = use(tankDefinitions);
const thicknessRange = useMemo(() => {
const entries = Object.values(awaitedTankDefinitions);
const filtered = entries.filter(
(thisTank) => thisTank.tier === protagonist.tier,
);
const value =
(filtered.reduce((accumulator, thisTank) => {
return (
accumulator +
resolveNearPenetration(
thisTank.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
)
);
}, 0) /
filtered.length) *
(3 / 4);
return { value } satisfies ThicknessRange;
}, [protagonist]);
useEffect(() => {
setDummyLoader(false);
}, []);
return (
<Flex justify="center" style={{ backgroundColor: Var('color-surface') }}>
<Flex
direction={{ initial: 'column', md: 'row' }}
style={{
backgroundColor: isFullScreen
? Var('color-background')
: Var('color-surface'),
zIndex: isFullScreen ? 2 : undefined,
}}
height={
isFullScreen ? '100vh' : `calc(100vh - ${NAVBAR_HEIGHT}px - 8rem)`
}
maxHeight={isFullScreen ? undefined : '40rem'}
maxWidth={isFullScreen ? undefined : '120rem'}
flexGrow="1"
width={isFullScreen ? '100vw' : undefined}
position={isFullScreen ? 'fixed' : 'relative'}
top={isFullScreen ? '0' : undefined}
left={isFullScreen ? '0' : undefined}
>
<Flex
style={{ userSelect: 'none' }}
direction="column"
align={{ initial: 'center', md: 'start' }}
justify="center"
pl={{ initial: '0', md: '9' }}
pt={{ initial: '5', md: '0' }}
>
<Heading
weight="bold"
size={{ initial: '8', lg: '9' }}
wrap="nowrap"
color={treeColor}
>
<Flex align="center" gap="3">
<Icon width="0.8em" height="0.8em" />
{protagonist.name}
</Flex>
</Heading>
<Text
color="gray"
size="3"
weight="light"
ml={{ initial: '0', md: '9' }}
>
BlitzKit Tankopedia
</Text>
</Flex>
<Box
className="tank-sandbox-container"
flexGrow="1"
flexBasis="0"
flexShrink="0"
position="relative"
>
<Box position="absolute" width="100%" height="100%">
<Box width="100%" height="100%">
<TankSandboxLoader
id={protagonist.id}
display={dummyLoader ? 'flex' : 'none'}
/>
<Suspense
fallback={
<TankSandboxLoader
id={protagonist.id}
display={dummyLoader ? 'none' : 'flex'}
/>
}
>
<TankSandbox ref={canvas} thicknessRange={thicknessRange} />
</Suspense>
</Box>
<Options canvas={canvas} thicknessRange={thicknessRange} />
</Box>
</Box>
</Flex>
</Flex>
);
}
@@ -1,14 +0,0 @@
import { Flex, Text, TextProps } from '@radix-ui/themes';
type ListingProps = TextProps & {
label: string;
};
export function Listing({ label, ...props }: ListingProps) {
return (
<Flex minWidth="15rem" justify="between" gap="4">
<Text color="gray">{label}</Text>
<Text align="right" {...props} />
</Flex>
);
}
@@ -1,25 +0,0 @@
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
import { Box, BoxProps } from '@radix-ui/themes';
type PartialStarProps = BoxProps & {
fill: number;
};
export function PartialStar({ fill, ...props }: PartialStarProps) {
return (
<Box height="1em" width="1em" position="relative" {...props}>
<StarIcon width="1em" height="1em" />
{fill > 0 && (
<Box
top="0"
left="0"
width={`${fill * 100}%`}
position="absolute"
overflow="hidden"
>
<StarFilledIcon width="1em" height="1em" />
</Box>
)}
</Box>
);
}
@@ -1,44 +0,0 @@
import { StarsInt } from '@blitzkit/core';
import { Flex, Text, TextProps } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { PartialStar } from './components/PartialStar';
type StarsProps = TextProps & {
stars: number | null;
onCast?: (value: StarsInt) => void;
};
export function Stars({ stars, onCast, ...props }: StarsProps) {
let color: TextProps['color'] = 'green';
if (stars === null) color = 'gray';
else if (stars < 1.5) color = 'tomato';
else if (stars < 2.5) color = 'orange';
else if (stars < 3.5) color = 'amber';
else if (stars < 4.5) color = 'blue';
return (
<Text color={color} {...props}>
<Flex style={{ cursor: onCast ? 'pointer' : 'unset' }}>
{stars !== null &&
times(Math.floor(stars), (index) => (
<PartialStar
fill={1}
key={index}
onClick={() => onCast?.((index + 1) as StarsInt)}
/>
))}
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
{times(5 - Math.ceil(stars ?? 0), (index) => (
<PartialStar
fill={0}
key={index}
onClick={() =>
onCast?.((Math.floor(stars ?? 0) + index + 1) as StarsInt)
}
/>
))}
</Flex>
</Text>
);
}
@@ -1,18 +0,0 @@
import { Flex, Text } from '@radix-ui/themes';
import { Stars } from '../../Stars';
interface StarRowProps {
stars: number | null;
children: string;
}
export function StarRow({ stars, children }: StarRowProps) {
return (
<Text color={stars === null ? 'gray' : undefined}>
<Flex align="center" justify="between" width="15rem">
{children}
<Stars stars={stars} />
</Flex>
</Text>
);
}
@@ -1,178 +0,0 @@
import { BlitzkitResponse, StarsInt } from '@blitzkit/core';
import { PaperPlaneIcon } from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
Dialog,
Flex,
Spinner,
Text,
} from '@radix-ui/themes';
import { useState } from 'react';
import * as App from '../../../../../../../../../../stores/app';
import * as Duel from '../../../../../../../../../../stores/duel';
import { Stars } from '../../Stars';
export function VoteCaster() {
const tank = Duel.use((state) => state.protagonist.tank);
const [easiness, setEasiness] = useState<StarsInt | null>(null);
const [firepower, setFirepower] = useState<StarsInt | null>(null);
const [maneuverability, setManeuverability] = useState<StarsInt | null>(null);
const [survivability, setSurvivability] = useState<StarsInt | null>(null);
const appStore = App.useStore();
// true = vote successful, false = vote failed, null = hide alert
const [postVote, setPostVote] = useState<boolean | null>(null);
// true = open, false = closed, null = casting
const [showDialogue, setShowDialogue] = useState<boolean | null>(false);
return (
<>
<Dialog.Root open={showDialogue !== false} onOpenChange={setShowDialogue}>
<Dialog.Trigger>
<Button variant="surface">
<PaperPlaneIcon /> Vote
</Button>
</Dialog.Trigger>
<Dialog.Content maxWidth="23rem">
<Dialog.Title>Voting for {tank.name}</Dialog.Title>
<Flex direction="column" mt="4">
<Flex align="center" justify="between">
<Text>
Easiness{' '}
<Text color="gray" size="2">
{easiness === null ? '(empty)' : `(${easiness})`}
</Text>
</Text>
<Stars stars={easiness} size="5" onCast={setEasiness} />
</Flex>
<Flex align="center" justify="between">
<Text>
Firepower{' '}
<Text color="gray" size="2">
{firepower === null ? '(empty)' : `(${firepower})`}
</Text>
</Text>
<Stars stars={firepower} size="5" onCast={setFirepower} />
</Flex>
<Flex align="center" justify="between">
<Text>
Maneuverability{' '}
<Text color="gray" size="2">
{maneuverability === null
? '(empty)'
: `(${maneuverability})`}
</Text>
</Text>
<Stars
stars={maneuverability}
size="5"
onCast={setManeuverability}
/>
</Flex>
<Flex align="center" justify="between">
<Text>
Survivability{' '}
<Text color="gray" size="2">
{survivability === null ? '(empty)' : `(${survivability})`}
</Text>
</Text>
<Stars stars={survivability} size="5" onCast={setSurvivability} />
</Flex>
</Flex>
<Flex justify="end" mt="4" gap="2">
<Dialog.Close>
<Button variant="outline" color="red">
Cancel
</Button>
</Dialog.Close>
<Button
disabled={
showDialogue === null ||
(easiness === null &&
firepower === null &&
maneuverability === null &&
survivability === null)
}
onClick={() => {
const wargaming = appStore.getState().logins.wargaming;
if (!wargaming) {
return setShowDialogue(false);
}
setShowDialogue(null);
fetch(
`/api/tank-voting/${tank.id}/cast?${
easiness === null ? '' : `easiness=${easiness}&`
}${firepower === null ? '' : `firepower=${firepower}&`}${
maneuverability === null
? ''
: `maneuverability=${maneuverability}&`
}${
survivability === null
? ''
: `survivability=${survivability}&`
}player=${wargaming.id}&token=${wargaming.token}`,
)
.then(
(response) => response.json() as Promise<BlitzkitResponse>,
)
.then((json) => {
setShowDialogue(false);
setPostVote(json.status === 'ok');
if (json.status === 'error') {
console.error('vote failed', json);
}
});
}}
>
{showDialogue === null ? <Spinner /> : <PaperPlaneIcon />} Vote
</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root
open={postVote !== null}
onOpenChange={() => setPostVote(null)}
>
{postVote === true && (
<AlertDialog.Content maxWidth="20rem">
<AlertDialog.Title>Vote casted</AlertDialog.Title>
<AlertDialog.Description>
It may take a few minutes for it to show up.
</AlertDialog.Description>
<Flex justify="end" mt="4">
<AlertDialog.Cancel>
<Button>Okay</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
)}
{postVote === false && (
<AlertDialog.Content maxWidth="20rem">
<AlertDialog.Title>Voting failed</AlertDialog.Title>
<AlertDialog.Description>
So sorry about this! If you're a nerd, feel free to check out the
console.
</AlertDialog.Description>
<Flex justify="end" mt="4">
<AlertDialog.Cancel>
<Button>Gotcha</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
)}
</AlertDialog.Root>
</>
);
}
@@ -1,92 +0,0 @@
import { formatCompact } from '@blitzkit/core';
import { Flex, Heading, Skeleton, Text } from '@radix-ui/themes';
import { memo } from 'react';
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
import { useTankVotes } from '../../../../../../../../../hooks/useTankVotes';
import * as App from '../../../../../../../../../stores/app';
import * as Duel from '../../../../../../../../../stores/duel';
import { StarRow } from './components/StarRow';
import { VoteCaster } from './components/VoteCaster';
export const Votes = memo(
() => {
const id = Duel.use((state) => state.protagonist.tank.id);
const votes = useTankVotes(id);
const wargaming = App.useDeferred(
undefined,
(state) => state.logins.wargaming,
);
return (
<Flex direction="column" gap="3">
<Flex justify="between" align="center">
<Heading size="4">
{!votes && <Skeleton height="1em" width="4rem" />}
{votes && formatCompact(votes.votes)}{' '}
{votes && (votes.votes > 1 ? 'votes' : 'vote')}
</Heading>
{!wargaming && (
<WargamingLoginButton variant="surface">
Login to vote
</WargamingLoginButton>
)}
{wargaming && <VoteCaster />}
</Flex>
<Flex gap="0" direction="column">
<Flex
gap={{ initial: '0', sm: '6' }}
direction={{ initial: 'column', sm: 'row' }}
>
<StarRow
stars={votes?.categories.easiness ?? null}
children="Easiness"
/>
<StarRow
stars={votes?.categories.firepower ?? null}
children="Firepower"
/>
</Flex>
<Flex
gap={{ initial: '0', sm: '6' }}
direction={{ initial: 'column', sm: 'row' }}
>
<StarRow
stars={votes?.categories.maneuverability ?? null}
children="Maneuverability"
/>
<StarRow
stars={votes?.categories.survivability ?? null}
children="Survivability"
/>
</Flex>
<Flex justify="center">
<Text size="2" color="gray" mt="2">
<>
{votes ? (
wargaming ? (
votes.last_updated ? (
`You voted on ${new Date(
votes.last_updated,
).toLocaleDateString()}`
) : (
`You haven't voted yet`
)
) : (
'Login to vote'
)
) : (
<Skeleton height="1em" width="5rem" />
)}
</>
</Text>
</Flex>
</Flex>
</Flex>
);
},
() => true,
);
@@ -1,215 +0,0 @@
import {
assertSecret,
asset,
modelDefinitions,
provisionDefinitions,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { ChevronLeftIcon, MixIcon, UpdateIcon } from '@radix-ui/react-icons';
import { Button, Code, Dialog, Flex } from '@radix-ui/themes';
import { use, useState } from 'react';
import { classIcons } from '../../../../../../../components/ClassIcon';
import { Link } from '../../../../../../../components/Link';
import { ScienceIcon } from '../../../../../../../components/ScienceIcon';
import { tankToDuelMember } from '../../../../../../../core/blitzkit/tankToDuelMember';
import * as App from '../../../../../../../stores/app';
import * as Duel from '../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
import { TankSearch } from '../../../../components/TankSearch';
import { Listing } from './components/Listing';
export function MetaSection() {
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const protagonist = Duel.use((state) => state.protagonist.tank);
const ClassIcon = classIcons[protagonist.class];
const antagonist = Duel.use((state) => state.antagonist.tank);
const compareTanks =
protagonist.id === antagonist.id
? [protagonist.id]
: [protagonist.id, antagonist.id];
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const awaitedProvisionDefinitions = use(provisionDefinitions);
const [showSwapDialog, setShowSwapDialog] = useState(false);
const awaitedModelDefinitions = use(modelDefinitions);
return (
<Flex justify="center" align="center">
<Flex direction="column" align="center" gap="6">
<Flex gap="2" wrap="wrap" justify="center" align="center">
<Link href="/tools/tankopedia">
<Button variant="outline">
<ChevronLeftIcon />
Back
</Button>
</Link>
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
<Button variant="outline">
<MixIcon />
Compare
</Button>
</Link>
{assertSecret(process.env.PUBLIC_PROMOTE_OPENTEST) === 'true' && (
<Link
href={`https://opentest.blitzkit.app/tools/tankopedia/${protagonist.id}`}
>
<Button variant="outline" color="green">
<ScienceIcon height="1.25em" width="1.25em" />
OpenTest
</Button>
</Link>
)}
<Dialog.Root open={showSwapDialog} onOpenChange={setShowSwapDialog}>
<Dialog.Trigger>
<Button>
<UpdateIcon />
Swap
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Swap tanks</Dialog.Title>
<TankSearch
compact
onSelect={(tank) => {
setShowSwapDialog(false);
mutateTankopediaEphemeral((draft) => {
draft.model = awaitedModelDefinitions[tank.id];
});
mutateDuel((draft) => {
draft.protagonist = tankToDuelMember(
tank,
awaitedProvisionDefinitions,
);
});
window.history.replaceState(
null,
'',
`/tools/tankopedia/${tank.id}`,
);
}}
/>
</Dialog.Content>
</Dialog.Root>
</Flex>
<Flex
gap={{ initial: '0', sm: '6' }}
direction={{ initial: 'column', sm: 'row' }}
>
<Flex direction="column" width="100%">
{protagonist.nameFull && (
<Listing label="Full-name">{protagonist.nameFull}</Listing>
)}
<Listing label="Nation">
<Flex align="center" gap="1">
<img
style={{ width: '1em', height: '1em' }}
alt={protagonist.nation}
src={asset(`flags/circle/${protagonist.nation}.webp`)}
/>
{
strings.common.nations[
protagonist.nation as keyof typeof strings.common.nations
]
}
</Flex>
</Listing>
<Listing label="Class">
<Flex align="center" gap="1">
<ClassIcon width="1em" height="1em" />
{strings.common.tank_class_short[protagonist.class]}
</Flex>
</Listing>
<Listing label="Tier">{protagonist.tier}</Listing>
<Listing
label="Type"
color={
protagonist.treeType === 'collector'
? 'blue'
: protagonist.treeType === 'premium'
? 'amber'
: undefined
}
>
{strings.common.tree_type[protagonist.treeType]}
</Listing>
</Flex>
<Flex direction="column" width="100%">
{developerMode && (
<Listing label="DEV: ID">
<Code>{protagonist.id}</Code>
</Listing>
)}
{protagonist.treeType === 'premium' && (
<Listing label="Purchase price">
<Flex align="center" gap="1">
{protagonist.price.value / 400}
<img
style={{ width: '1em', height: '1em' }}
alt="gold"
src={asset('icons/currencies/gold.webp')}
/>
</Flex>
</Listing>
)}
<Listing
label={`${
protagonist.treeType === 'researchable'
? 'Purchase'
: 'Restoration'
} price`}
>
<Flex align="center" gap="1">
{protagonist.price.value.toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt={protagonist.price.type}
src={asset(
`icons/currencies/${
protagonist.price.type === 'gold' ? 'gold' : 'silver'
}.webp`,
)}
/>
</Flex>
</Listing>
<Listing label="Sale price">
<Flex align="center" gap="1">
{(protagonist.price.value / 2).toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt={protagonist.price.type}
src={asset(
`icons/currencies/${
protagonist.price.type === 'gold' ? 'gold' : 'silver'
}.webp`,
)}
/>
</Flex>
</Listing>
{protagonist.xp && (
<Listing label="Research XP">
<Flex align="center" gap="1">
{protagonist.xp.toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt="xp"
src={asset('icons/currencies/xp.webp')}
/>
</Flex>
</Listing>
)}
</Flex>
</Flex>
{/* <Votes /> */}
</Flex>
</Flex>
);
}
@@ -1,197 +0,0 @@
import { Canvas } from '@react-three/fiber';
import {
forwardRef,
Suspense,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { Armor } from '../../../../../../components/Armor';
import { ArmorPlateDisplay } from '../../../../../../components/Armor/components/ArmorPlateDisplay';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
import {
StaticArmor,
ThicknessRange,
} from '../../../../../../components/Armor/components/StaticArmor';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useOnScreen } from '../../../../../../hooks/useOnScreen';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../stores/tankopediaEphemeral';
import * as TankopediaPersistent from '../../../../../../stores/tankopediaPersistent';
import { TankopediaDisplay } from '../../../../../../stores/tankopediaPersistent/constants';
import { Controls } from '../Control';
import { Lighting } from '../Lighting';
import { SceneProps } from '../SceneProps';
import { AutoClear } from './AutoClear';
import { ModelLoader } from './components/ModelLoader';
import { TankModel } from './components/TankModel';
interface TankSandboxProps {
thicknessRange: ThicknessRange;
}
export const TankSandbox = forwardRef<HTMLCanvasElement, TankSandboxProps>(
({ thicknessRange }, ref) => {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const protagonist = Duel.use((state) => state.protagonist);
const mutateDuel = Duel.useMutation();
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const display = TankopediaPersistent.use((state) => state.display);
const onScreen = useOnScreen(canvas);
useImperativeHandle(ref, () => canvas.current!, []);
function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
event.preventDefault();
}
function handlePointerUp(event: PointerEvent) {
event.preventDefault();
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
}
useEffect(() => {
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
-Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.FaceHug: {
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
Infinity,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
case Pose.Default:
mutateDuel((draft) => {
const [pitch, yaw] = applyPitchYawLimits(
0,
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
draft.protagonist.pitch = pitch;
draft.protagonist.yaw = yaw;
});
break;
}
}
poseEvent.on(handlePoseEvent);
return () => {
poseEvent.off(handlePoseEvent);
};
});
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist.pitch, draft.protagonist.yaw] = applyPitchYawLimits(
draft.protagonist.pitch,
draft.protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
});
}, [protagonist.gun, protagonist.turret]);
useEffect(() => {
if (display !== TankopediaDisplay.DynamicArmor) {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}
if (display !== TankopediaDisplay.StaticArmor) {
mutateTankopediaEphemeral((draft) => {
draft.highlightArmor = undefined;
});
}
}, [display]);
return (
<Canvas
frameloop={onScreen ? 'demand' : 'never'}
gl={{
clippingPlanes: [],
localClippingEnabled: true,
preserveDrawingBuffer: true,
}}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
draft.highlightArmor = undefined;
});
}}
style={{ userSelect: 'none' }}
camera={{ fov: 25, far: 32 }}
>
<Controls />
<SceneProps />
{display !== TankopediaDisplay.StaticArmor && <TankModel />}
<ShotDisplay />
<ArmorPlateDisplay />
<AutoClear />
{/* idk why the shot display doesn't work without suspense here lol */}
<Suspense fallback={<ModelLoader />}>
{display === TankopediaDisplay.DynamicArmor && <Armor />}
{display === TankopediaDisplay.StaticArmor && (
<StaticArmor thicknessRange={thicknessRange} />
)}
<Lighting />
</Suspense>
</Canvas>
);
},
);
@@ -1,22 +0,0 @@
import { tankIcon } from '@blitzkit/core';
import { Flex, FlexProps, Progress } from '@radix-ui/themes';
import { useProgress } from '@react-three/drei';
type TankSandboxLoaderProps = Omit<FlexProps, 'id'> & {
id: number;
};
export function TankSandboxLoader({ id, ...props }: TankSandboxLoaderProps) {
const data = useProgress();
return (
<Flex width="100%" height="100%" align="center" justify="center" {...props}>
<img src={tankIcon(id)} style={{ height: '60%', filter: 'blur(4px)' }} />
<Progress
size="3"
value={data.progress}
style={{ position: 'absolute', width: 128 }}
/>
</Flex>
);
}
@@ -1,10 +0,0 @@
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Text } from '@radix-ui/themes';
export function Arrow() {
return (
<Text color="gray">
<CaretRightIcon />
</Text>
);
}
@@ -1,131 +0,0 @@
import {
asset,
averageDefinitions,
formatCompact,
tankDefinitions,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { Box, Flex, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { use } from 'react';
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
interface NodeProps {
id: number;
nextIds?: number[];
highlight?: boolean;
}
export function Node({ id, highlight, nextIds }: NodeProps) {
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
const awaitedTankDefinitions = use(tankDefinitions);
const tank = awaitedTankDefinitions[id];
const nextTanks = nextIds?.map((id) => awaitedTankDefinitions[id]);
const awaitedAverageDefinitions = use(averageDefinitions);
const thisTankXp =
tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier;
const nextTanksXp = nextTanks?.reduce(
(xp, tank) =>
xp +
(tank.xp === undefined || tank.tier === 1 ? 0 : tank.xp! / xpMultiplier),
0,
);
const averages = awaitedAverageDefinitions.averages[tank.id]?.mu;
const averageXp = averages ? averages.xp / averages.battles : undefined;
const games =
averages && nextTanks ? Math.round(nextTanksXp! / averageXp!) : undefined;
return (
<Link
href={`/tools/tankopedia/${id}`}
key={id}
style={{
textDecoration: 'none',
color: 'inherit',
position: 'relative',
}}
>
<Box
mx={highlight ? '2' : '0'}
style={{
background: highlight
? `url(${asset(`flags/fade_small/${tank.nation}.webp`)})`
: undefined,
backgroundPosition: 'center',
backgroundSize: 'cover',
boxShadow: highlight ? 'var(--shadow-4)' : undefined,
borderRadius: 'var(--radius-4)',
overflow: 'hidden',
}}
>
<Flex
direction="column"
align="center"
py="4"
px={highlight ? '4' : '2'}
style={{
backdropFilter: highlight ? 'brightness(0.25)' : undefined,
WebkitBackdropFilter: highlight ? 'brightness(0.25)' : undefined,
}}
>
<img
alt={tank.name}
src={asset(`icons/tanks/big/${id}.webp`)}
width={64}
height={64}
style={{
objectFit: 'contain',
}}
/>
<Flex direction="column" align="center">
<Flex gap="2" align="center">
<Text color="gray" size="1">
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
<Text wrap="nowrap">{tank.name}</Text>
</Flex>
<Flex gap="2" align="center">
<Text color="gray" size="1" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="XP"
src={asset('icons/currencies/xp.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{formatCompact(thisTankXp)}
</Flex>
</Text>
<Text color="gray" size="1" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset('icons/currencies/silver.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{formatCompact(tank.price.value)}
</Flex>
</Text>
</Flex>
{averages && nextTanks && (
<Text color="gray" size="1" mt="2">
{games!} {games === 1 ? 'battle' : 'battles'}
</Text>
)}
</Flex>
</Flex>
</Box>
</Link>
);
}
@@ -1,225 +0,0 @@
import { asset, imgur, tankDefinitions } from '@blitzkit/core';
import { CaretLeftIcon, CaretRightIcon, PlusIcon } from '@radix-ui/react-icons';
import { Flex, Heading, IconButton, ScrollArea, Text } from '@radix-ui/themes';
import { Fragment, use, useEffect, useMemo, useRef, useState } from 'react';
import * as Duel from '../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
import { Arrow } from './components/Arrow';
import { Node } from './components/Node';
type Line = number[];
export const XP_MULTIPLIERS = [1, 2, 3, 4, 5, 10];
export function TechTreeSection() {
const xpMultiplier = TankopediaEphemeral.use((state) => state.xpMultiplier);
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const master = Duel.use((state) => state.protagonist.tank);
const awaitedTankDefinitions = use(tankDefinitions);
const container = useRef<HTMLDivElement>(null);
const lines = useMemo(() => {
function extend(line: Line): Line[] {
const root = awaitedTankDefinitions[line.at(-1)!];
if (root.ancestors === undefined || root.tier === 1) {
return [line];
} else {
if (root.ancestors.length === 1 || root.tier === 2) {
line.push(
root.ancestors.find(
(ancestor) => !awaitedTankDefinitions[ancestor].deprecated,
) ?? root.ancestors[0],
);
return extend(line);
} else {
return root.ancestors
.map((ancestor) => {
const newLine = [...line, ancestor];
return extend(newLine);
})
.flat();
}
}
}
return extend([master.id]);
}, [master]);
const [lineIndex, setLineIndex] = useState(0);
const line = useMemo(
() => [...lines[lineIndex]].reverse(),
[master, lineIndex],
);
const totalXp = line.reduce(
(xp, id) =>
xp +
(awaitedTankDefinitions[id].xp === undefined ||
awaitedTankDefinitions[id].tier === 1
? 0
: awaitedTankDefinitions[id].xp),
0,
);
const totalCredits = line.reduce(
(credits, id) => credits + awaitedTankDefinitions[id].price.value,
0,
);
useEffect(() => {
if (!container.current) return;
container.current.scrollLeft = container.current.scrollWidth;
});
if (
master.treeType !== 'researchable' ||
master.ancestors === undefined ||
master.ancestors.length === 0
) {
return null;
}
return (
<Flex
direction="column"
align="center"
gap="0"
style={{ backgroundColor: 'var(--color-surface)' }}
py="6"
>
<Flex direction="column" align="center">
<Heading size="6">Tech tree</Heading>
{master.tier > 1 && (
<Flex gap="4" align="center">
<Text color="gray" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="XP"
src={asset('icons/currencies/xp.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{Math.round(totalXp / xpMultiplier).toLocaleString()}
</Flex>
</Text>
<Text color="gray" wrap="nowrap">
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset('icons/currencies/silver.webp')}
style={{
width: '1em',
height: '1em',
objectFit: 'contain',
objectPosition: 'center',
}}
/>
{totalCredits.toLocaleString()}
</Flex>
</Text>
</Flex>
)}
<Flex gap="1" mt="2">
{XP_MULTIPLIERS.map((multiplier) => {
const selected = xpMultiplier === multiplier;
return (
<Flex
key={multiplier}
style={{
background: `url(${imgur('7hDltb4')})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
opacity: selected ? 1 : 0.5,
cursor: 'pointer',
}}
onClick={() => {
mutateTankopediaEphemeral((draft) => {
draft.xpMultiplier = multiplier;
});
}}
width="2.5rem"
justify="center"
align="center"
pt="1"
pl="1"
>
<Text size="2">
x<b>{multiplier}</b>
</Text>
</Flex>
);
})}
</Flex>
</Flex>
<ScrollArea type="hover" scrollbars="horizontal" ref={container}>
<Flex align="center" gap="2" justify="center" p="4">
{line.map((id, index) => {
const last = index === line.length - 1;
const tank = awaitedTankDefinitions[id];
return (
<Fragment key={id}>
{index > 0 && <Arrow key={`${id}-arrow`} />}
<Node
key={`${id}-node`}
id={id}
nextIds={last ? tank.successors : [line[index + 1]]}
highlight={last}
/>
</Fragment>
);
})}
{master.tier < 10 && (
<>
<Arrow />
{master.successors!.map((id, index) => (
<Fragment key={id}>
{index > 0 && (
<Text color="gray" key={`${id}-plus`}>
<PlusIcon />
</Text>
)}
<Node key={`${id}-node}`} id={id} />
</Fragment>
))}
</>
)}
</Flex>
</ScrollArea>
<Flex>
{lines.length > 1 && (
<Flex align="center" gap="2">
<IconButton
size="2"
variant="soft"
onClick={() =>
setLineIndex((lines.length + lineIndex - 1) % lines.length)
}
>
<CaretLeftIcon />
</IconButton>
<Text>
Route {lineIndex + 1} of {lines.length}
</Text>
<IconButton
size="2"
variant="soft"
onClick={() => setLineIndex((lineIndex + 1) % lines.length)}
>
<CaretRightIcon />
</IconButton>
</Flex>
)}
</Flex>
</Flex>
);
}
@@ -1,53 +0,0 @@
import { asset } from '@blitzkit/core';
import { IconButton } from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber';
import { useArmor } from '../../../../../../../../../hooks/useArmor';
import * as Duel from '../../../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../../../stores/tankopediaEphemeral';
export function DynamicArmorSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank);
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const isDynamicArmorActive = Duel.use((state) =>
state.protagonist.consumables.includes(73),
);
const { hasDynamicArmor } = useArmor(tank.id);
if (!hasDynamicArmor) return null;
return (
<IconButton
color={isDynamicArmorActive ? undefined : 'gray'}
variant="soft"
size={{ initial: '2', sm: '3' }}
onClick={() => {
invalidate();
mutateDuel((draft) => {
if (draft.protagonist.consumables.includes(73)) {
draft.protagonist.consumables =
draft.protagonist.consumables.filter((id) => id !== 73);
} else {
if (draft.protagonist.consumables.length === tank.consumables) {
draft.protagonist.consumables[tank.consumables - 1] = 73;
} else {
draft.protagonist.consumables.push(73);
}
}
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Dynamic Armor"
src={asset('icons/consumables/73.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
);
}
@@ -1,555 +0,0 @@
import { asset, imgur, resolveNearPenetration, resolvePenetrationCoefficient, TIER_ROMAN_NUMERALS, uniqueGuns } from '@blitzkit/core';
import {
CameraIcon,
CopyIcon,
DownloadIcon,
EnterFullScreenIcon,
ExitFullScreenIcon,
EyeOpenIcon,
GearIcon,
} from '@radix-ui/react-icons';
import {
Button,
Dialog,
DropdownMenu,
Flex,
IconButton,
Popover,
SegmentedControl,
Tabs,
Text,
Tooltip,
} from '@radix-ui/themes';
import { invalidate } from '@react-three/fiber';
import { RefObject, useEffect, useState } from 'react';
import { useEquipment } from '../../../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../../../hooks/useFullScreen';
import { useFullscreenAvailability } from '../../../../../../../../hooks/useFullscreenAvailability';
import * as App from '../../../../../../../../stores/app';
import * as Duel from '../../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../../stores/tankopediaEphemeral';
import * as TankopediaPersistent from '../../../../../../../../stores/tankopediaPersistent';
import { TankopediaDisplay } from '../../../../../../../../stores/tankopediaPersistent/constants';
import { TankSearch } from '../../../../../components/TankSearch';
import { ENVIRONMENTS } from '../../../Lighting';
import { QuickInputs } from '../../../QuickInputs';
import { Thicknesses } from '../../../Thicknesses';
import { DynamicArmorSwitcher } from './components/DynamicArmorSwitcher';
import { ThicknessRange } from '../../../../../../../../components/Armor/components/StaticArmor';
import { ModuleButton } from '../../../../../../../../components/ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../../../../../components/SmallTankIcon';
import { poseEvent, Pose } from '../../../../../../../../core/blitzkit/pose';
interface OptionsProps {
thicknessRange: ThicknessRange;
canvas: RefObject<HTMLCanvasElement>;
}
export function Options({ thicknessRange, canvas }: OptionsProps) {
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const displayRaw = TankopediaPersistent.use((state) => state.display);
const [display, setDisplay] = useState<TankopediaDisplay>(
TankopediaDisplay.Model,
);
const isFullScreen = useFullScreen();
const showGrid = TankopediaPersistent.use(
(state) => state.model.visual.showGrid,
);
const showEnvironment = TankopediaPersistent.use(
(state) => state.model.visual.showEnvironment,
);
const greenPenetration = TankopediaPersistent.use(
(state) => state.model.visual.greenPenetration,
);
const wireframe = TankopediaPersistent.use(
(state) => state.model.visual.wireframe,
);
const opaque = TankopediaPersistent.use((state) => state.model.visual.opaque);
const fullScreenAvailable = useFullscreenAvailability();
const environment = TankopediaPersistent.use(
(state) => state.model.visual.environment,
);
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const protagonistTank = Duel.use((state) => state.protagonist.tank);
const antagonistGun = Duel.use((state) => state.antagonist.gun);
const antagonistShell = Duel.use((state) => state.antagonist.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
const antagonistTank = Duel.use((state) => state.antagonist.tank);
const hasCalibratedShells = useEquipment(103, true);
const [tab, setTab] = useState('search');
const mutateDuel = Duel.useMutation();
const hasEnhancedArmor = useEquipment(110);
const antagonistUniqueGuns = uniqueGuns(antagonistTank.turrets);
useEffect(() => setDisplay(displayRaw), [displayRaw]);
return (
<>
<QuickInputs />
{display === TankopediaDisplay.StaticArmor && (
<Thicknesses thicknessRange={thicknessRange} />
)}
{display === TankopediaDisplay.DynamicArmor && (
<Flex
gap="2"
direction="column"
top="50%"
right="3"
style={{ position: 'absolute', transform: 'translateY(-50%)' }}
align="end"
>
<Text color="gray" size={{ initial: '1', sm: '2' }}>
{(
resolvePenetrationCoefficient(
hasCalibratedShells,
antagonistShell.type,
) * resolveNearPenetration(antagonistShell.penetration)
).toFixed(0)}
mm
</Text>
{antagonistUniqueGuns.length > 1 && (
<Popover.Root>
<Popover.Trigger>
<IconButton
radius="full"
variant="soft"
color="gray"
size={{ initial: '2', sm: '3' }}
style={{ position: 'relative' }}
>
<Text
size="1"
color="gray"
style={{
position: 'absolute',
right: 0,
bottom: 0,
}}
mr="2"
mb="1"
>
{TIER_ROMAN_NUMERALS[antagonistGun.tier]}
</Text>
<img
alt="Antagonist Gun"
src={asset('icons/modules/gun.webp')}
style={{
width: '65%',
height: '65%',
objectFit: 'contain',
}}
/>
</IconButton>
</Popover.Trigger>
<Popover.Content side="left" align="center">
<Flex gap="2">
{[...antagonistUniqueGuns]
.reverse()
.map(({ gun, turret }, index) => (
<ModuleButton
module="gun"
key={gun.id}
onClick={() =>
mutateDuel((draft) => {
draft.antagonist.turret = turret;
draft.antagonist.gun = gun;
draft.antagonist.shell = gun.shells[0];
})
}
selected={gun.id === antagonistGun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>
))}
</Flex>
</Popover.Content>
</Popover.Root>
)}
<Flex
direction="column"
style={{
borderRadius: 'var(--radius-full)',
}}
overflow="hidden"
>
{antagonistGun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === antagonistShell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
size={{ initial: '2', sm: '3' }}
radius="none"
style={{
marginTop: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.antagonist.shell = thisShell;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt={thisShell.name}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
))}
</Flex>
<Flex
direction="column"
style={{
pointerEvents: 'auto',
borderRadius: 'var(--radius-full)',
}}
overflow="hidden"
>
<IconButton
color={hasCalibratedShells ? undefined : 'gray'}
variant="soft"
size={{ initial: '2', sm: '3' }}
radius="none"
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.antagonist.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Calibrated Shells"
src={asset('icons/equipment/103.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
<IconButton
color={hasEnhancedArmor ? undefined : 'gray'}
variant="soft"
size={{ initial: '2', sm: '3' }}
style={{ marginTop: -1 }}
radius="none"
onClick={() => {
invalidate();
mutateDuel((draft) => {
draft.protagonist.equipmentMatrix[1][1] = hasEnhancedArmor
? 0
: -1;
});
mutateTankopediaEphemeral((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Enhanced Armor"
src={asset('icons/equipment/110.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
</Flex>
<DynamicArmorSwitcher />
</Flex>
)}
<Flex
direction="column"
align="center"
position="absolute"
bottom="4"
left="50%"
style={{ transform: 'translateX(-50%)' }}
>
{display === TankopediaDisplay.DynamicArmor && (
<Flex align="center" gap="2">
<Text color="gray" size="2">
Shooter:
</Text>
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorOpen}
>
<Dialog.Trigger>
<Button variant="ghost">
<Flex gap="2" align="center">
<SmallTankIcon id={antagonistTank.id} size={16} />
{antagonistTank.name}
</Flex>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title align="center">Select tank</Dialog.Title>
<Tabs.Root
value={tab}
onValueChange={setTab}
style={{ position: 'relative' }}
>
<TankSearch
compact
onSelect={(tank) => {
mutateDuel((draft) => {
draft.antagonist.tank = tank;
draft.antagonist.engine = tank.engines.at(-1)!;
draft.antagonist.track = tank.tracks.at(-1)!;
draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
draft.antagonist.turret.guns.at(-1)!;
draft.antagonist.shell = draft.antagonist.gun.shells[0];
});
setAntagonistSelectorOpen(false);
}}
/>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
</Flex>
)}
<Flex gap="3" align="center" mt="2">
<SegmentedControl.Root
value={`${display}`}
onValueChange={(value) => {
mutateTankopediaPersistent((draft) => {
draft.display = Number(value);
});
}}
>
<SegmentedControl.Item value={`${TankopediaDisplay.Model}`}>
<Tooltip content="Model">
<Flex height="100%" align="center">
<img src={imgur('jAdYf0m')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.DynamicArmor}`}>
<Tooltip content="Dynamic armor">
<Flex height="100%" align="center">
<img src={imgur('oe4Cq0g')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
<SegmentedControl.Item value={`${TankopediaDisplay.StaticArmor}`}>
<Tooltip content="Static armor">
<Flex height="100%" align="center">
<img src={imgur('VQ4uDno')} style={{ height: '1.25em' }} />
</Flex>
</Tooltip>
</SegmentedControl.Item>
</SegmentedControl.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<EyeOpenIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{display === TankopediaDisplay.DynamicArmor && (
<>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
});
}}
>
Opaque
</DropdownMenu.CheckboxItem>
{developerMode && (
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
<b>DEV:</b> Wireframe
</DropdownMenu.CheckboxItem>
)}
</>
)}
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem
key={environment}
value={environment}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment;
});
}}
>
{environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Popover.Root>
<Popover.Trigger>
<IconButton color="gray" variant="ghost">
<CameraIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Popover.Close>
<Button
onClick={() => {
if (!canvas.current) return;
const anchor = document.createElement('a');
anchor.setAttribute(
'download',
`${protagonistTank.name}.png`,
);
anchor.setAttribute(
'href',
canvas.current.toDataURL('image/png'),
);
anchor.click();
}}
>
<DownloadIcon />
Download
</Button>
</Popover.Close>
<Popover.Close>
<Button
variant="outline"
onClick={() => {
if (!canvas.current) return;
canvas.current.toBlob((blob) => {
if (!blob) return;
navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
});
}}
>
<CopyIcon />
Copy
</Button>
</Popover.Close>
</Flex>
</Popover.Content>
</Popover.Root>
{fullScreenAvailable && (
<IconButton
color="gray"
variant="ghost"
onClick={() => {
if (isFullScreen) {
document.exitFullscreen();
} else document.body.requestFullscreen();
}}
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</IconButton>
)}
</Flex>
</Flex>
</>
);
}
@@ -1,15 +0,0 @@
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'none',
':focus-within': {
display: 'flex',
},
selectors: {
'.tank-sandbox-container:hover &': {
display: 'flex',
},
},
});
@@ -1,167 +0,0 @@
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
import { Flex, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelTransformEvent, ModelTransformEventData } from '../../../../../../core/blitzkit/modelTransform';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useTankModelDefinition } from '../../../../../../hooks/useTankModelDefinition';
import * as Duel from '../../../../../../stores/duel';
import * as styles from './index.css';
export function QuickInputs() {
const mutateDuel = Duel.useMutation();
const protagonist = Duel.use((state) => state.protagonist);
const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null);
const tankModelDefinition = useTankModelDefinition();
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
useEffect(() => {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}, [protagonist.yaw]);
useEffect(() => {
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) + (tankModelDefinition.turretRotation?.pitch ?? 0)
).toFixed(1);
if (yaw === undefined) return;
yawInput.current.value = radToDeg(yaw).toFixed(1);
}
modelTransformEvent.on(handleTransformEvent);
return () => {
modelTransformEvent.off(handleTransformEvent);
};
}, []);
return (
<Flex
align="center"
gap="2"
width="10rem"
style={{
pointerEvents: 'auto',
position: 'absolute',
left: '50%',
top: 16,
transform: 'translateX(-50%)',
}}
className={styles.container}
>
<TextField.Root
size="1"
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(yawInput.current!.value);
if (isNaN(value)) {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
yawInput.current?.blur();
}
}}
onFocus={() => yawInput.current?.focus()}
ref={yawInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
<WidthIcon />
</TextField.Slot>
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
</TextField.Root>
<TextField.Root
size="1"
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(pitchInput.current!.value);
if (isNaN(value)) {
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist.pitch = pitch;
state.protagonist.yaw = yaw;
});
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
pitchInput.current?.blur();
}
}}
onFocus={() => pitchInput.current?.focus()}
ref={pitchInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
<HeightIcon />
</TextField.Slot>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root>
</Flex>
);
}
@@ -1,15 +0,0 @@
import { Flex } from '@radix-ui/themes';
import { ShotDisplayCard } from '../../../../../components/Armor/components/ShotDisplayCard';
import * as TankEphemeral from '../../../../../stores/tankopediaEphemeral';
export function ShotDisplaySection() {
const shot = TankEphemeral.use((state) => state.shot);
if (!shot) return null;
return (
<Flex justify="center" display={{ initial: 'flex', sm: 'none' }}>
<ShotDisplayCard shot={shot} />
</Flex>
);
}
@@ -2,19 +2,19 @@ import { TIER_ROMAN_NUMERALS } from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { invalidate } from '@react-three/fiber';
import { useEffect } from 'react';
import { CalloutsSection } from '../../../../../../website/src/components/Tankopedia/CalloutsSection';
import { CharacteristicsSection } from '../../../../../../website/src/components/Tankopedia/CharacteristicsSection';
import { GameModeSection } from '../../../../../../website/src/components/Tankopedia/GameModeSection';
import { HeroSection } from '../../../../../../website/src/components/Tankopedia/HeroSection';
import { HistorySection } from '../../../../../../website/src/components/Tankopedia/HistorySection';
import { MetaSection } from '../../../../../../website/src/components/Tankopedia/MetaSection';
import { ShotDisplaySection } from '../../../../../../website/src/components/Tankopedia/ShotDisplaySection';
import { TechTreeSection } from '../../../../../../website/src/components/Tankopedia/TechTreeSection';
import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive';
import { PageWrapper } from '../../../../components/PageWrapper';
import { useAdExempt } from '../../../../hooks/useAdExempt';
import * as Duel from '../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../stores/tankopediaEphemeral';
import { CalloutsSection } from './components/CalloutsSection';
import { HistorySection } from './components/HistorySection';
import { CharacteristicsSection } from './components/Model/CharacteristicsSection';
import { GameModeSection } from './components/Model/GameModeSection';
import { HeroSection } from './components/Model/HeroSection';
import { MetaSection } from './components/Model/MetaSection';
import { TechTreeSection } from './components/Model/TechTreeSection';
import { ShotDisplaySection } from './components/ShotDisplaySection';
import { VideoSection } from './components/VideoSection';
export default function Page() {