feat: separate armors

This commit is contained in:
tresabhi committed 2025-03-20 11:59:24 -05:00
1 parent 30b18f60be
commit e60bbc03d2
31 files changed
+1387 -690

No files matched your search

@@ -0,0 +1,42 @@
import { asset, type Tank } from '@blitzkit/core';
import { useLoader } from '@react-three/fiber';
import { Material, MeshBasicMaterial } from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { randInt } from 'three/src/math/MathUtils.js';
import { jsxTree } from '../core/blitzkit/jsxTree';
interface ArmorGroupProps {
tank: Tank;
group: string;
}
export function ArmorGroup({ tank, group }: ArmorGroupProps) {
const gltf = useLoader(
GLTFLoader,
asset(`/tanks/${tank.id}/collision/hull.glb`),
);
return jsxTree(gltf.scene, {
mesh: (mesh) => {
return {
material: new MeshBasicMaterial({
color: `rgb(${randInt(0, 255)}, ${randInt(0, 255)}, ${randInt(
0,
255,
)})`,
}),
onClick: (event) => {
if (!(mesh.material instanceof Material)) return;
event.stopPropagation();
const armorName = mesh.material.name;
const penetrationGroup = tank.penetration_groups[group];
const thickness = penetrationGroup.armors[armorName];
console.log(tank.id, armorName, penetrationGroup, thickness);
},
};
},
});
}
@@ -1,16 +1,13 @@
import { asset } from '@blitzkit/core';
import { Tank } from '@blitzkit/core';
import { OrbitControls } from '@react-three/drei';
import { Canvas, useLoader } from '@react-three/fiber';
import { MeshBasicMaterial } from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { randInt } from 'three/src/math/MathUtils.js';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { Canvas } from '@react-three/fiber';
import { ArmorGroup } from '../../../../components/ArmorGroup';
interface PageProps {
id: string;
tank: Tank;
}
export function Page({ id }: PageProps) {
export function Page({ tank }: PageProps) {
return (
<Canvas
style={{
@@ -19,29 +16,7 @@ export function Page({ id }: PageProps) {
}}
>
<OrbitControls />
<Model id={id} />
<ArmorGroup group="hull" tank={tank} />
</Canvas>
);
}
function Model({ id }: { id: string }) {
const gltf = useLoader(GLTFLoader, asset(`/tanks/${id}/collision/hull.glb`));
console.log(gltf);
return jsxTree(gltf.scene, {
mesh: (mesh) => ({
material: new MeshBasicMaterial({
color: `rgb(${randInt(0, 255)}, ${randInt(0, 255)}, ${randInt(
0,
255,
)})`,
}),
onClick: (event) => {
if (!(mesh.material instanceof MeshBasicMaterial)) return;
event.stopPropagation();
console.log(mesh.material.name)
},
}),
});
}
@@ -1,4 +1,5 @@
---
import { fetchTank } from '@blitzkit/core';
import type { GetStaticPaths } from 'astro';
import { awaitableTanksFull } from '../../../../core/awaitables/tanksFull';
import BaseLayout from '../../../../layouts/BaseLayout.astro';
@@ -20,8 +21,9 @@ export const getStaticPaths = (async () => {
}) satisfies GetStaticPaths;
const { id } = Astro.props;
const tank = await fetchTank(id);
---
<BaseLayout>
<Page {id} client:load />
<Page {tank} client:load />
</BaseLayout>
@@ -1,166 +0,0 @@
import { createDefaultSkills } from '@blitzkit/core';
import { Flex, Heading, Text } from '@radix-ui/themes';
import { useEffect, useMemo, useState } from 'react';
import { CompareTable } from '../../../../components/Compare/CompareTable';
import { Controls } from '../../../../components/Compare/Controls';
import { DamageWarning } from '../../../../components/DamageWarning';
import { PageWrapper } from '../../../../components/PageWrapper';
import { awaitableEquipmentDefinitions } from '../../../../core/awaitables/equipmentDefinitions';
import { awaitableModelDefinitions } from '../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../core/awaitables/provisionDefinitions';
import { awaitableSkillDefinitions } from '../../../../core/awaitables/skillDefinitions';
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
import { tankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
import { tankToCompareMember } from '../../../../core/blitzkit/tankToCompareMember';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { App } from '../../../../stores/app';
import { CompareEphemeral } from '../../../../stores/compareEphemeral';
import { ComparePersistent } from '../../../../stores/comparePersistent';
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
const [
modelDefinitions,
equipmentDefinitions,
provisionDefinitions,
skillDefinitions,
tankDefinitions,
] = await Promise.all([
awaitableModelDefinitions,
awaitableEquipmentDefinitions,
awaitableProvisionDefinitions,
awaitableSkillDefinitions,
awaitableTankDefinitions,
]);
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<App.Provider>
<TankopediaPersistent.Provider>
<ComparePersistent.Provider>
<CompareEphemeral.Provider
data={createDefaultSkills(skillDefinitions)}
>
<Content />
</CompareEphemeral.Provider>
</ComparePersistent.Provider>
</TankopediaPersistent.Provider>
</App.Provider>
</LocaleProvider>
);
}
function Content() {
const members = CompareEphemeral.use((state) => state.members);
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const { strings } = useLocale();
const stats = useMemo<ReturnType<typeof tankCharacteristics>[]>(
() =>
members.map((thisMember) =>
tankCharacteristics(
{
...thisMember,
crewSkills,
stockEngine: thisMember.tank.engines[0],
stockGun: thisMember.tank.turrets[0].guns[0],
stockTrack: thisMember.tank.tracks[0],
stockTurret: thisMember.tank.turrets[0],
applyReactiveArmor: members.some(
(member) =>
member.key !== thisMember.key &&
member.consumables.includes(33),
),
applyDynamicArmor: members.some(
(member) =>
member.key !== thisMember.key &&
member.consumables.includes(73),
),
applySpallLiner: members.some(
(member) =>
member.key !== thisMember.key &&
member.provisions.includes(101),
),
},
{
equipmentDefinitions: equipmentDefinitions,
provisionDefinitions: provisionDefinitions,
tankModelDefinition: modelDefinitions.models[thisMember.tank.id],
},
),
),
[members, crewSkills],
);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
useEffect(() => {
const search = new URLSearchParams(window.location.search);
const tanksQuery = search.get('tanks');
if (tanksQuery === null) return;
const tanks = tanksQuery.split(',').map(Number);
mutateCompareEphemeral((draft) => {
draft.members = [];
tanks.forEach((id) => {
const tank = tankDefinitions.tanks[id];
if (tank === undefined) return;
draft.members.push(tankToCompareMember(tank, provisionDefinitions));
});
});
}, []);
useEffect(() => {
const search = new URLSearchParams(window.location.search);
search.set('tanks', members.map((member) => member.tank.id).join(','));
window.history.replaceState(
{},
'',
`${window.location.pathname}?${search.toString()}`,
);
}, [members]);
return (
<PageWrapper color="crimson" maxWidth="100%">
<Flex justify="center" gap="4" align="center" direction="column">
<DamageWarning />
<Controls
addTankDialogOpen={addTankDialogOpen}
onAddTankDialogOpenChange={setAddTankDialogOpen}
/>
</Flex>
{members.length > 0 && (
<Flex justify="center" flexGrow="1" position="relative">
<CompareTable stats={stats} />
</Flex>
)}
{members.length === 0 && (
<Flex
align="center"
justify="center"
direction="column"
style={{ flex: 1 }}
>
<Heading color="gray">
{strings.website.tools.compare.no_tanks.title}
</Heading>
<Text color="gray">
{strings.website.tools.compare.no_tanks.body}
</Text>
</Flex>
)}
</PageWrapper>
);
}
@@ -1,16 +0,0 @@
---
import ToolOpenGraph from '../../../../components/ToolOpenGraph.astro';
import { compareTool } from '../../../../constants/tools';
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<ToolOpenGraph tool={compareTool} />
<BaseLayout noFooter>
<Page {locale} client:load />
</BaseLayout>
@@ -1,296 +0,0 @@
import { imgur } from '@blitzkit/core';
import {
CodeIcon,
HeightIcon,
ImageIcon,
ResetIcon,
TimerIcon,
WidthIcon,
} from '@radix-ui/react-icons';
import { Box, Button, Flex, Heading, ScrollArea, Text } from '@radix-ui/themes';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { CopyButton } from '../../../../../components/CopyButton';
import { Boolean } from '../../../../../components/Embeds/Boolean';
import { Color } from '../../../../../components/Embeds/Color';
import { Enum } from '../../../../../components/Embeds/Enum';
import { GenerateURL } from '../../../../../components/Embeds/GenerateURL';
import { Import } from '../../../../../components/Embeds/Import';
import { PreviewWrapper } from '../../../../../components/Embeds/PreviewWrapper';
import { Radius } from '../../../../../components/Embeds/Radius';
import { RichText } from '../../../../../components/Embeds/RichText';
import { Size } from '../../../../../components/Embeds/Size';
import { SizeWithout0 } from '../../../../../components/Embeds/SizeWithout0';
import { Slider } from '../../../../../components/Embeds/Slider';
import { TextController } from '../../../../../components/Embeds/TextController';
import { PageWrapper } from '../../../../../components/PageWrapper';
import {
embedConfigurations,
extractEmbedConfigDefaults,
} from '../../../../../constants/embeds';
import { NAVBAR_HEIGHT } from '../../../../../constants/navbar';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../../../../hooks/useLocale';
import { App } from '../../../../../stores/app';
import { EmbedState, type EmbedConfig } from '../../../../../stores/embedState';
import { EmbedItemType } from '../../../../../stores/embedState/constants';
export interface EmbedPreviewControllerProps {
configKey: string;
}
interface PageProps {
embed: keyof typeof embedConfigurations;
}
export function Page({ embed, locale }: PageProps & LocaleAcceptorProps) {
const config = embedConfigurations[embed];
return (
<LocaleProvider locale={locale}>
<App.Provider>
<EmbedState.Provider data={extractEmbedConfigDefaults(config)}>
<Content embed={embed} />
</EmbedState.Provider>
</App.Provider>
</LocaleProvider>
);
}
function Content({ embed }: PageProps) {
const embedStateStore = EmbedState.useStore();
const config = embedConfigurations[embed] as EmbedConfig;
const [backgroundImage, setBackgroundImage] = useState(
imgur('SO13zur', { format: 'jpeg' }),
);
const fileInput = useRef<HTMLInputElement>();
const { strings } = useLocale();
useEffect(() => {
fileInput.current = document.createElement('input');
fileInput.current.type = 'file';
fileInput.current.style.display = 'none';
fileInput.current.accept = 'image/*';
fileInput.current.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
setBackgroundImage(URL.createObjectURL(file));
};
}, []);
return (
<PageWrapper padding="0" maxWidth="104rem" color="red">
<Flex>
<ScrollArea
scrollbars="vertical"
style={{ height: '100%', maxWidth: 320 }}
>
<Flex direction="column" gap="2" p="4">
<Import />
<Heading>
{strings.website.tools.embed.configuration.export.title}
</Heading>
<Text size="2" color="gray" mb="2">
{strings.website.tools.embed.configuration.export.description}
</Text>
<Flex mb="6" gap="2" wrap="wrap">
<GenerateURL embed={embed} />
<CopyButton
variant="outline"
copy={() =>
'body { margin: 0; background-color: transparent; overflow: hidden; }'
}
>
<CodeIcon />
{strings.website.tools.embed.configuration.export.css}
</CopyButton>
<CopyButton
variant="outline"
copy={() => `${embedStateStore.getState().width}`}
>
<WidthIcon />
{strings.website.tools.embed.configuration.export.width}
</CopyButton>
<CopyButton
variant="outline"
copy={() => `${embedStateStore.getState().height}`}
>
<HeightIcon />
{strings.website.tools.embed.configuration.export.height}
</CopyButton>
<CopyButton variant="outline" copy={() => '1'}>
<TimerIcon />
{strings.website.tools.embed.configuration.export.framerate}
</CopyButton>
</Flex>
<Flex justify="between" align="center">
<Heading>
{strings.website.tools.embed.configuration.customize.title}
</Heading>
<Button
color="red"
variant="ghost"
onClick={() => {
embedStateStore.setState(
embedStateStore.getInitialState(),
true,
);
}}
>
<ResetIcon />{' '}
{strings.website.tools.embed.configuration.customize.reset}
</Button>
</Flex>
<Text size="2" color="gray" mb="2">
{strings.website.tools.embed.configuration.customize.description}
</Text>
{Object.keys(config).map((configKey) => {
const item = config[configKey];
let control: ReactNode;
let oneLiner = false;
switch (item.type) {
case EmbedItemType.Boolean: {
control = <Boolean configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Color: {
control = <Color configKey={configKey} />;
break;
}
case EmbedItemType.Radius: {
control = <Radius configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Size: {
control = <Size configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.SizeWithout0: {
control = <SizeWithout0 configKey={configKey} />;
oneLiner = true;
break;
}
case EmbedItemType.Slider: {
control = <Slider configKey={configKey} config={item} />;
oneLiner = true;
break;
}
case EmbedItemType.String: {
control = <TextController configKey={configKey} />;
break;
}
case EmbedItemType.RichText: {
control = <RichText configKey={configKey} />;
break;
}
case EmbedItemType.Enum: {
control = <Enum configKey={configKey} config={item} />;
oneLiner = true;
break;
}
}
return (
<Flex
key={configKey}
direction={oneLiner ? undefined : 'column'}
gap={oneLiner ? '2' : '1'}
justify={oneLiner ? 'between' : undefined}
mb={oneLiner ? '2' : '4'}
pb={item.pad ? '6' : undefined}
>
<Text>
{
(
strings.website.tools.embed.types[embed]
.options as Record<string, string>
)[configKey]
}
</Text>
<Flex
gap="2"
align="center"
flexGrow="1"
justify={oneLiner ? 'end' : undefined}
>
{control}
</Flex>
</Flex>
);
})}
</Flex>
</ScrollArea>
<Box
p="4"
flexGrow="1"
position="sticky"
top={`${NAVBAR_HEIGHT}px`}
height={`calc(100vh - ${NAVBAR_HEIGHT}px)`}
>
<Flex
direction="column"
height="100%"
style={{
borderRadius: 'var(--radius-3)',
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}
>
<Flex
justify="between"
align="center"
p="2"
pl="3"
style={{
backgroundColor: 'var(--color-panel-solid)',
}}
>
<Text color="gray">
{strings.website.tools.embed.preview.title}
</Text>
<Button
variant="outline"
color="gray"
onClick={() => fileInput.current?.click()}
>
<ImageIcon /> {strings.website.tools.embed.preview.upload}
</Button>
</Flex>
<Box
flexGrow="1"
style={{
background: `url(${backgroundImage}) center / cover no-repeat`,
}}
position="relative"
overflow="hidden"
>
<PreviewWrapper name={embed} />
</Box>
</Flex>
</Box>
</Flex>
</PageWrapper>
);
}
@@ -1,54 +0,0 @@
import { Box } from '@radix-ui/themes';
import { useMemo } from 'react';
import { parse } from 'urlon';
import { BlitzKitTheme } from '../../../../../../components/BlitzKitTheme';
import {
embedConfigurations,
embedRenderers,
extractEmbedConfigDefaults,
} from '../../../../../../constants/embeds';
import {
EmbedState,
type EmbedConfig,
type ExtractEmbedConfigTypes,
} from '../../../../../../stores/embedState';
import { useEmbedStateCurry } from '../../../../../../stores/embedState/utilities';
interface PageProps {
embed: keyof typeof embedConfigurations;
}
export function Page({ embed }: PageProps) {
const state = useMemo(() => {
const params = new URLSearchParams(window.location.search);
const stateRaw = params.get('state');
const partialState = stateRaw === null ? {} : parse(stateRaw);
const state = {
...extractEmbedConfigDefaults(embedConfigurations[embed]),
...partialState,
} as ExtractEmbedConfigTypes<EmbedConfig>;
return state;
}, [embed]);
return (
<EmbedState.Provider data={state}>
<Content embed={embed} />
</EmbedState.Provider>
);
}
function Content({ embed }: PageProps) {
const Renderer = embedRenderers[embed];
const { useEmbedState } = useEmbedStateCurry();
const width = useEmbedState('width');
const height = useEmbedState('height');
return (
<BlitzKitTheme style={{ background: 'transparent' }}>
<Box width={`${width}px`} height={`${height}px`} overflow="hidden">
<Renderer />
</Box>
</BlitzKitTheme>
);
}
@@ -1,14 +0,0 @@
---
import { capitalize } from 'lodash-es';
import EmbedLayout from '../../../../../../layouts/EmbedLayout.astro';
import type { getStaticPaths } from '../index.astro';
import { Page } from './_index';
export { getStaticPaths } from '../index.astro';
---
<title>BlitzKit - {capitalize(Astro.params.embed)} embed</title>
<EmbedLayout>
<Page client:only="react" embed={Astro.params.embed} />
</EmbedLayout>
@@ -1,50 +0,0 @@
---
import type { GetStaticPaths } from 'astro';
import { capitalize } from 'lodash-es';
import { embedConfigurations } from '../../../../../constants/embeds';
import BaseLayout from '../../../../../layouts/BaseLayout.astro';
import { getStaticPaths as parentGetStaticPaths } from '../index.astro';
import { Page } from './_index';
export const getStaticPaths = (() => {
const parentPaths = parentGetStaticPaths();
return Object.keys(embedConfigurations)
.map((embed) =>
parentPaths.map((parent) => ({
params: {
...parent.params,
embed: embed as keyof typeof embedConfigurations,
},
})),
)
.flat();
}) satisfies GetStaticPaths;
const { locale } = Astro.params;
---
<title>BlitzKit - {capitalize(Astro.params.embed)} embed</title>
<style is:global>
@keyframes fade-out-border {
from {
outline: 1rem solid #ff0000ff;
}
to {
outline: 0.75rem solid #ff000000;
}
}
.preview-container-animated {
animation: fade-out-border 0.5s linear;
}
.preview-container-animated:hover {
outline: 1rem solid #ff000080;
}
</style>
<BaseLayout>
<Page {locale} client:load embed={Astro.params.embed} />
</BaseLayout>
@@ -1,105 +0,0 @@
import { imgur, ImgurSize } from '@blitzkit/core';
import { Box, Flex, Link, Text } from '@radix-ui/themes';
import { PreviewWrapper } from '../../../../components/Embeds/PreviewWrapper';
import { LinkI18n } from '../../../../components/LinkI18n';
import { PageWrapper } from '../../../../components/PageWrapper';
import {
embedConfigurations,
extractEmbedConfigDefaults,
} from '../../../../constants/embeds';
import { Var } from '../../../../core/radix/var';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { App } from '../../../../stores/app';
import { EmbedState } from '../../../../stores/embedState';
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<App.Provider>
<Content />
</App.Provider>
</LocaleProvider>
);
}
function Content() {
const { locale, strings } = useLocale();
return (
<PageWrapper color="red">
<Text align="center" mt="3">
{strings.website.tools.embed.coming_soon}{' '}
<Link
underline="always"
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
>
{strings.website.tools.embed.suggest}
</Link>
.
</Text>
<Flex justify="center">
{Object.entries(embedConfigurations).map(([embed, config]) => (
<EmbedState.Provider
key={embed}
data={extractEmbedConfigDefaults(config)}
>
<LinkI18n
locale={locale}
href={`/tools/embed/${embed}`}
color="gray"
highContrast
style={{
width: 'fit-content',
height: 'fit-content',
}}
underline="hover"
>
<Flex
direction="column"
overflow="hidden"
style={{
borderRadius: Var('radius-4'),
backgroundColor: Var('color-panel-solid'),
boxShadow: Var('shadow-3'),
}}
>
<Flex justify="center" p="2">
<Text>
{
strings.website.tools.embed.types[
embed as keyof typeof strings.website.tools.embed.types
].name
}
</Text>
</Flex>
<Box
width="20rem"
height="20rem"
overflow="hidden"
position="relative"
style={{
backgroundImage: `url(${imgur('SO13zur', { format: 'jpeg', size: ImgurSize.SmallThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<PreviewWrapper
naked
name={embed as keyof typeof embedConfigurations}
/>
</Box>
</Flex>
</LinkI18n>
</EmbedState.Provider>
))}
</Flex>
</PageWrapper>
);
}
@@ -1,13 +0,0 @@
---
import { embedTool } from '../../../../constants/tools';
import ToolLayout from '../../../../layouts/ToolLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<ToolLayout tool={embedTool}>
<Page {locale} />
</ToolLayout>
@@ -1,53 +0,0 @@
import { literals } from '@blitzkit/i18n/src/literals';
import { CalendarIcon } from '@radix-ui/react-icons';
import { Callout, Flex } from '@radix-ui/themes';
import { GalleryList, type Avatar } from '../../../../components/Gallery/List';
import { GallerySearch } from '../../../../components/Gallery/Search';
import { PageWrapper } from '../../../../components/PageWrapper';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { GalleryEphemeral } from '../../../../stores/galleryEphemeral';
interface ContentProps {
avatars: Avatar[];
lastUpdated: string;
}
type PageProps = ContentProps & LocaleAcceptorProps;
export function Page({ locale, ...props }: PageProps) {
return (
<LocaleProvider locale={locale}>
<Content {...props} />
</LocaleProvider>
);
}
function Content({ avatars, lastUpdated }: ContentProps) {
const { strings } = useLocale();
return (
<PageWrapper maxWidth="80rem" color="gold">
<Flex justify="center">
<Callout.Root size="1">
<Callout.Icon>
<CalendarIcon />
</Callout.Icon>
<Callout.Text>
{literals(strings.website.tools.gallery.last_updated, [
lastUpdated,
])}
</Callout.Text>
</Callout.Root>
</Flex>
<GalleryEphemeral.Provider>
<GallerySearch />
<GalleryList avatars={avatars} />
</GalleryEphemeral.Provider>
</PageWrapper>
);
}
@@ -1,29 +0,0 @@
---
import type { Avatar } from '../../../../components/Gallery/List';
import { galleryTool } from '../../../../constants/tools';
import { fetchGlossary } from '../../../../core/blitz/fetchGlossary';
import ToolLayout from '../../../../layouts/ToolLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
const glossary = await fetchGlossary(locale);
const avatars = Object.entries(glossary)
.filter(
([key, value]) =>
key.startsWith('avatar') &&
!key.endsWith('_part') &&
value.image_url !== null,
)
.map(
([key, value]) =>
({ id: key.replace('avatar_', ''), name: value.title }) satisfies Avatar,
)
.sort((a, b) => a.name.localeCompare(b.name));
const lastUpdated = new Date().toLocaleDateString(locale);
---
<ToolLayout tool={galleryTool}>
<Page {locale} {avatars} {lastUpdated} client:load />
</ToolLayout>
@@ -1,38 +0,0 @@
import { PageWrapper } from '../../../../components/PageWrapper';
import { PerformanceInfo } from '../../../../components/Performance/PerformanceInfo';
import { PlayerCountControl } from '../../../../components/Performance/PlayerCountControl';
import { TankTable } from '../../../../components/Performance/Table';
import { FilterControl } from '../../../../components/TankSearch/components/FilterControl';
import {
LocaleProvider,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { TankPerformanceEphemeral } from '../../../../stores/tankPerformanceEphemeral';
import { TankPerformanceSort } from '../../../../stores/tankPerformanceSort';
import type { MaybeSkeletonComponentProps } from '../../../../types/maybeSkeletonComponentProps';
type PageProps = MaybeSkeletonComponentProps & LocaleAcceptorProps;
export function Page({ skeleton, locale }: PageProps) {
return (
<LocaleProvider locale={locale}>
<TankPerformanceSort.Provider>
<TankPerformanceEphemeral.Provider>
<Content skeleton={skeleton} />
</TankPerformanceEphemeral.Provider>
</TankPerformanceSort.Provider>
</LocaleProvider>
);
}
function Content({ skeleton }: MaybeSkeletonComponentProps) {
return (
<PageWrapper color="jade" maxWidth="100%">
<PerformanceInfo skeleton={skeleton} />
<FilterControl />
<PlayerCountControl />
<TankTable skeleton={skeleton} />
</PageWrapper>
);
}
@@ -1,18 +0,0 @@
---
import ToolOpenGraph from '../../../../components/ToolOpenGraph.astro';
import { performanceTool } from '../../../../constants/tools';
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<ToolOpenGraph tool={performanceTool} />
<BaseLayout noFooter>
<Page client:only="react" {locale}>
<Page skeleton slot="fallback" {locale} />
</Page>
</BaseLayout>
@@ -1,488 +0,0 @@
import {
compositeStats,
compositeStatsKeys,
deltaTankStats,
formatCompositeStat,
getAccountInfo,
getTankStats,
idToRegion,
searchPlayersAcrossRegions,
sumCompositeStats,
type AccountListWithServer,
type CompositeStatsKey,
type IndividualAccountInfo,
type IndividualTankStats,
} from '@blitzkit/core';
import { literals } from '@blitzkit/i18n/src/literals';
import {
ArrowDownIcon,
MagnifyingGlassIcon,
PlusIcon,
} from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
DropdownMenu,
Flex,
Heading,
IconButton,
Select,
Table,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash-es';
import { useEffect, useMemo, useRef, useState } from 'react';
import { PageWrapper } from '../../../../components/PageWrapper';
import { StickyRowHeaderCell } from '../../../../components/StickyRowHeaderCell';
import { StickyTableRoot } from '../../../../components/StickyTableRoot';
import { TankRowHeaderCell } from '../../../../components/TankRowHeaderCell';
import { awaitableAverageDefinitions } from '../../../../core/awaitables/averageDefinitions';
import { awaitableTankDefinitions } from '../../../../core/awaitables/tankDefinitions';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { Session, type SessionTracking } from '../../../../stores/session';
const [tankDefinitions, averageDefinitions] = await Promise.all([
awaitableTankDefinitions,
awaitableAverageDefinitions,
]);
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<Session.Provider>
<Content />
</Session.Provider>
</LocaleProvider>
);
}
function Content() {
const { locale, strings } = useLocale();
const [showSearch, setShowSearch] = useState(false);
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<AccountListWithServer>([]);
const [showCCInaccessibilityPrompt, setShowCCInaccessibilityPrompt] =
useState(false);
const input = useRef<HTMLInputElement>(null);
const session = Session.use();
const mutateSession = Session.useMutation();
const [tankStatsB, setTankStatsB] = useState<IndividualTankStats[] | null>(
null,
);
const [accountInfo, setAccountInfo] = useState<
IndividualAccountInfo | null | undefined
>(undefined);
useEffect(() => {
(async () => {
const id = session.tracking ? session.player.id : undefined;
if (!id) return setAccountInfo(null);
setAccountInfo(
session.tracking ? await getAccountInfo(idToRegion(id), id) : null,
);
})();
}, [session.tracking && session.player.id]);
const delta = useMemo(
() =>
session.tracking && tankStatsB
? deltaTankStats(session.player.stats, tankStatsB)
.sort((a, b) => b.last_battle_time - a.last_battle_time)
.map((entry) => {
const tank = tankDefinitions.tanks[entry.tank_id];
const average = averageDefinitions.averages[tank.id];
const composite = compositeStats(
{ ...entry.all, battle_life_time: entry.battle_life_time },
average?.mu,
);
return { tank, composite };
})
: undefined,
[session.tracking && session.player, tankStatsB],
);
const total = useMemo(
() =>
delta
? sumCompositeStats(delta.map(({ composite }) => composite))
: undefined,
[delta],
);
const search = debounce(async () => {
if (!input.current) return;
setSearchResults(await searchPlayersAcrossRegions(input.current.value, 15));
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;
const tankStats = await getTankStats(
idToRegion(session.player.id),
session.player.id,
);
setTankStatsB(tankStats);
}
update();
const interval = setInterval(update, 5 * 1000);
return () => clearInterval(interval);
}, [session.tracking && session.player.id]);
return (
<PageWrapper color="blue">
<AlertDialog.Root
open={showCCInaccessibilityPrompt}
onOpenChange={setShowCCInaccessibilityPrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>
{strings.website.tools.session.no_cc.title}
</AlertDialog.Title>
<AlertDialog.Description>
{strings.website.tools.session.no_cc.body}
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button variant="solid">
{strings.website.tools.session.no_cc.alright}
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
{!session.tracking && (
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="3"
>
<Flex gap="2" align="center">
<Text color="gray">
{strings.website.tools.session.search.lookup}
</Text>
<ArrowDownIcon />
</Flex>
<TextField.Root
size="3"
style={{
position: 'relative',
width: '75vw',
maxWidth: 480,
}}
ref={input}
placeholder={strings.website.tools.session.search.hint}
onChange={() => {
if (!input.current) return;
const sanitized = input.current.value.trim();
if (sanitized.length > 0) {
setShowSearch(true);
setSearching(true);
search();
} else {
setShowSearch(false);
}
}}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
</TextField.Root>
{showSearch && (
<Flex mt="2">
{searching && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.session.search.searching}
</Text>
</Flex>
)}
{!searching && searchResults.length === 0 && (
<Flex justify="center">
<Text color="gray">
{strings.website.tools.session.search.no_results}
</Text>
</Flex>
)}
{!searching && searchResults.length > 0 && (
<Flex direction="column" gap="2">
{searchResults.map((player) => (
<Button
key={player.account_id}
variant="ghost"
onClick={() => track(player.account_id)}
>
{player.nickname} (
{strings.common.regions.short[player.region]})
</Button>
))}
</Flex>
)}
</Flex>
)}
</Flex>
)}
{session.tracking && (
<Flex
mt="2"
mb="2"
direction={{ initial: 'column', sm: 'row' }}
align="center"
justify="between"
gap="2"
>
<Flex mt="2" mb="2" direction="column" gap="2">
<Heading size="5">
{literals(strings.website.tools.session.controls.title, [
`${accountInfo?.nickname}`,
])}
</Heading>
<Text color="gray">
{literals(strings.website.tools.session.controls.subtitle, [
new Date(session.player.since).toLocaleString(locale),
])}
</Text>
</Flex>
<Flex gap="2" align="center" justify="center">
<Button
color="red"
onClick={async () => {
const stats = await getTankStats(
session.player.region,
session.player.id,
);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
mutateSession((draft) => {
(draft as SessionTracking).player.stats = stats;
});
}}
>
{strings.website.tools.session.controls.reset}
</Button>
<Button
onClick={() => {
mutateSession((draft) => {
draft.tracking = false;
});
}}
>
{strings.website.tools.session.controls.change}
</Button>
</Flex>
</Flex>
)}
{session.tracking && delta && delta.length > 0 && (
<Flex justify="center">
<StickyTableRoot variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
{strings.website.tools.session.table.tank}
</Table.ColumnHeaderCell>
{session.columns.map((column, index) => (
<Table.ColumnHeaderCell
align="center"
width="0"
key={column}
style={{ position: 'relative' }}
>
{index === 0 && session.columns.length < 4 && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton
size="1"
variant="surface"
style={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translate(-100%, -50%)',
zIndex: 1,
}}
>
<PlusIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{compositeStatsKeys
.filter((item) => !session.columns.includes(item))
.map((key) => (
<DropdownMenu.Item
key={key}
onClick={() => {
mutateSession((draft) => {
(draft as SessionTracking).columns.unshift(
key,
);
});
}}
>
{strings.common.composite_stats[key]}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
<Select.Root
value={column}
onValueChange={(value) => {
mutateSession((draft) => {
if (value === 'remove') {
(draft as SessionTracking).columns.splice(index, 1);
} else {
(draft as SessionTracking).columns[index] =
value as CompositeStatsKey;
}
});
}}
>
<Select.Trigger variant="ghost" />
<Select.Content>
<Select.Item value="remove">
{strings.website.tools.session.table.remove}
</Select.Item>
<Select.Separator />
{compositeStatsKeys
.filter(
(item) =>
!session.columns.includes(item) ||
item === column,
)
.map((key) => (
<Select.Item key={key} value={key}>
{strings.common.composite_stats[key]}
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row
style={{
overflow: 'hidden',
}}
>
<StickyRowHeaderCell
style={{
paddingLeft: 32,
position: 'relative',
overflowY: 'hidden',
}}
>
{strings.website.tools.session.table.total}
</StickyRowHeaderCell>
{session.columns.map((column) => (
<Table.Cell align="center" key={column}>
{formatCompositeStat(total![column], column, total!)}
</Table.Cell>
))}
</Table.Row>
{delta.map(({ tank, composite }) => {
return (
<Table.Row
key={tank.id}
style={{
overflow: 'hidden',
}}
>
<TankRowHeaderCell tank={tank} />
{session.columns.map((column) => (
<Table.Cell align="center" key={column}>
{formatCompositeStat(
composite[column],
column,
composite,
)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</StickyTableRoot>
</Flex>
)}
{session.tracking && delta && delta.length === 0 && (
<Flex
align="center"
justify="center"
direction="column"
style={{
flex: 1,
}}
>
<Heading color="gray">
{strings.website.tools.session.no_battles.title}
</Heading>
<Text color="gray">
{strings.website.tools.session.no_battles.hint}
</Text>
</Flex>
)}
</PageWrapper>
);
}
@@ -1,13 +0,0 @@
---
import { sessionTool } from '../../../../constants/tools';
import ToolLayout from '../../../../layouts/ToolLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<ToolLayout tool={sessionTool}>
<Page client:load {locale} />
</ToolLayout>
@@ -1,64 +0,0 @@
import { TankType } from '@blitzkit/core';
import { PageWrapper } from '../../../../../components/PageWrapper';
import { CalloutsSection } from '../../../../../components/Tankopedia/CalloutsSection';
import { CharacteristicsSection } from '../../../../../components/Tankopedia/CharacteristicsSection';
import { GameModeSection } from '../../../../../components/Tankopedia/GameModeSection';
import { HeroSection } from '../../../../../components/Tankopedia/HeroSection';
import { MetaSection } from '../../../../../components/Tankopedia/MetaSection';
import { TechTreeSection } from '../../../../../components/Tankopedia/TechTreeSection';
import { VideoSection } from '../../../../../components/Tankopedia/VideoSection';
import { awaitableModelDefinitions } from '../../../../../core/awaitables/modelDefinitions';
import { awaitableProvisionDefinitions } from '../../../../../core/awaitables/provisionDefinitions';
import { awaitableTankDefinitions } from '../../../../../core/awaitables/tankDefinitions';
import {
LocaleProvider,
type LocaleAcceptorProps,
} from '../../../../../hooks/useLocale';
import { App } from '../../../../../stores/app';
import { Duel } from '../../../../../stores/duel';
import { TankopediaEphemeral } from '../../../../../stores/tankopediaEphemeral';
import { TankopediaPersistent } from '../../../../../stores/tankopediaPersistent';
import type { MaybeSkeletonComponentProps } from '../../../../../types/maybeSkeletonComponentProps';
type PageProps = MaybeSkeletonComponentProps &
LocaleAcceptorProps & {
id: number;
};
const [tankDefinitions, provisionDefinitions, modelDefinitions] =
await Promise.all([
awaitableTankDefinitions,
awaitableProvisionDefinitions,
awaitableModelDefinitions,
]);
export function Page({ id, skeleton, locale }: PageProps) {
const tank = tankDefinitions.tanks[id];
const model = modelDefinitions.models[id];
return (
<LocaleProvider locale={locale}>
<TankopediaEphemeral.Provider data={model}>
<App.Provider>
<TankopediaPersistent.Provider>
<Duel.Provider
data={{ tank, provisionDefinitions: provisionDefinitions }}
>
<PageWrapper p="0" maxWidth="unset" color="purple" gap="9" pb="9">
<HeroSection skeleton={skeleton} />
<CalloutsSection />
<MetaSection />
{tank.type === TankType.RESEARCHABLE && !tank.deprecated && (
<TechTreeSection skeleton={skeleton} />
)}
<CharacteristicsSection />
<GameModeSection />
<VideoSection skeleton={skeleton} />
</PageWrapper>
</Duel.Provider>
</TankopediaPersistent.Provider>
</App.Provider>
</TankopediaEphemeral.Provider>
</LocaleProvider>
);
}
@@ -1,110 +0,0 @@
---
import {
asset,
createDefaultProvisions,
createDefaultSkills,
fetchEquipmentDefinitions,
fetchModelDefinitions,
fetchProvisionDefinitions,
fetchSkillDefinitions,
fetchTankDefinitions,
TIER_ROMAN_NUMERALS,
} from '@blitzkit/core';
import { DEFAULT_LOCALE, unwrapper } from '@blitzkit/i18n';
import { literals } from '@blitzkit/i18n/src/literals';
import type { GetStaticPaths } from 'astro';
import { tankCharacteristics } from '../../../../../core/blitzkit/tankCharacteristics';
import { getStrings } from '../../../../../core/i18n/getStrings';
import BaseLayout from '../../../../../layouts/BaseLayout.astro';
import { genericDefaultEquipmentMatrix } from '../../../../../stores/duel/constants';
import { getStaticPaths as parentGetStaticPaths } from '../index.astro';
import { Page } from './_index';
export const getStaticPaths = (async () => {
const tankDefinitions = await fetchTankDefinitions();
const parentPaths = parentGetStaticPaths();
return Object.values(tankDefinitions.tanks)
.map((tank) =>
parentPaths.map((parent) => ({
params: { ...parent.params, id: tank.id },
})),
)
.flat();
}) satisfies GetStaticPaths;
const { id } = Astro.params;
const locale = Astro.params.locale ?? DEFAULT_LOCALE;
const strings = getStrings(locale);
const tankDefinitions = await fetchTankDefinitions();
const skillDefinitions = await fetchSkillDefinitions();
const provisionDefinitions = await fetchProvisionDefinitions();
const equipmentDefinitions = await fetchEquipmentDefinitions();
const tankModelDefinitions = await fetchModelDefinitions();
const tank = tankDefinitions.tanks[id];
const tankModelDefinition = tankModelDefinitions.models[tank.id];
const name = tank.name_full ?? tank.name;
const unwrap = unwrapper(locale);
const title = literals(strings.website.tools.tanks.tank_seo_title, [
unwrap(name),
TIER_ROMAN_NUMERALS[tank.tier],
strings.common.nations_adjectives[
tank.nation as keyof typeof strings.common.nations_adjectives
],
strings.common.tank_class_short[tank.class],
'BlitzKit',
strings.common.nations[tank.nation as keyof typeof strings.common.nations],
]);
const url = import.meta.env.SITE + Astro.url.pathname;
const icon = asset(`icons/tanks/big/${id}.webp`);
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const characteristics = tankCharacteristics(
{
tank,
applyDynamicArmor: false,
applyReactiveArmor: false,
applySpallLiner: false,
camouflage: true,
consumables: [],
crewSkills: createDefaultSkills(skillDefinitions),
engine: tank.engines.at(-1)!,
equipmentMatrix: genericDefaultEquipmentMatrix,
turret,
gun,
provisions: createDefaultProvisions(tank, gun, provisionDefinitions),
shell: gun.gun_type!.value.base.shells[0],
stockEngine: tank.engines[0],
stockGun: tank.turrets[0].guns[0],
stockTurret: tank.turrets[0],
stockTrack: tank.tracks[0],
track: tank.tracks.at(-1)!,
},
{ equipmentDefinitions, provisionDefinitions, tankModelDefinition },
);
const description = literals(strings.website.tools.tanks.tank_seo_description, [
unwrap(name),
Math.round(characteristics.dpm).toLocaleString(locale),
Math.round(characteristics.damage).toLocaleString(locale),
Math.round(characteristics.penetration).toLocaleString(locale),
Math.round(characteristics.speedForwards).toLocaleString(locale),
Math.round(characteristics.health).toLocaleString(locale),
]);
---
<title>{title}</title>
<meta property="og:title" content={title} />
<meta name="description" content={description} />
<meta property="og:description" content={description} />
<meta property="og:image" content={icon} />
<meta property="og:url" content={url} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:image:src" content={icon} />
<BaseLayout>
<Page {locale} id={id} client:only="react">
<Page {locale} id={id} skeleton slot="fallback" />
</Page>
</BaseLayout>
@@ -1,22 +0,0 @@
import { PageWrapper } from '../../../../components/PageWrapper';
import { TankSearch } from '../../../../components/TankSearch';
import {
LocaleProvider,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { App } from '../../../../stores/app';
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<PageWrapper color="purple" maxWidth="80rem">
<TankopediaPersistent.Provider>
<App.Provider>
<TankSearch />
</App.Provider>
</TankopediaPersistent.Provider>
</PageWrapper>
</LocaleProvider>
);
}
@@ -1,15 +0,0 @@
---
import { tanksTool } from '../../../../constants/tools';
import ToolLayout from '../../../../layouts/ToolLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<ToolLayout tool={tanksTool}>
<Page {locale} client:only="react">
<Page {locale} slot="fallback" />
</Page>
</ToolLayout>
@@ -1,36 +0,0 @@
import { PageWrapper } from '../../../../components/PageWrapper';
import { FilterControl } from '../../../../components/TankSearch/components/FilterControl';
import { TierListControls } from '../../../../components/TierList/Controls';
import { TierListTable } from '../../../../components/TierList/Table';
import { TierListTiles } from '../../../../components/TierList/Tiles';
import { URLManager } from '../../../../components/TierList/URLManager';
import {
LocaleProvider,
type LocaleAcceptorProps,
} from '../../../../hooks/useLocale';
import { TankopediaPersistent } from '../../../../stores/tankopediaPersistent';
import { TierList } from '../../../../stores/tierList';
export function Page({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<TierList.Provider>
<TankopediaPersistent.Provider>
<Content />
</TankopediaPersistent.Provider>
</TierList.Provider>
</LocaleProvider>
);
}
function Content() {
return (
<PageWrapper color="orange" maxWidth="100rem">
<URLManager />
<TierListControls />
<TierListTable />
<FilterControl />
<TierListTiles />
</PageWrapper>
);
}
@@ -1,13 +0,0 @@
---
import { tierListTool } from '../../../../constants/tools';
import ToolLayout from '../../../../layouts/ToolLayout.astro';
import { Page } from './_index';
export { getStaticPaths } from '../../index.astro';
const { locale } = Astro.params;
---
<ToolLayout tool={tierListTool}>
<Page {locale} client:load />
</ToolLayout>