Merge branch 'main' into dev

This commit is contained in:
TrèsAbhi committed 2024-06-26 13:18:47 -05:00
commit 2928db2727
75 files changed
+1732 -7937

No files matched your search

+14 -32
View File
@@ -16,52 +16,34 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_TOKEN }}
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
- name: Add fixed sharp
run: yarn add [email protected] --ignore-engines
run: bun install
- name: Setup DepotDownloader
run: yarn setup:depotdownloader
run: bun scripts/setupDepotDownloader.ts
- name: Run DepotDownloader
run: ./temp/depotDownloader/DepotDownloader.exe -app 444200 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
run: ./temp/depotDownloader/DepotDownloader.exe -os windows -app 444200 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
- name: Apply patches
run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
yarn setup:patches --depot
bun scripts/applyPatches.ts --depot
- name: Build assets
run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:VIDEOS_SHEET = "${{ secrets.VIDEOS_SHEET }}"
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:VIDEOS_SHEET = "${{ secrets.VIDEOS_SHEET }}"
$env:GOOGLE_APPLICATION_CREDENTIALS = "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}"
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
bun build:assets --all-targets --depot --production
+1 -3
View File
@@ -1,11 +1,9 @@
name: CD Discover IDs
on:
push:
branches: main
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
- cron: '0 0 * * 1'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+8 -29
View File
@@ -17,44 +17,23 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Build docs
run: yarn build:docs
run: bun build:docs
- name: Configure pages
uses: actions/configure-pages@v2
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v1
- uses: actions/configure-pages@v2
- uses: actions/upload-pages-artifact@v1
with:
path: dist/docs
- name: Deploy
id: deployment
- id: deployment
uses: actions/deploy-pages@v1
- name: Archive build
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
with:
name: dist
path: docs/.vitepress/dist
+4 -19
View File
@@ -9,26 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} bun scripts/buildRatingLeaderboard.ts --region=asia --target=midnight --production
+4 -19
View File
@@ -9,26 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} bun scripts/buildRatingLeaderboard.ts --region=eu --target=midnight --production
+4 -19
View File
@@ -9,26 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Generate all leaderboards
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} bun scripts/buildRatingLeaderboard.ts --region=na,asia,eu --target=latest --production
+4 -19
View File
@@ -9,26 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na --target=midnight --production
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} bun scripts/buildRatingLeaderboard.ts --region=na --target=midnight --production
+5 -20
View File
@@ -12,32 +12,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Build Prisma
run: yarn prisma generate
run: bun prisma generate
- name: Build
run: yarn build:bot --production
run: bun build:bot --production
- name: Archive build
uses: actions/upload-artifact@v4
-43
View File
@@ -1,43 +0,0 @@
name: CI Build Docs
on: push
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Build
run: yarn build:docs
- name: Archive build
uses: actions/upload-artifact@v4
with:
name: docs
path: dist/docs
+4 -19
View File
@@ -12,29 +12,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: yarn
run: bun install
- name: Build
run: yarn build:website
run: bun build:website
- name: Archive build
uses: actions/upload-artifact@v4
BIN
View File
Binary file not shown.
+1
View File
@@ -15,3 +15,4 @@ BlitzKit is a one man band **TrèsAbhi** but I am far from solo.
| Tankopedia pre-release testing | [OPTML], Aeson, \_34, Droodles |
| Website advertisement platform | Aeson, Droodles |
| Compare pre-release testing | [OPTML], Droodles |
| Redacted | GonnaHetzMe |
+13
View File
@@ -0,0 +1,13 @@
# BlitzKit 1.20.4
Continuing the revamping UI train, this release tanks the tank search page.
## Changes
- Tanks are now displayed naked, not bounded by a button
- The filters are now far more compact, responsive, and intuitive
- The search bar is now faster and shows skeletons when loading
- The sort feature now displays the values of tanks used to sort underneath the tank's names
- Nations are now ordered in the way they are in-game
- When filtering tanks in testing, a work-in-progress warning is shown
- When there are no results, a proper message is shown
+8 -12
View File
@@ -5,31 +5,25 @@
"main": "src/server.ts",
"type": "module",
"scripts": {
"build:assets": "bun scripts/buildAssets/index.ts",
"build:averages": "bun scripts/averages",
"build:db": "prisma generate",
"build:bot": "vite-node --script scripts/build --production",
"build:website": "yarn build:db && yarn build:sitemap && next build",
"build:sitemap": "vite-node --script scripts/sitemap",
"build:bot": "bun scripts/build.ts --production",
"build:website": "bun build:db && bun build:sitemap && next build",
"build:sitemap": "bun scripts/sitemap.ts",
"build:docs": "vitepress build docs",
"build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
"test": "vite-node --script scripts/test",
"test2": "vite-node --script scripts/test2",
"build:ratingleaderboard": "bun --script scripts/buildRatingLeaderboard.ts",
"dev:bot": "vite-node --script scripts/build && yarn start:bot",
"dev:docs": "vitepress dev docs",
"dev:website": "next dev",
"start:bot": "node --enable-source-maps dist/bot/index.cjs",
"start:website": "next start",
"host:docs": "vitepress preview docs",
"publish:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard --publish",
"setup:patches": "vite-node --script scripts/applyPatches",
"lint": "npx tsc",
"lint:website": "next lint",
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\"",
"lint:version": "vite-node --script scripts/checkVersionMissMatch",
"knip": "knip",
"update": "yarn upgrade --latest",
"setup:depotdownloader": "vite-node --script scripts/setupDepotDownloader",
"tunnel": "cloudflared tunnel --url http://localhost:3000",
"discoverids": "scripts/discoverIds.ts"
},
@@ -47,8 +41,10 @@
"@resvg/resvg-js": "^2.4.1",
"@types/three": "^0.160.0",
"@vanilla-extract/css": "^1.13.0",
"@vanilla-extract/dynamic": "^2.1.1",
"adm-zip": "^0.5.12",
"bepaint": "^0.1.0-beta.3",
"csv-parse": "^5.5.6",
"decompress": "^4.2.1",
"discord.js": "^14.13.0",
"dotenv": "^16.3.1",
@@ -87,6 +83,7 @@
"devDependencies": {
"@total-typescript/ts-reset": "^0.5.1",
"@types/adm-zip": "^0.5.5",
"@types/bun": "^1.1.5",
"@types/decompress": "^4.2.7",
"@types/escape-html": "^1.0.2",
"@types/express": "^4.17.18",
@@ -105,7 +102,6 @@
"crc": "^4.3.2",
"fast-xml-parser": "^4.3.2",
"knip": "^3.4.0",
"lz4": "^0.6.5",
"prettier": "^3.0.3",
"prisma": "^5.4.1",
"typescript": "^5.4.5"
+4 -1
View File
@@ -17,7 +17,10 @@ export async function boosterIcons(production: boolean) {
await Promise.all(
boosterFiles.map(async (file) => {
const name = file.match(/booster_(.+).txt.dvpl/)![1];
const name = file.match(/booster_(.+).txt.dvpl/)?.[1];
if (name === undefined) return;
const sizes = (
await readStringDVPL(`${DATA}/${POI.boosterIcons}/${file}`)
)
+6 -2
View File
@@ -1,5 +1,6 @@
import { readdir } from 'fs/promises';
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkit/commitAssets';
import { FileChange } from '../../src/core/blitzkit/commitMultipleFiles';
import { DATA, POI } from './constants';
@@ -14,7 +15,10 @@ export async function circleFlags(production: boolean) {
!flag.endsWith('@2x.packed.webp.dvpl'),
)
.map(async (flag) => {
const content = await readBase64DVPL(`${DATA}/${POI.flags}/${flag}`);
const image = sharp(await readDVPLFile(`${DATA}/${POI.flags}/${flag}`));
const content = (
await image.trim({ threshold: 100 }).toBuffer()
).toString('base64');
const name = flag.match(/flag_profile-stat_(.+)\.packed\.webp/)![1];
return {
+13 -4
View File
@@ -353,11 +353,15 @@ export interface MapAlly {
points: Array<number[]>;
}
interface AvailableNationsYaml {
available_nations: string[];
}
const blitzShellKindToBlitzkit: Record<ShellKind, ShellType> = {
ARMOR_PIERCING: 'ap',
ARMOR_PIERCING_CR: 'ap_cr',
HIGH_EXPLOSIVE: 'he',
HOLLOW_CHARGE: 'hc',
ARMOR_PIERCING: ShellType.AP,
ARMOR_PIERCING_CR: ShellType.APCR,
HIGH_EXPLOSIVE: ShellType.HE,
HOLLOW_CHARGE: ShellType.HEAT,
};
const missingStrings: Record<string, string> = {
'#artefacts:tungstentip/name': 'Tungsten Shells',
@@ -378,6 +382,11 @@ export async function definitions(production: boolean) {
.split('.')
.slice(0, 3)
.join('.'),
nations: (
await readYAMLDVPL<AvailableNationsYaml>(
`${DATA}/available_nations.yaml.dvpl`,
)
).available_nations,
};
const tankDefinitions: TankDefinitions = {};
const modelDefinitions: ModelDefinitions = {};
+1 -1
View File
@@ -41,9 +41,9 @@ const methods = [
consumableProvisionIcons,
camouflageIcons,
boosterIcons,
tankIcons,
tankArmors,
tankModels,
tankIcons,
];
for (const method of methods) {
-12
View File
@@ -1,12 +0,0 @@
import { readdir } from 'fs/promises';
import packageJSON from '../package.json';
const { version } = packageJSON;
const changelogs = await readdir('docs/changelogs');
const hasVersion = changelogs.includes(`${version}.md`);
if (hasVersion) {
console.log(`Version ${version} found in changelogs`);
} else {
throw new Error(`Version ${version} not found in changelogs`);
}
@@ -343,7 +343,7 @@ export function Characteristics() {
{stats.dispersion}
</InfoWithDelta>
<InfoWithDelta
prefix="+ "
prefix="+"
decimals={3}
indent
name="Moving"
@@ -353,7 +353,7 @@ export function Characteristics() {
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
prefix="+"
indent
name="Hull traversing"
deltaType="lowerIsBetter"
@@ -362,7 +362,7 @@ export function Characteristics() {
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
prefix="+"
indent
name="Turret traversing"
deltaType="lowerIsBetter"
@@ -371,7 +371,7 @@ export function Characteristics() {
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
prefix="+"
indent
name="After shooting"
deltaType="lowerIsBetter"
@@ -502,7 +502,7 @@ export function Characteristics() {
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="km/hr" />
<Info name="Speed" unit="kph" />
<InfoWithDelta decimals={0} indent name="Forwards">
{stats.speedForwards}
</InfoWithDelta>
@@ -55,8 +55,11 @@ export function HeroSection() {
height: '100%',
}}
>
<Heading size={wideFormat ? '9' : '8'}>
{protagonistTank.name}
<Heading
size={wideFormat ? '9' : '8'}
align={wideFormat ? undefined : 'center'}
>
{protagonistTank.nameFull ?? protagonistTank.name}
</Heading>
<Text color="gray">
@@ -0,0 +1,300 @@
import { ReloadIcon } from '@radix-ui/react-icons';
import { Box, Flex, IconButton, Text } from '@radix-ui/themes';
import { times } from 'lodash';
import { use } from 'react';
import { classIcons } from '../../../../components/ClassIcon';
import { ExperimentIcon } from '../../../../components/ExperimentIcon';
import { ResearchedIcon } from '../../../../components/ResearchedIcon';
import { ScienceIcon } from '../../../../components/ScienceIcon';
import { ScienceOffIcon } from '../../../../components/ScienceOffIcon';
import { TANK_CLASSES } from '../../../../components/Tanks/components/Item/constants';
import { asset } from '../../../../core/blitzkit/asset';
import { gameDefinitions } from '../../../../core/blitzkit/gameDefinitions';
import { Tier } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import {
mutateTankopediaFilters,
useTankopediaFilters,
} from '../../../../stores/tankopediaFilters';
interface FilterControlProps {
compact?: boolean;
}
export function FilterControl({ compact }: FilterControlProps) {
const awaitedGameDefinitions = use(gameDefinitions);
const filters = useTankopediaFilters();
return (
<Flex height="fit-content" gap="2" align="start" justify="center">
<Flex
direction={compact ? 'row' : { initial: 'row', sm: 'column' }}
overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }}
>
<Flex direction={compact ? 'column' : { sm: 'row', initial: 'column' }}>
{times(5, (index) => {
const tier = (index + 1) as Tier;
const selected = filters.tier === tier;
return (
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.tier = draft.tier === tier ? undefined : tier;
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
);
})}
</Flex>
<Flex direction={compact ? 'column' : { sm: 'row', initial: 'column' }}>
{times(5, (index) => {
const tier = (index + 6) as Tier;
const selected = filters.tier === tier;
return (
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.tier = draft.tier === tier ? undefined : tier;
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
);
})}
</Flex>
</Flex>
<Flex
direction={compact ? 'row' : { initial: 'row', sm: 'column' }}
overflow="hidden"
style={{ borderRadius: 'var(--radius-5)' }}
>
<Flex direction={compact ? 'column' : { sm: 'row', initial: 'column' }}>
{awaitedGameDefinitions.nations
.slice(0, Math.ceil(awaitedGameDefinitions.nations.length / 2))
.map((nation) => {
const selected = filters.nation === nation;
return (
<IconButton
key={nation}
variant={selected ? 'solid' : 'soft'}
color={selected ? undefined : 'gray'}
highContrast
radius="none"
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.nation =
draft.nation === nation ? undefined : nation;
})
}
>
<img
style={{ width: '1em', height: '1em' }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
);
})}
</Flex>
<Flex direction={compact ? 'column' : { sm: 'row', initial: 'column' }}>
{awaitedGameDefinitions.nations
.slice(Math.ceil(awaitedGameDefinitions.nations.length / 2))
.map((nation) => {
const selected = filters.nation === nation;
return (
<IconButton
key={nation}
style={{ flex: 1 }}
variant={selected ? 'solid' : 'soft'}
color={selected ? undefined : 'gray'}
highContrast
radius="none"
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.nation =
draft.nation === nation ? undefined : nation;
})
}
>
<img
style={{ width: '1em', height: '1em' }}
src={asset(`flags/circle/${nation}.webp`)}
/>
</IconButton>
);
})}
</Flex>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
>
{TANK_CLASSES.map((tankClass) => {
const Icon = classIcons[tankClass];
const selected = filters.class === tankClass;
return (
<IconButton
key={tankClass}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.class =
draft.class === tankClass ? undefined : tankClass;
})
}
>
<Icon style={{ width: '1em', height: '1em' }} />
</IconButton>
);
})}
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
>
<IconButton
variant={filters.type === 'researchable' ? 'solid' : 'soft'}
radius="none"
color={filters.type === 'researchable' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.type =
draft.type === 'researchable' ? undefined : 'researchable';
})
}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</IconButton>
<IconButton
variant={filters.type === 'premium' ? 'solid' : 'soft'}
radius="none"
color={filters.type === 'premium' ? 'amber' : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.type = draft.type === 'premium' ? undefined : 'premium';
})
}
>
<Text
color={filters.type === 'premium' ? undefined : 'amber'}
style={{ display: 'flex', justifyContent: 'center' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
<IconButton
variant={filters.type === 'collector' ? 'solid' : 'soft'}
radius="none"
color={filters.type === 'collector' ? 'blue' : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.type = draft.type === 'collector' ? undefined : 'collector';
})
}
>
<Text
color={filters.type === 'collector' ? undefined : 'blue'}
style={{ display: 'flex', justifyContent: 'center' }}
>
<ResearchedIcon style={{ width: '1em', height: '1em' }} />
</Text>
</IconButton>
</Flex>
<Flex
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
direction={compact ? 'column' : { sm: 'row', initial: 'column' }}
>
<IconButton
variant={filters.testing === 'include' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'include' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.testing = 'include';
})
}
>
<ExperimentIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={filters.testing === 'exclude' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'exclude' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.testing = 'exclude';
})
}
>
<ScienceOffIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
<IconButton
variant={filters.testing === 'only' ? 'solid' : 'soft'}
radius="none"
color={filters.testing === 'only' ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.testing = 'only';
})
}
>
<ScienceIcon
style={{ width: '1em', height: '1em', color: 'currentColor' }}
/>
</IconButton>
</Flex>
<Box>
<IconButton
color="red"
onClick={() =>
useTankopediaFilters.setState(
useTankopediaFilters.getInitialState(),
true,
)
}
>
<ReloadIcon />
</IconButton>
</Box>
</Flex>
);
}
@@ -0,0 +1,30 @@
import { Flex, Link, Text } from '@radix-ui/themes';
import { useTankopediaFilters } from '../../../../stores/tankopediaFilters';
interface NoResultsProps {
type?: 'filters' | 'search';
}
export function NoResults({ type = 'filters' }: NoResultsProps) {
return (
<Flex flexGrow="1" align="center" justify="center">
<Text color="gray">
No tanks found.{' '}
<Link
href="#"
underline="always"
color="red"
onClick={() =>
useTankopediaFilters.setState(
useTankopediaFilters.getInitialState(),
true,
)
}
>
{type === 'filters' ? 'Try clearing filters' : 'Try searching again'}
</Link>
.
</Text>
</Flex>
);
}
@@ -0,0 +1,25 @@
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Link, TextField } from '@radix-ui/themes';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import { useTankopediaFilters } from '../../../../stores/tankopediaFilters';
interface QuickLinkProps {
topResult?: TankDefinition;
}
export function QuickLink({ topResult }: QuickLinkProps) {
const search = useTankopediaFilters((state) => state.search);
const searching = useTankopediaFilters((state) => state.searching);
if (!search || !topResult || searching) return null;
return (
<TextField.Slot>
<Link href={`/tools/tankopedia/${topResult.id}`}>
<Button variant="ghost">
{topResult.name} <CaretRightIcon />
</Button>
</Link>
</TextField.Slot>
);
}
@@ -0,0 +1,71 @@
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Flex, Spinner, TextField } from '@radix-ui/themes';
import { debounce } from 'lodash';
import { useRouter } from 'next/navigation';
import { KeyboardEventHandler, useCallback, useRef } from 'react';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import { useTankopediaFilters } from '../../../../stores/tankopediaFilters';
import { QuickLink } from './QuickLink';
import { Sort } from './Sort';
interface SearchBarProps {
topResult?: TankDefinition;
}
export function SearchBar({ topResult }: SearchBarProps) {
const router = useRouter();
const input = useRef<HTMLInputElement>(null);
const searching = useTankopediaFilters((state) => state.searching);
const performSearch = useCallback(
debounce(() => {
useTankopediaFilters.setState({ searching: false });
if (!input.current) return;
const sanitized = input.current.value.trim();
useTankopediaFilters.setState({
search: sanitized.length === 0 ? undefined : sanitized,
});
}, 500),
[],
);
const handleChange = useCallback(() => {
if (!useTankopediaFilters.getState().searching) {
useTankopediaFilters.setState({ searching: true });
}
performSearch();
}, []);
const handleKeyDown = useCallback<KeyboardEventHandler>(
(event) => {
if (event.key !== 'Enter' || !topResult || searching) return;
event.preventDefault();
router.push(`/tools/tankopedia/${topResult.id}`);
},
[topResult],
);
return (
<Flex justify="center">
<Flex gap="2" flexGrow="1">
<TextField.Root
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
onChange={handleChange}
onKeyDown={handleKeyDown}
>
<TextField.Slot>
{searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot>
<QuickLink topResult={topResult} />
</TextField.Root>
<Sort />
</Flex>
</Flex>
);
}
@@ -0,0 +1,10 @@
import { Flex, Skeleton } from '@radix-ui/themes';
export function SkeletonTankCard() {
return (
<Flex direction="column" gap="2">
<Skeleton height="64px" />
<Skeleton height="1em" />
</Flex>
);
}
@@ -1,49 +1,36 @@
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu } from '@radix-ui/themes';
import mutateTankopediaPersistent, {
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../../../../stores/tankopedia';
import { TankopediaSortDirection } from '../../../../stores/tankopedia';
import {
mutateTankopediaFilters,
useTankopediaFilters,
} from '../../../../stores/tankopediaFilters';
export function Sort() {
const sort = useTankopediaPersistent((state) => state.sort);
const sort = useTankopediaFilters((state) => state.sort);
return (
<DropdownMenu.Root>
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray">
<Button variant="surface" color="gray">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Meta</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.tier'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.tier';
});
}}
>
Tier
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.name'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.name';
});
}}
>
Name
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Content align="end">
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.none'}
onClick={() => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'meta.none';
});
}}
>
No sort
</DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Sort by</DropdownMenu.Label>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
@@ -51,7 +38,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dpm'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.dpm';
});
}}
@@ -61,7 +48,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.reload'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.reload';
});
}}
@@ -71,7 +58,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.caliber'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.caliber';
});
}}
@@ -81,7 +68,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.standardPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.standardPenetration';
});
}}
@@ -91,7 +78,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.premiumPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.premiumPenetration';
});
}}
@@ -101,7 +88,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.damage'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.damage';
});
}}
@@ -111,7 +98,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.shellVelocity'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.shellVelocity';
});
}}
@@ -121,7 +108,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.aimTime'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.aimTime';
});
}}
@@ -131,7 +118,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.dispersionStill';
});
}}
@@ -141,7 +128,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.dispersionMoving';
});
}}
@@ -151,7 +138,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunDepression'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.gunDepression';
});
}}
@@ -161,7 +148,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunElevation'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'fire.gunElevation';
});
}}
@@ -177,7 +164,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.forwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'maneuverability.forwardsSpeed';
});
}}
@@ -187,7 +174,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.backwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'maneuverability.backwardsSpeed';
});
}}
@@ -197,7 +184,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.power'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'maneuverability.power';
});
}}
@@ -207,7 +194,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.powerToWeight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'maneuverability.powerToWeight';
});
}}
@@ -217,7 +204,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.weight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'maneuverability.weight';
});
}}
@@ -227,7 +214,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.traverseSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'maneuverability.traverseSpeed';
});
}}
@@ -243,7 +230,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.health'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.health';
});
}}
@@ -253,7 +240,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.viewRange'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.viewRange';
});
}}
@@ -263,7 +250,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.camouflageStill';
});
}}
@@ -273,7 +260,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.camouflageMoving';
});
}}
@@ -283,7 +270,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageShooting'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.camouflageShooting';
});
}}
@@ -293,7 +280,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.volume'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.volume';
});
}}
@@ -303,7 +290,7 @@ export function Sort() {
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.by = 'survivability.length';
});
}}
@@ -313,13 +300,11 @@ export function Sort() {
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
mutateTankopediaFilters((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
@@ -0,0 +1,32 @@
import { createVar, style } from '@vanilla-extract/css';
export const cardBackgroundVar = createVar();
export const card = style({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 'var(--space-2)',
background: cardBackgroundVar,
});
export const image = style({
height: 64,
width: '100%',
objectPosition: 'center right',
objectFit: 'contain',
});
export const classIcon = style({
width: '1em',
height: '1em',
minWidth: '1em',
minHeight: '1em',
});
export const name = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
@@ -0,0 +1,185 @@
import { Flex, Text } from '@radix-ui/themes';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { use, useMemo } from 'react';
import { classIcons } from '../../../../components/ClassIcon';
import { Link } from '../../../../components/Link';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { asset } from '../../../../core/blitzkit/asset';
import { modelDefinitions } from '../../../../core/blitzkit/modelDefinitions';
import { normalizeBoundingBox } from '../../../../core/blitzkit/normalizeBoundingBox';
import { resolveDpm } from '../../../../core/blitzkit/resolveDpm';
import { resolveReload } from '../../../../core/blitzkit/resolveReload';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
import { unionBoundingBox } from '../../../../core/blitzkit/unionBoundingBox';
import { useTankopediaFilters } from '../../../../stores/tankopediaFilters';
import * as styles from './TankCard.css';
interface TankCardProps {
tank: TankDefinition;
onSelect?: (tank: TankDefinition) => void;
}
export function TankCard({ tank, onSelect }: TankCardProps) {
const awaitedModelDefinitions = use(modelDefinitions);
const Icon = classIcons[tank.class];
// const link = useRef<HTMLAnchorElement>(null);
const sortBy = useTankopediaFilters((state) => state.sort.by);
const discriminator = useMemo(() => {
if (sortBy.startsWith('meta')) return undefined;
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const shell0 = gun.shells[0];
const shell1 = gun.shells[1];
const tracks = tank.tracks.at(-1)!;
const engine = tank.engines.at(-1)!;
const tankModelDefinition = awaitedModelDefinitions[tank.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
switch (sortBy) {
case 'fire.aimTime':
return gun.aimTime.toFixed(2);
case 'fire.caliber':
return shell0.caliber.toFixed(0);
case 'fire.damage':
return shell0.damage.armor.toFixed(0);
case 'fire.dispersionMoving':
return tracks.dispersion.move.toFixed(3);
case 'fire.dispersionStill':
return gun.dispersion.base.toFixed(3);
case 'fire.dpm':
return resolveDpm(gun, shell0).toFixed(0);
case 'fire.reload':
return resolveReload(gun).toFixed(2);
case 'fire.standardPenetration':
return resolveNearPenetration(shell0.penetration).toFixed(0);
case 'fire.premiumPenetration':
return shell1
? resolveNearPenetration(shell1.penetration).toFixed(0)
: '--';
case 'fire.shellVelocity':
return shell0.speed.toFixed(0);
case 'fire.gunDepression':
return (
gunModelDefinition.pitch.max +
(tankModelDefinition.turretRotation?.pitch ?? 0)
).toFixed(1);
case 'fire.gunElevation':
return (
-gunModelDefinition.pitch.min -
(tankModelDefinition.turretRotation?.pitch ?? 0)
).toFixed(1);
case 'maneuverability.forwardsSpeed':
return tank.speed.forwards.toFixed(0);
case 'maneuverability.backwardsSpeed':
return tank.speed.backwards.toFixed(0);
case 'maneuverability.power':
return engine.power.toFixed(0);
case 'maneuverability.powerToWeight':
return (
(1000 * engine.power) /
(tank.weight +
engine.weight +
tracks.weight +
turret.weight +
gun.weight)
).toFixed(1);
case 'maneuverability.weight':
return (
(tank.weight +
engine.weight +
tracks.weight +
turret.weight +
gun.weight) /
1000
).toFixed(1);
case 'maneuverability.traverseSpeed':
return tracks.traverseSpeed.toFixed(1);
case 'survivability.health':
return (tank.health + turret.health).toFixed(0);
case 'survivability.viewRange':
return turret.viewRange.toFixed(0);
case 'survivability.camouflageStill':
return (tank.camouflage.still * 100).toFixed(0);
case 'survivability.camouflageMoving':
return (tank.camouflage.moving * 100).toFixed(0);
case 'survivability.camouflageShooting':
return (tank.camouflage.still * gun.camouflageLoss * 100).toFixed(0);
case 'survivability.volume': {
const dimensions = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.boundingBox,
turretModelDefinition.boundingBox,
),
);
return (dimensions[0] * dimensions[1] * dimensions[2]).toFixed(0);
}
case 'survivability.length':
return Math.max(
...normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.boundingBox,
turretModelDefinition.boundingBox,
),
),
).toFixed(0);
}
}, [sortBy]);
return (
<Link
tabIndex={onSelect ? 0 : undefined}
size="1"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: 'gray'
}
highContrast={tank.treeType === 'researchable'}
underline="hover"
href={onSelect ? undefined : `/tools/tankopedia/${tank.id}`}
onClick={onSelect ? () => onSelect(tank) : undefined}
className={styles.card}
style={assignInlineVars({
[styles.cardBackgroundVar]: `top left / contain no-repeat url(${asset(`flags/scratched/${tank.nation}.webp`)})`,
})}
>
<img alt={tank.name} src={tankIcon(tank.id)} className={styles.image} />
<Flex
justify="center"
gap="1"
align="center"
overflow="hidden"
width="100%"
maxWidth="100%"
>
<Icon className={styles.classIcon} />
<Text align="center" className={styles.name}>
{tank.name}
</Text>
</Flex>
{discriminator && (
<Text
mt="-2"
color="gray"
align="center"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{discriminator}
</Text>
)}
</Link>
);
}
@@ -0,0 +1,14 @@
import { Grid, GridProps } from '@radix-ui/themes';
export function TankCardWrapper(props: GridProps) {
return (
<Grid
py="4"
columns="repeat(auto-fill, minmax(100px, 1fr))"
flow="dense"
gap="2"
gapY="6"
{...props}
/>
);
}
@@ -1,51 +0,0 @@
import { Button, Flex, Text } from '@radix-ui/themes';
import { SmallTankIcon } from '../../../../../../components/SmallTankIcon';
import { TankDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
interface CompactSearchResultRowProps {
tanks: TankDefinition[];
onSelect?: (tank: TankDefinition) => void;
}
export function CompactSearchResultRow({
tanks,
onSelect = () => {},
}: CompactSearchResultRowProps) {
return (
<Flex justify="center">
<Flex direction="column" gap="2" align="start">
{tanks.map((tank) => (
<Button
key={tank.id}
variant="ghost"
style={{ width: '100%' }}
onClick={() => onSelect(tank)}
>
<Flex gap="2" align="start" style={{ width: '100%' }}>
<Text align="right" color="gray" style={{ width: 24 }}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
<Text color={tank.treeType === 'collector' ? 'blue' : 'amber'}>
<Flex gap="2" align="center">
<SmallTankIcon id={tank.id} size={16} />
<div
style={{
display: 'block',
color:
tank.treeType === 'researchable' ? 'white' : undefined,
}}
>
{tank.name}
</div>
</Flex>
</Text>
</Flex>
</Button>
))}
</Flex>
</Flex>
);
}
@@ -1,17 +0,0 @@
import { style } from '@vanilla-extract/css';
export const splitFilters = style({
'@media': {
'screen and (min-width: 513px)': {
display: 'none',
},
},
});
export const oneLineFilters = style({
'@media': {
'screen and (max-width: 512px)': {
display: 'none',
},
},
});
@@ -1,341 +0,0 @@
'use client';
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { use } from 'react';
import { Flag } from '../../../../../../../components/Flag';
import {} from '../../../../../../../components/Tanks';
import {
TANK_CLASSES,
TREE_TYPES,
TREE_TYPE_ICONS,
TREE_TYPE_IMAGES,
} from '../../../../../../../components/Tanks/components/Item/constants';
import { NATIONS } from '../../../../../../../core/blitzkit/tankDefinitions';
import {
TIERS,
TIER_ROMAN_NUMERALS,
} from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import mutateTankopediaPersistent, {
TankopediaTestTankDisplay,
useTankopediaPersistent,
} from '../../../../../../../stores/tankopedia';
import * as styles from './index.css';
export function Options() {
const nations = use(NATIONS);
const filters = useTankopediaPersistent((state) => state.filters);
return (
<Flex gap="2" wrap="wrap" justify="center">
<Flex>
{TREE_TYPES.map((type, index) => (
<Button
key={type}
color={filters.treeTypes.includes(type) ? undefined : 'gray'}
variant={filters.treeTypes.includes(type) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === TREE_TYPES.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TREE_TYPES.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.treeTypes.includes(type)) {
draft.filters.treeTypes = draft.filters.treeTypes.filter(
(preexistingType) => preexistingType !== type,
);
} else {
draft.filters.treeTypes.push(type);
}
})
}
>
<img
alt={type}
src={TREE_TYPE_IMAGES[type]}
style={{ width: '1em', height: '1em' }}
/>
</Button>
))}
</Flex>
<Flex className={styles.oneLineFilters}>
{nations.map((nation, index) => (
<Button
key={nation}
color={filters.nations.includes(nation) ? undefined : 'gray'}
variant={filters.nations.includes(nation) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === nations.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === nations.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.nations.includes(nation)) {
draft.filters.nations = draft.filters.nations.filter(
(preexistingType) => preexistingType !== nation,
);
} else {
draft.filters.nations.push(nation);
}
})
}
>
<Flag nation={nation} />
</Button>
))}
</Flex>
<Flex>
{[...TANK_CLASSES].map((type, index) => (
<Button
key={type}
color={filters.types.includes(type) ? undefined : 'gray'}
variant={filters.types.includes(type) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius:
index === TANK_CLASSES.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TANK_CLASSES.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.types.includes(type)) {
draft.filters.types = draft.filters.types.filter(
(preexistingType) => preexistingType !== type,
);
} else {
draft.filters.types.push(type);
}
})
}
>
<img
alt={type}
src={TREE_TYPE_ICONS.researchable[type]}
style={{ width: '1em', height: '1em' }}
/>
</Button>
))}
</Flex>
<Flex className={styles.oneLineFilters}>
{[...TIERS].map((tier, index) => (
<Button
key={tier}
color={filters.tiers.includes(tier) ? undefined : 'gray'}
variant={filters.tiers.includes(tier) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius: index === TIERS.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === TIERS.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.tiers.includes(tier)) {
draft.filters.tiers = draft.filters.tiers.filter(
(preexistingTier) => preexistingTier !== tier,
);
} else {
draft.filters.tiers.push(tier);
}
})
}
>
{TIER_ROMAN_NUMERALS[tier]}
</Button>
))}
</Flex>{' '}
<Flex
direction="column"
className={styles.splitFilters}
style={{ width: '100%' }}
>
<Flex>
{[...TIERS].slice(0, 5).map((tier, index) => (
<Button
key={tier}
color={filters.tiers.includes(tier) ? undefined : 'gray'}
variant={filters.tiers.includes(tier) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? 16 : 0,
borderBottomLeftRadius: 0,
borderTopRightRadius:
index === TIERS.length - Math.round(TIERS.length / 2) - 1
? 16
: 0,
borderBottomRightRadius: 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.tiers.includes(tier)) {
draft.filters.tiers = draft.filters.tiers.filter(
(preexistingTier) => preexistingTier !== tier,
);
} else {
draft.filters.tiers.push(tier);
}
})
}
>
{TIER_ROMAN_NUMERALS[tier]}
</Button>
))}
</Flex>
<Flex>
{[...TIERS].slice(5).map((tier, index) => (
<Button
key={tier}
color={filters.tiers.includes(tier) ? undefined : 'gray'}
variant={filters.tiers.includes(tier) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: 0,
borderBottomLeftRadius: index === 0 ? 16 : 0,
borderTopRightRadius: 0,
borderBottomRightRadius:
index === TIERS.length - Math.round(TIERS.length / 2) - 1
? 16
: 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.tiers.includes(tier)) {
draft.filters.tiers = draft.filters.tiers.filter(
(preexistingTier) => preexistingTier !== tier,
);
} else {
draft.filters.tiers.push(tier);
}
})
}
>
{TIER_ROMAN_NUMERALS[tier]}
</Button>
))}
</Flex>
</Flex>
<Flex
direction="column"
className={styles.splitFilters}
style={{ width: '100%' }}
>
<Flex>
{nations.slice(0, 5).map((nation, index) => (
<Button
key={nation}
color={filters.nations.includes(nation) ? undefined : 'gray'}
variant={filters.nations.includes(nation) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: index === 0 ? 16 : 0,
borderBottomLeftRadius: 0,
borderTopRightRadius:
index === nations.length - Math.round(nations.length / 2)
? 16
: 0,
borderBottomRightRadius:
index === nations.length - 1 ? undefined : 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.nations.includes(nation)) {
draft.filters.nations = draft.filters.nations.filter(
(preexistingType) => preexistingType !== nation,
);
} else {
draft.filters.nations.push(nation);
}
})
}
>
<Flag nation={nation} />
</Button>
))}
</Flex>
<Flex>
{nations.slice(5).map((nation, index) => (
<Button
key={nation}
color={filters.nations.includes(nation) ? undefined : 'gray'}
variant={filters.nations.includes(nation) ? 'surface' : 'soft'}
style={{
flex: 1,
margin: -0.5,
borderTopLeftRadius: 0,
borderBottomLeftRadius: index === 0 ? 16 : 0,
borderTopRightRadius:
index === nations.length - 1 ? undefined : 0,
borderBottomRightRadius:
index === nations.length - Math.round(nations.length / 2) - 1
? 16
: 0,
}}
onClick={() =>
mutateTankopediaPersistent((draft) => {
if (draft.filters.nations.includes(nation)) {
draft.filters.nations = draft.filters.nations.filter(
(preexistingType) => preexistingType !== nation,
);
} else {
draft.filters.nations.push(nation);
}
})
}
>
<Flag nation={nation} />
</Button>
))}
</Flex>
</Flex>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray">
Test tanks
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={filters.test}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.filters.test = value as TankopediaTestTankDisplay;
})
}
>
<DropdownMenu.RadioItem value="include">
Include
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="exclude">
Exclude
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="only">Only</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
);
}
@@ -1,175 +0,0 @@
import { slateDark } from '@radix-ui/colors';
import { Card, Flex, Inset, Link, Text } from '@radix-ui/themes';
import { asset } from '../../../../../../core/blitzkit/asset';
import { TankDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
} from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../../core/blitzkit/tankIcon';
import { theme } from '../../../../../../stitches.config';
import * as styles from '../../../page.css';
import { CompactSearchResultRow } from './CompactSearchResultRow';
interface ResultsProps {
compact?: boolean;
results: TankDefinition[];
onSelect?: (tank: TankDefinition) => void;
}
export function Results({ compact, results, onSelect }: ResultsProps) {
const firstChunk = compact
? results.slice(0, Math.ceil(results.length / 2))
: [];
const secondChunk = compact
? results.slice(Math.ceil(results.length / 2))
: [];
return (
<Flex
wrap="wrap"
gap="2"
style={{
justifyContent:
compact && firstChunk.length > 0 && secondChunk.length > 0
? 'space-around'
: 'center',
}}
>
{!compact &&
results.map((tank) => (
<Link
key={tank.id}
className={styles.listing}
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
minWidth: 256,
}}
>
<Card
key={tank.id}
style={{
cursor: 'pointer',
height: 64,
width: '100%',
}}
>
<Inset
key={tank.id}
style={{
minHeight: 64,
position: 'relative',
display: 'flex',
}}
>
<img
alt={tank.name}
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
alt={tank.name}
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
alt={tank.name}
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Link>
))}
{compact && firstChunk.length > 0 && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)}
{compact && secondChunk.length > 0 && (
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)}
</Flex>
);
}
@@ -0,0 +1,7 @@
import { TreeType } from '../../../../../components/Tanks';
export const treeTypeOrder: TreeType[] = [
'researchable',
'premium',
'collector',
];
@@ -1,554 +1,444 @@
import {
CaretRightIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
Flex,
Spinner,
Text,
TextField,
} from '@radix-ui/themes';
import { Callout, Flex, FlexProps, Link, Text } from '@radix-ui/themes';
import { go } from 'fuzzysort';
import { use, useEffect, useMemo, useRef, useState } from 'react';
import { isMobileOnly } from 'react-device-detect';
import { TreeType } from '../../../../../components/Tanks';
import { times } from 'lodash';
import { memo, use, useMemo } from 'react';
import { AdMidSectionResponsive } from '../../../../../components/AdMidSectionResponsive';
import { ExperimentIcon } from '../../../../../components/ExperimentIcon';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
import { gameDefinitions } from '../../../../../core/blitzkit/gameDefinitions';
import { modelDefinitions } from '../../../../../core/blitzkit/modelDefinitions';
import { normalizeBoundingBox } from '../../../../../core/blitzkit/normalizeBoundingBox';
import { resolveDpm } from '../../../../../core/blitzkit/resolveDpm';
import { resolveReload } from '../../../../../core/blitzkit/resolveReload';
import {
NATIONS,
TankDefinition,
tankDefinitions,
tankNames,
tanksDefinitionsArray,
} from '../../../../../core/blitzkit/tankDefinitions';
import { tankopediaFilterTank } from '../../../../../core/blitzkit/tankopediaFilterTank';
import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox';
import mutateTankopediaPersistent, {
SORT_NAMES,
useTankopediaPersistent,
} from '../../../../../stores/tankopedia';
import { PageTurner } from '../PageTurner';
import { Options } from './components/Options';
import { Results } from './components/Results';
import { Sort } from './components/Sort';
import { useAdExempt } from '../../../../../hooks/useAdExempt';
import { SORT_NAMES, SORT_UNITS } from '../../../../../stores/tankopedia';
import { useTankopediaFilters } from '../../../../../stores/tankopediaFilters';
import { FilterControl } from '../FilterControl';
import { NoResults } from '../NoResults';
import { SearchBar } from '../SearchBar';
import { SkeletonTankCard } from '../SkeletonTankCard';
import { TankCard } from '../TankCard';
import { TankCardWrapper } from '../TankCardWrapper';
import { treeTypeOrder } from './constants';
interface TankSearchProps {
type TankSearchProps = Omit<FlexProps, 'onSelect'> & {
compact?: boolean;
onSelect?: (tank: TankDefinition) => void;
onSelectAll?: (tanks: TankDefinition[]) => void;
}
};
const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector'];
export function TankSearch({
compact,
onSelect = () => {},
onSelectAll,
}: TankSearchProps) {
const tanksPerPage = (compact ? 16 : 96) * (isMobileOnly ? 0.5 : 1);
const nations = use(NATIONS);
const filters = useTankopediaPersistent((state) => state.filters);
const sort = useTankopediaPersistent((state) => state.sort);
const awaitedTanks = use(tanksDefinitionsArray);
const awaitedModelDefinitions = use(modelDefinitions);
const [loaded, setLoaded] = useState(false);
const defaultSortedTanks = useMemo(
() =>
awaitedTanks
.sort(
(a, b) =>
(treeTypeOrder.indexOf(a.treeType) -
treeTypeOrder.indexOf(b.treeType)) *
(sort.direction === 'descending' ? -1 : 1),
)
.sort(
(a, b) =>
(TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class)) *
(sort.direction === 'descending' ? -1 : 1),
)
.sort(
(a, b) =>
(nations.indexOf(a.nation) - nations.indexOf(b.nation)) *
(sort.direction === 'descending' ? -1 : 1),
),
[],
);
const input = useRef<HTMLInputElement>(null);
const searchableTanks = useMemo(() => {
const filtered = defaultSortedTanks.filter(
(tank) =>
(filters.tiers.length === 0
? true
: filters.tiers.includes(tank.tier)) &&
(filters.types.length === 0
? true
: filters.types.includes(tank.class)) &&
(filters.treeTypes.length === 0
? true
: filters.treeTypes.includes(tank.treeType)) &&
(filters.nations.length === 0
? true
: filters.nations.includes(tank.nation)) &&
(filters.test === 'include'
? true
: filters.test === 'exclude'
? !tank.testing
: tank.testing),
);
let sorted: TankDefinition[] = [];
switch (sort.by) {
case 'meta.tier':
sorted = filtered.sort((a, b) => a.tier - b.tier);
break;
case 'meta.name':
sorted = filtered.sort((a, b) => a.name?.localeCompare(b.name));
break;
case 'survivability.health': {
sorted = filtered.sort((a, b) => {
const aHealth = a.health + a.turrets.at(-1)!.health;
const bHealth = b.health + b.turrets.at(-1)!.health;
return aHealth - bHealth;
});
break;
}
case 'survivability.viewRange':
sorted = filtered.sort(
(a, b) => a.turrets.at(-1)!.viewRange - b.turrets.at(-1)!.viewRange,
export const TankSearch = memo<TankSearchProps>(
({ compact, onSelect, onSelectAll, ...props }) => {
const exempt = useAdExempt();
const awaitedGameDefinitions = use(gameDefinitions);
const awaitedModelDefinitions = use(modelDefinitions);
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedTanksDefinitionsArray = use(tanksDefinitionsArray);
const awaitedTankNames = use(tankNames);
const filters = useTankopediaFilters();
const tanks = useMemo(() => {
if (filters.search === undefined) {
const filtered = awaitedTanksDefinitionsArray.filter((tank) =>
tankopediaFilterTank(filters, tank),
);
break;
let sorted: TankDefinition[];
case 'survivability.camouflageStill':
sorted = filtered.sort(
(a, b) => a.camouflage.still - b.camouflage.still,
);
break;
switch (filters.sort.by) {
case 'meta.none':
sorted = filtered
.sort((a, b) => b.tier - a.tier)
.sort(
(a, b) =>
treeTypeOrder.indexOf(b.treeType) -
treeTypeOrder.indexOf(a.treeType),
)
.sort(
(a, b) =>
TANK_CLASSES.indexOf(b.class) - TANK_CLASSES.indexOf(a.class),
)
.sort(
(a, b) =>
awaitedGameDefinitions.nations.indexOf(b.nation) -
awaitedGameDefinitions.nations.indexOf(a.nation),
);
break;
case 'survivability.camouflageMoving':
sorted = filtered.sort(
(a, b) => a.camouflage.moving - b.camouflage.moving,
);
break;
case 'survivability.health': {
sorted = filtered.sort((a, b) => {
const aHealth = a.health + a.turrets.at(-1)!.health;
const bHealth = b.health + b.turrets.at(-1)!.health;
case 'survivability.camouflageShooting':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.camouflageLoss -
b.turrets.at(-1)!.guns.at(-1)!.camouflageLoss,
);
break;
return aHealth - bHealth;
});
break;
}
case 'survivability.volume':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aVolume = aSize[0] * aSize[1] * aSize[2];
const bVolume = bSize[0] * bSize[1] * bSize[2];
case 'survivability.viewRange':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.viewRange - b.turrets.at(-1)!.viewRange,
);
break;
return aVolume - bVolume;
});
break;
case 'survivability.camouflageStill':
sorted = filtered.sort(
(a, b) => a.camouflage.still - b.camouflage.still,
);
break;
case 'survivability.length':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aLength = Math.max(aSize[0], aSize[1], aSize[2]);
const bLength = Math.max(bSize[0], bSize[1], bSize[2]);
case 'survivability.camouflageMoving':
sorted = filtered.sort(
(a, b) => a.camouflage.moving - b.camouflage.moving,
);
break;
return aLength - bLength;
});
break;
case 'survivability.camouflageShooting':
sorted = filtered.sort(
(a, b) =>
a.camouflage.still *
a.turrets.at(-1)!.guns.at(-1)!.camouflageLoss -
b.camouflage.still *
b.turrets.at(-1)!.guns.at(-1)!.camouflageLoss,
);
break;
case 'fire.dpm':
sorted = filtered.sort(
(a, b) =>
resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[0],
) -
resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[0],
),
);
break;
case 'survivability.volume':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aVolume = aSize[0] * aSize[1] * aSize[2];
const bVolume = bSize[0] * bSize[1] * bSize[2];
case 'fire.reload':
sorted = filtered.sort(
(a, b) =>
resolveReload(a.turrets.at(-1)!.guns.at(-1)!) -
resolveReload(b.turrets.at(-1)!.guns.at(-1)!),
);
break;
return aVolume - bVolume;
});
break;
case 'fire.caliber':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber,
);
break;
case 'survivability.length':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aLength = Math.max(aSize[0], aSize[1], aSize[2]);
const bLength = Math.max(bSize[0], bSize[1], bSize[2]);
case 'fire.standardPenetration':
sorted = filtered.sort(
(a, b) =>
resolveNearPenetration(
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
) -
resolveNearPenetration(
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
),
);
break;
return aLength - bLength;
});
break;
case 'fire.premiumPenetration':
sorted = filtered.sort(
(a, b) =>
resolveNearPenetration(
(
a.turrets.at(-1)!.guns.at(-1)!.shells[1] ??
a.turrets.at(-1)!.guns.at(-1)!.shells[0]
).penetration,
) -
resolveNearPenetration(
(
b.turrets.at(-1)!.guns.at(-1)!.shells[1] ??
b.turrets.at(-1)!.guns.at(-1)!.shells[0]
).penetration,
),
);
break;
case 'fire.dpm':
sorted = filtered.sort(
(a, b) =>
resolveDpm(
a.turrets.at(-1)!.guns.at(-1)!,
a.turrets.at(-1)!.guns.at(-1)!.shells[0],
) -
resolveDpm(
b.turrets.at(-1)!.guns.at(-1)!,
b.turrets.at(-1)!.guns.at(-1)!.shells[0],
),
);
break;
case 'fire.damage':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].damage.armor -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].damage.armor,
);
break;
case 'fire.reload':
sorted = filtered.sort(
(a, b) =>
resolveReload(a.turrets.at(-1)!.guns.at(-1)!) -
resolveReload(b.turrets.at(-1)!.guns.at(-1)!),
);
break;
case 'fire.shellVelocity':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].speed -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].speed,
);
break;
case 'fire.caliber':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].caliber,
);
break;
case 'fire.aimTime':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.aimTime -
b.turrets.at(-1)!.guns.at(-1)!.aimTime,
);
break;
case 'fire.standardPenetration':
sorted = filtered.sort(
(a, b) =>
resolveNearPenetration(
a.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
) -
resolveNearPenetration(
b.turrets.at(-1)!.guns.at(-1)!.shells[0].penetration,
),
);
break;
case 'fire.dispersionStill':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.dispersion.base -
b.turrets.at(-1)!.guns.at(-1)!.dispersion.base,
);
break;
case 'fire.premiumPenetration':
sorted = filtered.sort(
(a, b) =>
resolveNearPenetration(
a.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration ?? 0,
) -
resolveNearPenetration(
b.turrets.at(-1)!.guns.at(-1)!.shells[1]?.penetration ?? 0,
),
);
break;
case 'fire.dispersionMoving':
sorted = filtered.sort(
(a, b) =>
a.tracks.at(-1)!.dispersion.move - b.tracks.at(-1)!.dispersion.move,
);
break;
case 'fire.damage':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].damage.armor -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].damage.armor,
);
break;
case 'fire.gunDepression':
sorted = filtered.sort(
(a, b) =>
awaitedModelDefinitions[a.id].turrets[a.turrets.at(-1)!.id].guns[
a.turrets.at(-1)!.guns.at(-1)!.id
].pitch.max -
awaitedModelDefinitions[b.id].turrets[b.turrets.at(-1)!.id].guns[
b.turrets.at(-1)!.guns.at(-1)!.id
].pitch.max,
);
break;
case 'fire.shellVelocity':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.shells[0].speed -
b.turrets.at(-1)!.guns.at(-1)!.shells[0].speed,
);
break;
case 'fire.gunElevation':
sorted = filtered.sort(
(a, b) =>
// reversed because of negative values
awaitedModelDefinitions[b.id].turrets[b.turrets.at(-1)!.id].guns[
b.turrets.at(-1)!.guns.at(-1)!.id
].pitch.min -
awaitedModelDefinitions[a.id].turrets[a.turrets.at(-1)!.id].guns[
a.turrets.at(-1)!.guns.at(-1)!.id
].pitch.min,
);
break;
case 'fire.aimTime':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.aimTime -
b.turrets.at(-1)!.guns.at(-1)!.aimTime,
);
break;
case 'maneuverability.forwardsSpeed':
sorted = filtered.sort((a, b) => a.speed.forwards - b.speed.forwards);
break;
case 'fire.dispersionStill':
sorted = filtered.sort(
(a, b) =>
a.turrets.at(-1)!.guns.at(-1)!.dispersion.base -
b.turrets.at(-1)!.guns.at(-1)!.dispersion.base,
);
break;
case 'maneuverability.backwardsSpeed':
sorted = filtered.sort((a, b) => a.speed.backwards - b.speed.backwards);
break;
case 'fire.dispersionMoving':
sorted = filtered.sort(
(a, b) =>
a.tracks.at(-1)!.dispersion.move -
b.tracks.at(-1)!.dispersion.move,
);
break;
case 'maneuverability.power':
sorted = filtered.sort(
(a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power,
);
break;
case 'fire.gunDepression':
sorted = filtered.sort(
(a, b) =>
awaitedModelDefinitions[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.max +
(awaitedModelDefinitions[a.id].turretRotation?.pitch ?? 0) -
awaitedModelDefinitions[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.max -
(awaitedModelDefinitions[b.id].turretRotation?.pitch ?? 0),
);
break;
case 'maneuverability.powerToWeight':
sorted = filtered.sort(
(a, b) =>
a.engines.at(-1)!.power /
(a.weight +
case 'fire.gunElevation':
sorted = filtered.sort(
(a, b) =>
awaitedModelDefinitions[b.id].turrets[b.turrets.at(-1)!.id]
.guns[b.turrets.at(-1)!.guns.at(-1)!.id].pitch.min +
(awaitedModelDefinitions[b.id].turretRotation?.pitch ?? 0) -
awaitedModelDefinitions[a.id].turrets[a.turrets.at(-1)!.id]
.guns[a.turrets.at(-1)!.guns.at(-1)!.id].pitch.min -
(awaitedModelDefinitions[a.id].turretRotation?.pitch ?? 0),
);
break;
case 'maneuverability.forwardsSpeed':
sorted = filtered.sort(
(a, b) => a.speed.forwards - b.speed.forwards,
);
break;
case 'maneuverability.backwardsSpeed':
sorted = filtered.sort(
(a, b) => a.speed.backwards - b.speed.backwards,
);
break;
case 'maneuverability.power':
sorted = filtered.sort(
(a, b) => a.engines.at(-1)!.power - b.engines.at(-1)!.power,
);
break;
case 'maneuverability.powerToWeight':
sorted = filtered.sort(
(a, b) =>
a.engines.at(-1)!.power /
(a.weight +
a.engines.at(-1)!.weight +
a.tracks.at(-1)!.weight +
a.turrets.at(-1)!.weight +
a.turrets.at(-1)!.guns.at(-1)!.weight) -
b.engines.at(-1)!.power /
(b.weight +
b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight),
);
break;
case 'maneuverability.weight':
sorted = filtered.sort(
(a, b) =>
a.weight +
a.engines.at(-1)!.weight +
a.tracks.at(-1)!.weight +
a.turrets.at(-1)!.weight +
a.turrets.at(-1)!.guns.at(-1)!.weight) -
b.engines.at(-1)!.power /
(b.weight +
b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight),
a.turrets.at(-1)!.guns.at(-1)!.weight -
(b.weight +
b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight),
);
break;
case 'maneuverability.traverseSpeed':
sorted = filtered.sort(
(a, b) =>
a.tracks.at(-1)!.traverseSpeed - b.tracks.at(-1)!.traverseSpeed,
);
break;
}
return filters.sort.direction === 'ascending'
? sorted
: sorted.reverse();
} else {
const searchedRaw = go(filters.search, awaitedTankNames, {
key: 'combined',
limit: 25,
});
const searchedTanks = searchedRaw.map(
(result) => awaitedTankDefinitions[result.obj.id],
);
break;
return searchedTanks;
}
}, [filters]);
case 'maneuverability.weight':
sorted = filtered.sort(
(a, b) =>
a.weight +
a.engines.at(-1)!.weight +
a.tracks.at(-1)!.weight +
a.turrets.at(-1)!.weight +
a.turrets.at(-1)!.guns.at(-1)!.weight -
(b.weight +
b.engines.at(-1)!.weight +
b.tracks.at(-1)!.weight +
b.turrets.at(-1)!.weight +
b.turrets.at(-1)!.guns.at(-1)!.weight),
);
break;
case 'maneuverability.traverseSpeed':
sorted = filtered.sort(
(a, b) =>
a.tracks.at(-1)!.traverseSpeed - b.tracks.at(-1)!.traverseSpeed,
);
break;
}
return sort.direction === 'ascending' ? sorted : sorted.reverse();
}, [
sort,
...Object.entries(filters)
.filter(([name]) => name !== 'page')
.map(([, value]) => value),
]);
const [searchQuery, setSearchQuery] = useState('');
const searchResults = useMemo(
() =>
go(searchQuery, searchableTanks, {
keys: ['name', 'nameFull', 'id'] satisfies (keyof TankDefinition)[],
all: true,
}).map(({ obj }) => obj),
[searchQuery, searchableTanks],
);
const page = useTankopediaPersistent((state) => state.filters.page);
const searchResultsPageSlice = searchResults.slice(
page * tanksPerPage,
(page + 1) * tanksPerPage,
);
useEffect(() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.min(
Math.max(0, page),
Math.ceil(searchResults.length / tanksPerPage) - 1,
);
});
}, [searchResults]);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded) {
return (
<Flex justify="center">
<Spinner />
<Flex direction="column" gap="4" flexGrow="1" {...props}>
<SearchBar topResult={tanks?.[0]} />
{!filters.search && !filters.searching && (
<FilterControl compact={compact} />
)}
{(filters.sort.by !== 'meta.none' || onSelectAll) && (
<Flex
py="2"
mt="2"
direction="column"
align="center"
justify="center"
>
{filters.sort.by !== 'meta.none' && (
<Text color="gray">
Sorting by {SORT_NAMES[filters.sort.by]}
{SORT_UNITS[filters.sort.by] === undefined
? ''
: ` (${SORT_UNITS[filters.sort.by]})`}
, {filters.sort.direction}
</Text>
)}
{onSelectAll && (
<Link
underline="always"
href="#"
onClick={() => onSelectAll(tanks)}
>
Select all {tanks.length} tanks
</Link>
)}
</Flex>
)}
{filters.testing === 'only' && (
<Flex justify="center" mt="4">
<Callout.Root color="amber">
<Callout.Icon>
<ExperimentIcon style={{ width: '1em', height: '1em' }} />
</Callout.Icon>
<Callout.Text>
Tanks in testing are subject to change and many not represent
the final product.
</Callout.Text>
</Callout.Root>
</Flex>
)}
{!filters.searching && (
<>
{tanks.length > 0 && (
<TankCardWrapper>
{tanks.map((tank, index) => (
<>
<TankCard onSelect={onSelect} key={tank.id} tank={tank} />
{!exempt &&
(index + 16) % 32 === 0 &&
index !== tanks.length - 1 && (
<AdMidSectionResponsive gridColumn="1 / -1" />
)}
</>
))}
</TankCardWrapper>
)}
{tanks.length === 0 && <NoResults type="search" />}
</>
)}
{filters.searching && (
<TankCardWrapper>
{times(Math.round(10 + 10 * Math.random()), (index) => (
<SkeletonTankCard key={index} />
))}
</TankCardWrapper>
)}
</Flex>
);
}
return (
<>
<Flex direction="column" gap="4">
<Flex gap="2">
<TextField.Root
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
setSearchQuery(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (
searchResults.length > 0 &&
input.current &&
input.current.value.length > 0
) {
onSelect(searchResults[0]);
}
}
}}
>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
{searchResults.length > 0 &&
input.current &&
input.current.value.length > 0 && (
<TextField.Slot>
<Button
variant="ghost"
onClick={() => onSelect(searchResults[0])}
>
{searchResults[0].name} <CaretRightIcon />
</Button>
</TextField.Slot>
)}
</TextField.Root>
<Sort />
<Button
color="red"
variant="soft"
onClick={() =>
useTankopediaPersistent.setState({
filters: {
nations: [],
tiers: [],
treeTypes: [],
types: [],
test: 'include',
page: 0,
},
sort: {
by: 'meta.tier',
direction: 'descending',
},
})
}
>
<TrashIcon />
</Button>
</Flex>
<Options />
</Flex>
<Flex justify="center" gap="4" align="center">
<Text color="gray">
Sorting: {SORT_NAMES[sort.by]}, {sort.direction}
</Text>
{onSelectAll && searchResults.length <= 8 && (
<Button variant="ghost" onClick={() => onSelectAll(searchResults)}>
Select all
</Button>
)}
{onSelectAll && searchResults.length > 8 && (
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button variant="ghost">Select all</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>
Woah! That's a lot of tanks.
</AlertDialog.Title>
<AlertDialog.Description>
Would you like to select{' '}
<Text color="red">all {searchResults.length} tanks</Text>?
</AlertDialog.Description>
<Flex gap="2" justify="end">
<AlertDialog.Cancel>
<Button variant="soft" color="gray">
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action onClick={() => onSelectAll(searchResults)}>
<Button variant="solid" color="red">
Select {searchResults.length} tanks
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)}
</Flex>
{!compact && (
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)}
<Results
compact={compact}
results={searchResultsPageSlice}
onSelect={onSelect}
/>
{searchResultsPageSlice.length > tanksPerPage / 4 && (
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)}
</>
);
}
},
);
@@ -0,0 +1,49 @@
import { Flex, IconButton, Text } from '@radix-ui/themes';
import { times } from 'lodash';
import { memo } from 'react';
import { Tier } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import {
mutateTankopediaFilters,
useTankopediaFilters,
} from '../../../../stores/tankopediaFilters';
export const TierFilter = memo(() => {
const tierFilter = useTankopediaFilters((state) => state.tier);
const sort = useTankopediaFilters((state) => state.sort);
const search = useTankopediaFilters((state) => state.search);
if (sort.by !== 'meta.none' || search) return null;
return (
<Flex justify="center">
<Flex
direction="row"
overflow="hidden"
style={{ borderRadius: 'var(--radius-full)' }}
>
{times(10, (index) => {
const tier = (10 - index) as Tier;
const selected = tierFilter === tier;
return (
<IconButton
key={tier}
variant={selected ? 'solid' : 'soft'}
radius="none"
color={selected ? undefined : 'gray'}
highContrast
onClick={() =>
mutateTankopediaFilters((draft) => {
draft.tier = tier;
})
}
>
<Text size="2">{TIER_ROMAN_NUMERALS[tier]}</Text>
</IconButton>
);
})}
</Flex>
</Flex>
);
});
+4 -19
View File
@@ -1,32 +1,17 @@
'use client';
import { Flex } from '@radix-ui/themes';
import { times } from 'lodash';
import { useRouter } from 'next/navigation';
import { Ad, AdType } from '../../../components/Ad';
import { AdMidSectionResponsive } from '../../../components/AdMidSectionResponsive';
import PageWrapper from '../../../components/PageWrapper';
import { useAdExempt } from '../../../hooks/useAdExempt';
import { TankSearch } from './components/TankSearch';
export default function Page() {
const router = useRouter();
const exempt = useAdExempt();
return (
<PageWrapper size={1200} color="purple">
<TankSearch
onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`);
}}
/>
{!exempt && (
<Flex justify="center" wrap="wrap">
{times(2, (index) => (
<Ad key={index} type={AdType.MediumRectangleHorizontalPurple} />
))}
</Flex>
)}
<PageWrapper color="purple" size={1028}>
<TankSearch />
{!exempt && <AdMidSectionResponsive />}
</PageWrapper>
);
}
+4 -3
View File
@@ -3,8 +3,8 @@ import markdownEscape from 'markdown-escape';
import { TANK_CLASSES } from '../components/Tanks/components/Item/constants';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import getTankStats from '../core/blitz/getTankStats';
import { gameDefinitions } from '../core/blitzkit/gameDefinitions';
import {
NATIONS,
TankDefinition,
Tier,
flags,
@@ -62,7 +62,7 @@ export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
});
const awaitedTankDefinitions = await tankDefinitions;
const awaitedNations = await NATIONS;
const awaitedGameDefinitions = await gameDefinitions;
const lines = tankStats
.map(({ tank_id }) => awaitedTankDefinitions[tank_id])
.filter((tank) => tank.tier === tier)
@@ -72,7 +72,8 @@ export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
)
.sort(
(a, b) =>
awaitedNations.indexOf(a.nation) - awaitedNations.indexOf(b.nation),
awaitedGameDefinitions.nations.indexOf(a.nation) -
awaitedGameDefinitions.nations.indexOf(b.nation),
)
.map((tank) => `${flags[tank.nation]} ${markdownEscape(tank.name)}`);
+4 -3
View File
@@ -1,4 +1,4 @@
import { NATIONS } from '../core/blitzkit/tankDefinitions';
import { gameDefinitions } from '../core/blitzkit/gameDefinitions';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import { addFilterOptions } from '../core/discord/addFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
@@ -18,14 +18,15 @@ import { CommandRegistry } from '../events/interactionCreate';
import { renderBreakdown } from './breakdown/render';
export const todayCommand = new Promise<CommandRegistry>(async (resolve) => {
const nations = await NATIONS;
const awaitedGameDefinitions = await gameDefinitions;
resolve({
inProduction: true,
inPublic: true,
command: addFilterOptions(
createLocalizedCommand('today'),
nations,
awaitedGameDefinitions.nations,
).addStringOption(addUsernameChoices),
async handler(interaction) {
@@ -0,0 +1,58 @@
import { ComponentProps } from 'react';
export function ClassHeavy(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_70_177)">
<g clipPath="url(#clip1_70_177)">
<rect
y="32"
width="45.2548"
height="12.4183"
rx="6.20914"
transform="rotate(-45 0 32)"
fill="currentColor"
/>
<rect
x="11.6095"
y="43.6095"
width="45.2548"
height="12.4183"
rx="6.20914"
transform="rotate(-45 11.6095 43.6095)"
fill="currentColor"
/>
<rect
x="23.2189"
y="55.219"
width="45.2548"
height="12.4183"
rx="6.20914"
transform="rotate(-45 23.2189 55.219)"
fill="currentColor"
/>
</g>
</g>
<defs>
<clipPath id="clip0_70_177">
<rect width="64" height="64" fill="white" />
</clipPath>
<clipPath id="clip1_70_177">
<rect
width="45.2548"
height="45.2548"
fill="white"
transform="translate(0 32) rotate(-45)"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,23 @@
import { ComponentProps } from 'react';
export function ClassLight(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
y="32"
width="45.2548"
height="45.2548"
rx="8"
transform="rotate(-45 0 32)"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,49 @@
import { ComponentProps } from 'react';
export function ClassMedium(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_70_149)">
<g clipPath="url(#clip1_70_149)">
<rect
y="32"
width="45.2548"
height="20.6274"
rx="8"
transform="rotate(-45 0 32)"
fill="currentColor"
/>
<rect
x="17.4142"
y="49.4142"
width="45.2548"
height="20.6274"
rx="8"
transform="rotate(-45 17.4142 49.4142)"
fill="currentColor"
/>
</g>
</g>
<defs>
<clipPath id="clip0_70_149">
<rect width="64" height="64" fill="white" />
</clipPath>
<clipPath id="clip1_70_149">
<rect
width="45.2548"
height="45.2548"
fill="white"
transform="translate(0 32) rotate(-45)"
/>
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,19 @@
import { ComponentProps } from 'react';
export function ClassTankDestroyer(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M36.6355 54.572C34.7073 57.886 29.9201 57.8861 27.992 54.572L4.68577 14.5145C2.74639 11.1812 5.15107 7 9.00751 7H55.6199C59.4764 7 61.881 11.1812 59.9417 14.5145L36.6355 54.572Z"
fill="currentColor"
/>
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { ComponentProps, ReactNode } from 'react';
import { TankClass } from '../Tanks';
import { ClassHeavy } from './components/ClassHeavy';
import { ClassLight } from './components/ClassLight';
import { ClassMedium } from './components/ClassMedium';
import { ClassTankDestroyer } from './components/ClassTankDestroyer';
export const classIcons: Record<
TankClass,
(props: ComponentProps<'svg'>) => ReactNode
> = {
'AT-SPG': ClassTankDestroyer,
heavyTank: ClassHeavy,
lightTank: ClassLight,
mediumTank: ClassMedium,
};
+16
View File
@@ -0,0 +1,16 @@
import { ComponentProps } from 'react';
export function ExperimentIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M200-120q-51 0-72.5-45.5T138-250l222-270v-240h-40q-17 0-28.5-11.5T280-800q0-17 11.5-28.5T320-840h320q17 0 28.5 11.5T680-800q0 17-11.5 28.5T640-760h-40v240l222 270q32 39 10.5 84.5T760-120H200Zm0-80h560L520-492v-268h-80v268L200-200Zm280-280Z" />
</svg>
);
}
+23 -19
View File
@@ -2,25 +2,29 @@
import { LinkProps, Link as RadixLink } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { forwardRef } from 'react';
export function Link({ href, onClick, ...props }: LinkProps) {
const router = useRouter();
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
({ href, onClick, ...props }, ref) => {
const router = useRouter();
return (
<RadixLink
href={href}
onClick={(event) => {
event.preventDefault();
if (href) {
if (event.ctrlKey || event.metaKey) {
window.open(href);
} else {
router.push(href);
return (
<RadixLink
ref={ref}
href={href}
onClick={(event) => {
event.preventDefault();
if (href) {
if (event.ctrlKey || event.metaKey) {
window.open(href);
} else {
router.push(href);
}
}
}
onClick?.(event);
}}
{...props}
/>
);
}
onClick?.(event);
}}
{...props}
/>
);
},
);
+25
View File
@@ -0,0 +1,25 @@
import { ComponentProps } from 'react';
export function ResearchedIcon(props: ComponentProps<'svg'>) {
return (
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M63 25V43L48 51H60C58.5 53.6667 51.6 59 36 59L62 25H63Z"
fill="currentColor"
/>
<path d="M48 3L60 18L54 27L48 3Z" fill="currentColor" />
<path
d="M3 25V43L18 51H6C7.5 53.6667 14.4 59 30 59L4 25H3Z"
fill="currentColor"
/>
<path d="M18 3L6 18L12 27L18 3Z" fill="currentColor" />
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { ComponentProps } from 'react';
export function ScienceIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M200-120q-51 0-72.5-45.5T138-250l222-270v-240h-40q-17 0-28.5-11.5T280-800q0-17 11.5-28.5T320-840h320q17 0 28.5 11.5T680-800q0 17-11.5 28.5T640-760h-40v240l222 270q32 39 10.5 84.5T760-120H200Zm80-120h400L544-400H416L280-240Zm-80 40h560L520-492v-268h-80v268L200-200Zm280-280Z" />
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { ComponentProps } from 'react';
export function ScienceOffIcon(props: ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="m600-473-80-80v-207h-80v127l-80-80v-47h-40q-17 0-28.5-11.5T280-800q0-17 11.5-28.5T320-840h320q17 0 28.5 11.5T680-800q0 17-11.5 28.5T640-760h-40v287ZM200-200h448L402-446 200-200ZM792-56l-64-64H200q-51 0-72.5-45.5T138-250l208-252L56-792l56-56 736 736-56 56ZM402-446Zm78-147Z" />
</svg>
);
}
+6 -2
View File
@@ -12,8 +12,12 @@ import { readDVPLFile } from './readDVPLFile';
export async function extractArmor(data: string, fileName: string) {
const sc2Path = `${data}/3d/Tanks/CollisionMeshes/${fileName}.sc2.dvpl`;
const scgPath = `${data}/3d/Tanks/CollisionMeshes/${fileName}.scg.dvpl`;
const sc2 = new Sc2ReadStream((await readDVPLFile(sc2Path)).buffer).sc2();
const scg = new ScgReadStream((await readDVPLFile(scgPath)).buffer).scg();
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
).scg();
const document = new Document();
const scene = document.createScene();
const buffer = document.createBuffer();
+6 -2
View File
@@ -23,8 +23,12 @@ const omitMeshNames = {
export async function extractModel(data: string, path: string) {
const sc2Path = `${data}/3d/${path}.sc2.dvpl`;
const scgPath = `${data}/3d/${path}.scg.dvpl`;
const sc2 = new Sc2ReadStream((await readDVPLFile(sc2Path)).buffer).sc2();
const scg = new ScgReadStream((await readDVPLFile(scgPath)).buffer).scg();
const sc2 = new Sc2ReadStream(
(await readDVPLFile(sc2Path)).buffer as ArrayBuffer,
).sc2();
const scg = new ScgReadStream(
(await readDVPLFile(scgPath)).buffer as ArrayBuffer,
).scg();
const document = new Document();
const scene = document.createScene();
const buffer = document.createBuffer();
+17 -8
View File
@@ -1,9 +1,8 @@
import { crc32 } from 'crc';
import { decodeBlock } from 'lz4';
import { decompressBlock } from 'lz4js';
/**
* Thanks Maddoxkkm! Modernized heavily.
*
* Thanks Maddoxkkm! Modified heavily to be modern.
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function readDVPL(buffer: Buffer) {
@@ -30,6 +29,7 @@ export function readDVPL(buffer: Buffer) {
if (crc32(targetBlock) !== footerData.crc32) {
throw new TypeError('DVPL CRC32 mismatch');
}
if (footerData.type === 0) {
if (!(footerData.oSize === footerData.cSize && footerData.type === 0)) {
throw new RangeError('DVPL type and size mismatch');
@@ -37,14 +37,23 @@ export function readDVPL(buffer: Buffer) {
return targetBlock;
} else if (footerData.type === 1 || footerData.type === 2) {
const deDVPLBlock = Buffer.alloc(footerData.oSize);
const DecompressedBlockSize = decodeBlock(targetBlock, deDVPLBlock);
const source = new Uint8Array(targetBlock);
const destination = new Uint8Array(footerData.oSize);
const decompressedSize = decompressBlock(
source,
destination,
0,
source.length,
0,
);
if (DecompressedBlockSize !== footerData.oSize) {
throw new RangeError('Decompressed DVPL size mismatch');
if (decompressedSize !== footerData.oSize) {
throw new RangeError(
`Decompressed DVPL size mismatch (${decompressedSize} vs ${footerData.oSize})`,
);
}
return deDVPLBlock;
return Buffer.from(destination);
} else {
throw new SyntaxError('Unknown DVPL format');
}
+11 -3
View File
@@ -1,5 +1,5 @@
import { crc32 } from 'crc';
import { encodeBlockHC } from 'lz4';
import { compressBlock } from 'lz4js';
/**
* Thanks Maddoxkkm! Modernized heavily.
@@ -7,8 +7,16 @@ import { encodeBlockHC } from 'lz4';
* https://github.com/Maddoxkkm/dvpl_converter/
*/
export function writeDVPL(buffer: Buffer) {
let output = Buffer.alloc(buffer.length);
const compressedBlockSize = encodeBlockHC(buffer, output);
const source = new Uint8Array(buffer);
const destination = new Uint8Array(buffer.length);
const compressedBlockSize = compressBlock(
source,
destination,
0,
source.length,
0,
);
let output = Buffer.from(destination);
if (compressedBlockSize === 0 || compressedBlockSize >= buffer.length) {
const footerBuffer = toDVPLFooter(
+3 -9
View File
@@ -1,3 +1,4 @@
import ProgressBar from 'progress';
import { GithubChangeBlob, createBlob } from './createBlob';
import { octokit } from './octokit';
@@ -30,21 +31,14 @@ export default async function commitMultipleFiles(
})
).data.tree.sha;
const blobs: GithubChangeBlob[] = [];
const bar = new ProgressBar('Blobs :bar', changes.length);
for (const changeIndexString in changes) {
const changeIndex = parseInt(changeIndexString);
const change = changes[changeIndex];
const createdBlob = await createBlob(owner, repo, change);
blobs.push(createdBlob!);
if (verbose) {
console.log(
`Created blob ${changeIndex + 1} / ${changes.length} (${(
(100 * (changeIndex + 1)) /
changes.length
).toFixed(2)}%)`,
);
}
bar.tick();
}
const { data: treeData } = await octokit.git.createTree({
+1 -1
View File
@@ -3,5 +3,5 @@ import { superDecompress } from './superDecompress';
export async function fetchCdonLz4<Type>(url: string) {
return await fetch(url, { cache: 'no-store' })
.then((response) => response.arrayBuffer())
.then((buffer) => superDecompress<Type>(new Uint8Array(buffer)));
.then((buffer) => superDecompress<Type>(buffer));
}
+1
View File
@@ -3,6 +3,7 @@ import { fetchCdonLz4 } from './fetchCdonLz4';
export interface GameDefinitions {
version: string;
nations: string[];
}
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
+1 -3
View File
@@ -27,9 +27,7 @@ export default async function getArchivedRatingInfo(
)
.then((response) => response.arrayBuffer())
.then((buffer) =>
superDecompress<RatingInfo & { detail: undefined }>(
new Uint8Array(buffer),
),
superDecompress<RatingInfo & { detail: undefined }>(buffer),
);
cache[region][season] = info;
}
+23
View File
@@ -0,0 +1,23 @@
export async function patientFetchJSON<Type extends object>(
url: string,
timeout = 1000,
options?: Partial<{ cache: 'no-store' }>,
) {
let patientJSON: Type;
while (patientJSON! === undefined) {
try {
patientJSON = (await fetch(url, options).then((response) =>
response.json(),
)) as Type;
} catch (error) {
console.warn(
`Failed to fetch ${url} as JSON; retrying in ${timeout}ms...`,
error,
);
await new Promise((resolve) => setTimeout(resolve, timeout));
}
}
return patientJSON;
}
+2 -2
View File
@@ -14,8 +14,8 @@ export async function readTexture(path: string, mutation?: TextureMutation) {
const decompressedDvpl = await readDVPLFile(resolvedTexturePath);
const raw = isDds
? await new DdsReadStream(decompressedDvpl.buffer).dds()
: new PvrReadStream(decompressedDvpl.buffer).pvr();
? await new DdsReadStream(decompressedDvpl.buffer as ArrayBuffer).dds()
: new PvrReadStream(decompressedDvpl.buffer as ArrayBuffer).pvr();
switch (mutation) {
case TextureMutation.Normal: {
+1 -1
View File
@@ -2,7 +2,7 @@ import { decompress } from 'lz4js';
import { CdonReadStream } from '../streams/cdon';
export function superDecompress<Type>(buffer: ArrayBuffer) {
const decompressed = decompress(new Uint8Array(buffer)).buffer;
const decompressed = decompress(new Uint8Array(buffer)).buffer as ArrayBuffer;
const read = new CdonReadStream(decompressed);
return read.cdon() as Type;
}
@@ -19,18 +19,27 @@ export const GUN_TYPE_NAMES: Record<GunDefinition['type'], string> = {
autoLoader: 'Auto loader',
autoReloader: 'Auto reloader',
};
/**
* @deprecated use svg
*/
export const TANK_ICONS: Record<TankClass, string> = {
'AT-SPG': 'https://i.imgur.com/BIHSEH0.png',
lightTank: 'https://i.imgur.com/CSNha5V.png',
mediumTank: 'https://i.imgur.com/wvf3ltm.png',
heavyTank: 'https://i.imgur.com/ECeqlZa.png',
};
/**
* @deprecated use svg
*/
export const TANK_ICONS_PREMIUM: Record<TankClass, string> = {
'AT-SPG': 'https://i.imgur.com/TCu3EdR.png',
lightTank: 'https://i.imgur.com/zdkpTRb.png',
mediumTank: 'https://i.imgur.com/3z7eHX6.png',
heavyTank: 'https://i.imgur.com/P3vbmyA.png',
};
/**
* @deprecated use svg
*/
export const TANK_ICONS_COLLECTOR: Record<TankClass, string> = {
'AT-SPG': 'https://i.imgur.com/WTjeirB.png',
lightTank: 'https://i.imgur.com/EwhtKkU.png',
@@ -197,17 +197,6 @@ export const tankNames = tanksDefinitionsArray.then((tanks) =>
export type Tier = (typeof TIERS)[number];
export const NATIONS = tanksDefinitionsArray.then((tanks) => {
const nations = Array.from(
new Set<string>(Object.values(tanks).map((tank) => tank.nation)),
);
nations.splice(nations.indexOf('other'), 1);
nations.push('other');
return nations;
});
export const flags: Record<string, string> = {
ussr: '<:ussr:1218421042033197197>',
germany: '🇩🇪',
+17
View File
@@ -0,0 +1,17 @@
import { TankopediaFilters } from '../../stores/tankopediaFilters';
import { TankDefinition } from './tankDefinitions';
export function tankopediaFilterTank(
filters: TankopediaFilters,
tank: TankDefinition,
) {
return (
(filters.tier === undefined || filters.tier === tank.tier) &&
(filters.nation === undefined || filters.nation === tank.nation) &&
(filters.class === undefined || filters.class === tank.class) &&
(filters.type === undefined || filters.type === tank.treeType) &&
(filters.testing === 'include' ||
(filters.testing === 'only' && tank.testing) ||
(filters.testing === 'exclude' && !tank.testing))
);
}
+4 -3
View File
@@ -3,7 +3,7 @@ import {
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
} from 'discord.js';
import { NATIONS } from '../blitzkit/tankDefinitions';
import { gameDefinitions } from '../blitzkit/gameDefinitions';
import { addFilterOptions } from './addFilterOptions';
import { addPeriodSubCommands } from './addPeriodSubCommands';
@@ -15,9 +15,10 @@ export default async function addPeriodicFilterOptions<
option: SlashCommandSubcommandBuilder,
) => SlashCommandSubcommandBuilder = (option) => option,
): Promise<OptionType> {
const nations = await NATIONS;
const awaitedGameDefinitions = await gameDefinitions;
addPeriodSubCommands(option, (option) =>
addFilterOptions(extra(option), nations),
addFilterOptions(extra(option), awaitedGameDefinitions.nations),
);
return option;
@@ -8,6 +8,7 @@ import {
MessageEditOptions,
} from 'discord.js';
import { InteractionReturnable } from '../../events/interactionCreate';
import { Writeable } from '../../types/writable';
import jsxToPngThreaded from '../blitzkit/jsxToPngThreaded';
import { RenderConfiguration } from '../blitzkit/renderConfiguration';
@@ -30,7 +31,7 @@ export default async function normalizeInteractionReturnable(
reply.content = item;
} else if (item instanceof EmbedBuilder) {
if (!reply.embeds) reply.embeds = [];
reply.embeds.push(item);
(reply.embeds as Writeable<typeof reply.embeds>).push(item);
} else if (item instanceof ButtonBuilder) {
if (!reply.components)
reply.components = [new ActionRowBuilder<ButtonBuilder>()];
@@ -39,7 +40,7 @@ export default async function normalizeInteractionReturnable(
);
} else if (item instanceof AttachmentBuilder) {
if (!reply.files) reply.files = [];
reply.files.push(item);
(reply.files as Writeable<typeof reply.files>).push(item);
} else if (item instanceof RenderConfiguration) {
renderConfiguration = item;
} else if (item === null) {
@@ -54,7 +55,9 @@ export default async function normalizeInteractionReturnable(
if (images.length > 0) {
images.sort(([a], [b]) => b - a);
if (!reply.files) reply.files = [];
images.forEach(([, image]) => reply.files?.push(image));
images.forEach(([, image]) =>
(reply.files as Writeable<typeof reply.files>)?.push(image),
);
}
return reply;
+1 -1
View File
@@ -54,6 +54,6 @@ export class WotbReplayReadStream extends ReadStream {
}
const replay = await readFile('temp/test3.wotbreplay');
const stream = new WotbReplayReadStream(replay.buffer);
const stream = new WotbReplayReadStream(replay.buffer as ArrayBuffer);
stream.wotbReplay();
@@ -14,6 +14,7 @@ import normalizeInteractionReturnable from '../../../core/discord/normalizeInter
import { psa } from '../../../core/discord/psa';
import { translator } from '../../../core/localization/translator';
import { UserError } from '../../../hooks/userError';
import { Writeable } from '../../../types/writable';
const previewServers = [
'734786591205359697', // abhi
@@ -69,10 +70,12 @@ export default async function handleChatInputCommand(
const embed = embedWarning(psa.data.title, psa.data.description);
if (psa.data.image) embed.setImage(psa.data.image);
reply.embeds.push(embed);
(reply.embeds as Writeable<typeof reply.embeds>).push(embed);
} else if (psa.data.type === 'image') {
if (!reply.files) reply.files = [];
reply.files.push(new AttachmentBuilder(psa.data.image));
(reply.files as Writeable<typeof reply.files>).push(
new AttachmentBuilder(psa.data.image),
);
reply.content = reply.content ?? psa.data.title;
}
@@ -111,10 +114,12 @@ export default async function handleChatInputCommand(
const embed = embedWarning(psa.data.title, psa.data.description);
if (psa.data.image) embed.setImage(psa.data.image);
followUp.embeds.push(embed);
(followUp.embeds as Writeable<typeof followUp.embeds>).push(embed);
} else if (psa.data.type === 'image') {
if (!followUp.files) followUp.files = [];
followUp.files.push(new AttachmentBuilder(psa.data.image));
(followUp.files as Writeable<typeof followUp.files>).push(
new AttachmentBuilder(psa.data.image),
);
followUp.content = followUp.content ?? psa.data.title;
}
+2
View File
@@ -11,6 +11,7 @@ import {
RESTPostAPIChatInputApplicationCommandsJSONBody,
Routes,
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
} from 'discord.js';
import discord from '../../../discord.json' assert { type: 'json' };
@@ -64,6 +65,7 @@ interface CommandRegistryBase {
command:
| SlashCommandBuilder
| SlashCommandSubcommandsOnlyBuilder
| SlashCommandOptionsOnlyBuilder
| Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
autocomplete?: (interaction: AutocompleteInteraction<CacheType>) => void;
+2 -2
View File
@@ -35,8 +35,8 @@
"uk": "U.K.",
"france": "France",
"japan": "Japan",
"european": "European nation",
"other": "Hybrid nation"
"european": "European Nation",
"other": "Hybrid Nation"
},
"nations_adjectives": {
+31 -3
View File
@@ -106,8 +106,7 @@ export type ArmorPiercingLayer =
};
export const SORT_NAMES = {
'meta.tier': 'tier',
'meta.name': 'name',
'meta.none': 'none',
'survivability.health': 'health',
'survivability.viewRange': 'view range',
'survivability.camouflageStill': 'camouflage still',
@@ -135,6 +134,35 @@ export const SORT_NAMES = {
'maneuverability.traverseSpeed': 'traverse speed',
} as const;
export const SORT_UNITS: Record<TankopediaSortBy, string | undefined> = {
'meta.none': undefined,
'survivability.health': 'hp',
'fire.aimTime': 's',
'fire.caliber': 'mm',
'fire.damage': 'hp',
'fire.dispersionMoving': 'm',
'fire.dispersionStill': 'm',
'fire.dpm': undefined,
'fire.gunDepression': '°',
'fire.gunElevation': '°',
'fire.premiumPenetration': 'mm',
'fire.reload': 's',
'fire.shellVelocity': 'm/s',
'fire.standardPenetration': 'mm',
'maneuverability.backwardsSpeed': 'kph',
'maneuverability.forwardsSpeed': 'kph',
'maneuverability.power': 'hp',
'maneuverability.powerToWeight': 'hp/tn',
'maneuverability.traverseSpeed': '°/s',
'maneuverability.weight': 'tn',
'survivability.camouflageMoving': '%',
'survivability.camouflageShooting': '%',
'survivability.camouflageStill': '%',
'survivability.length': 'm',
'survivability.viewRange': 'm',
'survivability.volume': 'm^3',
};
export const useTankopediaPersistent = create<TankopediaPersistent>()(
persist(
subscribeWithSelector<TankopediaPersistent>(() => ({
@@ -151,7 +179,7 @@ export const useTankopediaPersistent = create<TankopediaPersistent>()(
},
},
sort: {
by: 'meta.tier',
by: 'meta.none',
direction: 'descending',
},
filters: {
+35
View File
@@ -0,0 +1,35 @@
import { produce } from 'immer';
import { create } from 'zustand';
import { TankClass, TreeType } from '../components/Tanks';
import { Tier } from '../core/blitzkit/tankDefinitions';
import { TankopediaSortBy, TankopediaSortDirection } from './tankopedia';
export interface TankopediaFilters {
tier?: Tier;
nation?: string;
class?: TankClass;
type?: TreeType;
testing: 'include' | 'exclude' | 'only';
search?: string;
sort: {
by: TankopediaSortBy;
direction: TankopediaSortDirection;
};
searching: boolean;
}
export const useTankopediaFilters = create<TankopediaFilters>()(() => ({
testing: 'include',
sort: {
by: 'meta.none',
direction: 'descending',
},
tier: 10,
searching: false,
}));
export function mutateTankopediaFilters(
recipe: (draft: TankopediaFilters) => void,
) {
useTankopediaFilters.setState(produce(recipe));
}
+1
View File
@@ -0,0 +1 @@
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
-6422
View File
File diff suppressed because it is too large. Load diff