mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-21 10:47:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f119b06dd8 | ||
|
|
e23a440508 | ||
|
|
6a9fa08500 | ||
|
|
8e86e81e91 | ||
|
|
8b359e220a | ||
|
|
690bc5dbb5 | ||
|
|
d6e96e17ad | ||
|
|
ab26b346e6 | ||
|
|
f02db6ef0b | ||
|
|
55ba2525d1 | ||
|
|
ea07d2646d | ||
|
|
ca111510f6 | ||
|
|
519558dd28 | ||
|
|
c4d1e305f6 | ||
|
|
59772afb04 | ||
|
|
8c9dcfa551 | ||
|
|
8bbf03a128 | ||
|
|
519fba84dc | ||
|
|
e7e74f706e | ||
|
|
12e2793c92 | ||
|
|
69040dc453 | ||
|
|
511151e101 | ||
|
|
7fc993c2eb | ||
|
|
030ca744e9 | ||
|
|
74756ab7bf | ||
|
|
e9a61d579d | ||
|
|
7c3cad2c7d | ||
|
|
5f39ba55f6 | ||
|
|
2bfd8286a4 | ||
|
|
f3421b6f70 | ||
|
|
abbbf11b3d | ||
|
|
797f5a90d2 | ||
|
|
818d13fade | ||
|
|
129d7c9688 | ||
|
|
28407b0f05 | ||
|
|
8a9d72d0e6 | ||
|
|
f98a4c7caa | ||
|
|
92b329a0fb | ||
|
|
f3d32a4e63 | ||
|
|
0e73d79c9c | ||
|
|
526b5785b7 | ||
|
|
02129feb25 | ||
|
|
0a38c164a4 | ||
|
|
2e0d52e30e | ||
|
|
6d48f9fe7f | ||
|
|
aba52c9968 | ||
|
|
b96b9fd574 | ||
|
|
fe4ee8b611 | ||
|
|
94558393d9 | ||
|
|
db1e4af20c | ||
|
|
be20173acd | ||
|
|
d90a70ed2a | ||
|
|
85d699dbfc | ||
|
|
5d0fe8726b | ||
|
|
850308b2ac | ||
|
|
88a85ef152 | ||
|
|
8160935081 | ||
|
|
c057e295d2 | ||
|
|
28d0c26268 | ||
|
|
52cb73a575 | ||
|
|
9f244452da | ||
|
|
d46d948c66 | ||
|
|
11b99ef211 | ||
|
|
85d350a912 | ||
|
|
145bc84fb1 | ||
|
|
b7486022eb | ||
|
|
eadb4bb7b1 | ||
|
|
79723157d2 | ||
|
|
4214ff0de5 | ||
|
|
f516c9275e | ||
|
|
f0b905593c | ||
|
|
11440bc654 | ||
|
|
2dab957a05 | ||
|
|
60f3951e7a | ||
|
|
4f03788f15 | ||
|
|
17f4435a30 | ||
|
|
04e42c929c | ||
|
|
4ba45b8fd8 | ||
|
|
0ece1f4b18 | ||
|
|
49d3e1d1c2 | ||
|
|
184ca522dd | ||
|
|
9f07d910b8 | ||
|
|
ac3f2ea93a | ||
|
|
a77ff1a638 | ||
|
|
96d802a09f | ||
|
|
1babd66649 | ||
|
|
2f2996a40c | ||
|
|
9d1e059ea5 | ||
|
|
48603b918f | ||
|
|
c1253e8121 | ||
|
|
ecc029402e | ||
|
|
1b88773e28 | ||
|
|
cf419a9ebb | ||
|
|
8565d75747 | ||
|
|
186ea8b458 | ||
|
|
4292081df6 | ||
|
|
4b54b61c35 | ||
|
|
5d098c46fa | ||
|
|
7575f4581c | ||
|
|
4669fa6ec5 | ||
|
|
49294d1906 | ||
|
|
3eee946978 | ||
|
|
7fff1c9737 | ||
|
|
5b4c3119dd | ||
|
|
0a41ee15ed | ||
|
|
ad57d284bb | ||
|
|
b929e9449d | ||
|
|
948ea0bb8d | ||
|
|
c1b1b8d304 | ||
|
|
3bc5a6d740 | ||
|
|
d25da724e1 | ||
|
|
753804b8cf | ||
|
|
096122d43c | ||
|
|
3448427de0 | ||
|
|
d8bbd02715 | ||
|
|
f696c5f3c9 | ||
|
|
bd412cdaa2 | ||
|
|
913411275b | ||
|
|
17ff8e0819 | ||
|
|
b5fdc4d5cf | ||
|
|
59c5d95aff | ||
|
|
16babbad90 | ||
|
|
a9dc9e7ebd | ||
|
|
52e8df560e | ||
|
|
87259ad515 | ||
|
|
c96655a6c0 | ||
|
|
43894567b8 | ||
|
|
76a952a8e6 | ||
|
|
e2efe428a2 | ||
|
|
887cec2dd4 | ||
|
|
04ad48bd33 | ||
|
|
3613f5d13b | ||
|
|
3362e3b091 | ||
|
|
3322fa97d4 | ||
|
|
1dc7748a04 | ||
|
|
6176b3b0d8 | ||
|
|
81c2af08c6 | ||
|
|
2d83519f93 | ||
|
|
869c1f0def | ||
|
|
7a64fb8176 | ||
|
|
9256244286 | ||
|
|
85e54e4a07 | ||
|
|
8075dc8d9f | ||
|
|
57dced109a | ||
|
|
dd19d5b979 | ||
|
|
68d3fe6805 | ||
|
|
70f7698466 | ||
|
|
26a5fdea0e | ||
|
|
367bd1386b | ||
|
|
fac773a8f8 | ||
|
|
6bd8b7d42f | ||
|
|
7093662130 | ||
|
|
4062e56b4c | ||
|
|
0885e7e68a | ||
|
|
3cfa837682 | ||
|
|
1982604878 | ||
|
|
9ef6eea4a9 | ||
|
|
78e6b2696c | ||
|
|
40b6f30092 | ||
|
|
de2d9a1a3e | ||
|
|
b7ad12c698 | ||
|
|
886c2aa1d1 | ||
|
|
9818c9868a | ||
|
|
31055f1d66 | ||
|
|
6cef753053 | ||
|
|
5b889f8483 | ||
|
|
d0d905ccfb | ||
|
|
9a91a8bb13 | ||
|
|
c12cc9b8c5 | ||
|
|
fdbfd0bc10 | ||
|
|
286b178218 | ||
|
|
54e012f736 | ||
|
|
cc6de50de6 | ||
|
|
194e5fceca | ||
|
|
879a5d534c | ||
|
|
29aaf60da0 | ||
|
|
3c8f8b49e4 | ||
|
|
0b88f71f26 | ||
|
|
65e239a22b | ||
|
|
7727e64931 | ||
|
|
7b0465322d | ||
|
|
099e5eb228 | ||
|
|
eaff26c8d9 | ||
|
|
d79129df91 | ||
|
|
b363c76639 | ||
|
|
034429cd47 | ||
|
|
57867317cc | ||
|
|
c2e314b193 | ||
|
|
9d2af1de13 | ||
|
|
232823566b |
No files matched your search
@@ -53,7 +53,7 @@ jobs:
|
||||
run: |
|
||||
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
||||
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
|
||||
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}"
|
||||
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
|
||||
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
|
||||
yarn setup:patches --depot
|
||||
|
||||
@@ -61,5 +61,5 @@ jobs:
|
||||
run: |
|
||||
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
||||
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
|
||||
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}"
|
||||
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
|
||||
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
|
||||
@@ -33,4 +33,4 @@ jobs:
|
||||
unzip bot.zip
|
||||
|
||||
- name: Run bot
|
||||
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs
|
||||
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} node --enable-source-maps index.cjs
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate all leaderboards
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} 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 }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
|
||||
@@ -31,4 +31,4 @@ jobs:
|
||||
run: yarn
|
||||
|
||||
- name: Generate leaderboard
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=na --target=midnight --production
|
||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na --target=midnight --production
|
||||
@@ -33,6 +33,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Build Prisma
|
||||
run: yarn prisma generate
|
||||
|
||||
- name: Build
|
||||
run: yarn build:bot --production
|
||||
|
||||
|
||||
+1
-2
@@ -18,5 +18,4 @@ temp/
|
||||
/.VSCodeCounter
|
||||
/.yarn
|
||||
depots
|
||||
public/sitemap*.xml
|
||||
public/robots.txt
|
||||
public/sitemap*
|
||||
Vendored
+6
@@ -3,6 +3,7 @@
|
||||
"AABBOX",
|
||||
"Abhi",
|
||||
"accountinfo",
|
||||
"adsense",
|
||||
"aeson",
|
||||
"alphatest",
|
||||
"APAC",
|
||||
@@ -72,6 +73,7 @@
|
||||
"leaderboards",
|
||||
"lerp",
|
||||
"LINEARSIZE",
|
||||
"loadouts",
|
||||
"loddist",
|
||||
"lods",
|
||||
"metallicness",
|
||||
@@ -106,6 +108,7 @@
|
||||
"Resvg",
|
||||
"roboto",
|
||||
"SCPG",
|
||||
"scroller",
|
||||
"searchclans",
|
||||
"searchplayers",
|
||||
"searchtanks",
|
||||
@@ -125,6 +128,7 @@
|
||||
"Synx",
|
||||
"tankaverages",
|
||||
"Tankerch",
|
||||
"tankmen",
|
||||
"tankopedia",
|
||||
"tankopedias",
|
||||
"tankstats",
|
||||
@@ -143,8 +147,10 @@
|
||||
"Vovko",
|
||||
"wargaming",
|
||||
"webp",
|
||||
"whitespaces",
|
||||
"winrate",
|
||||
"wireframe",
|
||||
"Woah",
|
||||
"WOTB",
|
||||
"wotblitz",
|
||||
"xffn",
|
||||
|
||||
@@ -8,3 +8,4 @@ HE gets more accurate and say goodbye to NaNaNaNaNaN.
|
||||
- Applied equipments now persist after switching shells
|
||||
- The bottom of the tank sandbox panel can now be interacted with as it was previously blocked by an invisible layer
|
||||
- `/rating` now uses the midnight leaderboard instead of the absolute latest one
|
||||
- 0-battle stats are now hidden
|
||||
@@ -0,0 +1,7 @@
|
||||
# Blitzkrieg 1.18.2
|
||||
|
||||
Redefining how micro a micro-path is.
|
||||
|
||||
## Fixes
|
||||
|
||||
- The pitch and yaw controls, along with the quick equipment buttons, can once again be interacted with
|
||||
@@ -0,0 +1,29 @@
|
||||
# Blitzkrieg 1.18.3
|
||||
|
||||
Making Tankopedia more pleasing to use. This update only affects the website.
|
||||
|
||||
## Features
|
||||
|
||||
- Added crew skills
|
||||
- Added quick actions
|
||||
- For example, crew skills have the "clear" and "maximize" actions and equipment simply has a "clear" action
|
||||
- Added info buttons
|
||||
- Beside the titles of a few sections, a new info buttons have been added for extended descriptions
|
||||
- Tank configurations have been condensed thanks to a new layout that works well on most screen sizes
|
||||
- The Tankopedia now takes advantage of wider screens than before; hence, getting rid of the useless whitespaces on the sides
|
||||
- Tank modules are now neatly wrapped into dropdown menus
|
||||
- Sorting tanks by "size" has been renamed to "volume" and length has been added as a new option
|
||||
- The antagonist (the shooting tank) bar has been shrunk to only fit the its contents
|
||||
- Fullscreen mode now features all the missing dropdowns and options that were previously hidden
|
||||
- Added icons for donations on Patreon and Ko-fi in the navigation bar
|
||||
- Tank search now uses regular links allowing for things like opening in new tabs, copying links, etc.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Armor highlighting once again works properly even after switching around tank modules
|
||||
- Inputting invalid numbers into the pitch and yaw fields is now handled correctly
|
||||
- Removed unnecessary tooltips that didn't show up anyway due to Radix UI 3
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Upgraded to Radix UI 3
|
||||
@@ -0,0 +1,23 @@
|
||||
# 1.19.0
|
||||
|
||||
Introducing the Blitzkrieg Compare tool!
|
||||
|
||||
## Features
|
||||
|
||||
- Added Compare
|
||||
- Allows you to compare multiple tanks
|
||||
- Shows deltas in the form of differences between values or percentages
|
||||
- All stats from the Tankopedia are available here
|
||||
- [CLS](https://web.dev/articles/cls) was reduced due to the new loading screens
|
||||
- Clicking enter when searching for tanks now selects the first result
|
||||
- The first tank name is also displayed in the search input
|
||||
|
||||
## Changes
|
||||
|
||||
- Removed the donation banner instead for a little note in the footer
|
||||
- The footer now shows the version of Blitzkrieg and the version of WoTB its running on
|
||||
- Removed frosted glass effect from most components apart from the navigation bar for performance reasons (I am looking at you, Apple!)
|
||||
|
||||
## Fixes
|
||||
|
||||
- Tank search is now not ignored when changing filters
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.SITE_URL || 'https://blitz-krieg.vercel.app',
|
||||
generateRobotsTxt: true,
|
||||
sourceDir: 'dist/website',
|
||||
};
|
||||
+7
-7
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "blitzkrieg",
|
||||
"version": "1.18.1",
|
||||
"version": "1.19.0",
|
||||
"description": "🎉 Tools for everything World of Tanks Blitz",
|
||||
"main": "src/server.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:bot": "vite-node --script scripts/build",
|
||||
"build:website": "next build && next-sitemap --config next-sitemap.config.cjs",
|
||||
"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:docs": "vitepress build docs",
|
||||
"build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
|
||||
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
|
||||
"build:test": "vite-node --script scripts/buildTest",
|
||||
"dev:bot": "vite-node --script scripts/build --production && yarn start:bot",
|
||||
"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",
|
||||
@@ -36,7 +38,7 @@
|
||||
"@prisma/client": "^5.4.1",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/themes": "^2.0.1",
|
||||
"@radix-ui/themes": "^3.0.1",
|
||||
"@react-three/drei": "^9.93.0",
|
||||
"@react-three/fiber": "^8.15.14",
|
||||
"@resvg/resvg-js": "^2.4.1",
|
||||
@@ -56,7 +58,6 @@
|
||||
"lz4js": "^0.2.0",
|
||||
"markdown-escape": "^2.0.0",
|
||||
"next": "^14.1.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"parse-dds": "^1.2.1",
|
||||
"postprocessing": "^6.34.1",
|
||||
"react": "^18.2.0",
|
||||
@@ -64,7 +65,6 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fuzzysort": "^1.0.1",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-spinners": "^0.13.8",
|
||||
"satori": "^0.10.13",
|
||||
"sharp": "0.33.1",
|
||||
"three": "0.159.0",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "discord_blitz" (
|
||||
"discord" INT8 NOT NULL,
|
||||
"region" STRING NOT NULL,
|
||||
"blitz" INT4 NOT NULL,
|
||||
|
||||
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "cockroachdb"
|
||||
@@ -4,11 +4,15 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "cockroachdb"
|
||||
url = env("DISCORD_BLITZ_DB")
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model discord_blitz {
|
||||
model Users {
|
||||
blitz Int @id
|
||||
last_used DateTime? @default(now())
|
||||
}
|
||||
|
||||
model DiscordUsers {
|
||||
discord BigInt @id
|
||||
region String
|
||||
blitz Int
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,7 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Host: https://blitz-krieg.vercel.app
|
||||
|
||||
Sitemap: https://blitz-krieg.vercel.app/sitemap.txt
|
||||
Sitemap: https://blitz-krieg.vercel.app/sitemap.xml
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readdir, writeFile } from 'fs/promises';
|
||||
import { readdir } from 'fs/promises';
|
||||
import { parse as parsePath } from 'path';
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { TankClass } from '../../src/components/Tanks';
|
||||
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
|
||||
@@ -16,11 +17,13 @@ import {
|
||||
EquipmentPreset,
|
||||
EquipmentRow,
|
||||
} from '../../src/core/blitzkrieg/equipmentDefinitions';
|
||||
import { GameDefinitions } from '../../src/core/blitzkrieg/gameDefinitions';
|
||||
import {
|
||||
ModelArmor,
|
||||
ModelDefinitions,
|
||||
} from '../../src/core/blitzkrieg/modelDefinitions';
|
||||
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
|
||||
import { SkillDefinitions } from '../../src/core/blitzkrieg/skillDefinitions';
|
||||
import { superCompress } from '../../src/core/blitzkrieg/superCompress';
|
||||
import {
|
||||
Crew,
|
||||
@@ -32,6 +35,7 @@ import {
|
||||
Tier,
|
||||
} from '../../src/core/blitzkrieg/tankDefinitions';
|
||||
import { DATA, POI } from './constants';
|
||||
import { Avatar } from './skillIcons';
|
||||
import { TankParameters } from './tankIcons';
|
||||
|
||||
interface Strings {
|
||||
@@ -116,6 +120,7 @@ interface VehicleDefinitions {
|
||||
weight: number;
|
||||
terrainResistance: string;
|
||||
rotationSpeed: number;
|
||||
userString: string;
|
||||
shotDispersionFactors: {
|
||||
vehicleMovement: number;
|
||||
vehicleRotation: number;
|
||||
@@ -329,10 +334,18 @@ const blitzShellKindToBLitzkrieg: Record<ShellKind, ShellType> = {
|
||||
const missingStrings: Record<string, string> = {
|
||||
'#artefacts:tungstentip/name': 'Tungsten Shells',
|
||||
};
|
||||
const botPattern = /^.+((tutorial_bot(\d+)?)|(TU(R?)))$/;
|
||||
|
||||
export async function definitions(production: boolean) {
|
||||
console.log('Building definitions...');
|
||||
|
||||
const gameDefinitions: GameDefinitions = {
|
||||
version: (await readStringDVPL(`${DATA}/version.txt.dvpl`))
|
||||
.split(' ')[0]
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.join('.'),
|
||||
};
|
||||
const tankDefinitions: TankDefinitions = {};
|
||||
const modelDefinitions: ModelDefinitions = {};
|
||||
const equipmentDefinitions: EquipmentDefinitions = {
|
||||
@@ -341,6 +354,9 @@ export async function definitions(production: boolean) {
|
||||
};
|
||||
const consumableDefinitions: ConsumableDefinitions = {};
|
||||
const provisionDefinitions: ProvisionDefinitions = {};
|
||||
const skillDefinitions: SkillDefinitions = {
|
||||
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
|
||||
};
|
||||
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
|
||||
(nations) => nations.filter((nation) => nation !== 'common'),
|
||||
);
|
||||
@@ -367,6 +383,9 @@ export async function definitions(production: boolean) {
|
||||
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
|
||||
`${DATA}/${POI.provisionsCommon}.dvpl`,
|
||||
);
|
||||
const avatar = await readXMLDVPL<{ root: Avatar }>(
|
||||
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
|
||||
);
|
||||
|
||||
const tankXps = new Map<number, number>();
|
||||
|
||||
@@ -402,6 +421,8 @@ export async function definitions(production: boolean) {
|
||||
);
|
||||
|
||||
for (const tankKey in tankList.root) {
|
||||
if (botPattern.test(tankKey)) continue;
|
||||
|
||||
const gunXps = new Map<number, number>();
|
||||
const turretXps = new Map<number, number>();
|
||||
const engineXps = new Map<number, number>();
|
||||
@@ -558,6 +579,7 @@ export async function definitions(production: boolean) {
|
||||
tankDefinitions[tankId].tracks.push({
|
||||
id: trackId,
|
||||
weight: track.weight,
|
||||
name: strings[track.userString],
|
||||
traverseSpeed: track.rotationSpeed,
|
||||
dispersion: {
|
||||
move: track.shotDispersionFactors.vehicleMovement,
|
||||
@@ -1084,13 +1106,18 @@ export async function definitions(production: boolean) {
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(
|
||||
'test.tanks.cdon.lz4',
|
||||
Buffer.from(superCompress(tankDefinitions), 'base64'),
|
||||
);
|
||||
Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
|
||||
skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
|
||||
});
|
||||
|
||||
await commitAssets(
|
||||
'definitions',
|
||||
[
|
||||
{
|
||||
content: superCompress(gameDefinitions),
|
||||
encoding: 'base64',
|
||||
path: 'definitions/game.cdon.lz4',
|
||||
},
|
||||
{
|
||||
content: superCompress(tankDefinitions),
|
||||
encoding: 'base64',
|
||||
@@ -1116,6 +1143,11 @@ export async function definitions(production: boolean) {
|
||||
encoding: 'base64',
|
||||
path: 'definitions/provisions.cdon.lz4',
|
||||
},
|
||||
{
|
||||
content: superCompress(skillDefinitions),
|
||||
encoding: 'base64',
|
||||
path: 'definitions/skills.cdon.lz4',
|
||||
},
|
||||
],
|
||||
production,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { equipmentIcons } from './equipmentIcons';
|
||||
import { moduleIcons } from './moduleIcons';
|
||||
import { scratchedFlags } from './scratchedFlags';
|
||||
import { shellIcons } from './shellIcons';
|
||||
import { skillIcons } from './skillIcons';
|
||||
import { tankArmors } from './tankArmors';
|
||||
import { tankIcons } from './tankIcons';
|
||||
import { tankModels } from './tankModels';
|
||||
@@ -28,8 +29,9 @@ if (!targets && !allTargets) throw new Error('No target(s) specified');
|
||||
|
||||
const methods = [
|
||||
definitions,
|
||||
tankIcons,
|
||||
scratchedFlags,
|
||||
currencies,
|
||||
skillIcons,
|
||||
circleFlags,
|
||||
shellIcons,
|
||||
moduleIcons,
|
||||
@@ -37,9 +39,9 @@ const methods = [
|
||||
consumableProvisionIcons,
|
||||
camouflageIcons,
|
||||
boosterIcons,
|
||||
tankIcons,
|
||||
tankArmors,
|
||||
tankModels,
|
||||
currencies,
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
|
||||
@@ -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/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA, POI } from './constants';
|
||||
@@ -16,9 +17,11 @@ export async function moduleIcons(production: boolean) {
|
||||
.map(async (file) => {
|
||||
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
|
||||
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
|
||||
const content = await readBase64DVPL(
|
||||
`${DATA}/${POI.moduleIcons}/${file}`,
|
||||
);
|
||||
const content = (
|
||||
await sharp(await readDVPLFile(`${DATA}/${POI.moduleIcons}/${file}`))
|
||||
.trim()
|
||||
.toBuffer()
|
||||
).toString('base64');
|
||||
|
||||
return {
|
||||
content,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import sharp from 'sharp';
|
||||
import { TankClass } from '../../src/components/Tanks';
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||
import { DATA } from './constants';
|
||||
|
||||
interface SkillIcon {
|
||||
name: string;
|
||||
state: number;
|
||||
}
|
||||
|
||||
export interface Avatar {
|
||||
roles: unknown;
|
||||
skillsByClasses: Record<TankClass, string>;
|
||||
skills: {
|
||||
[name: string]: {
|
||||
userString: string;
|
||||
effectDescription: string;
|
||||
tipDescription: string;
|
||||
icon: SkillIcon | SkillIcon[];
|
||||
type: 'continuous' | 'trigger';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function skillIcons(production: boolean) {
|
||||
console.log('Building skill icons...');
|
||||
|
||||
const avatar = await readXMLDVPL<{ root: Avatar }>(
|
||||
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
|
||||
);
|
||||
const changes = await Promise.all(
|
||||
Object.values(avatar.root.skills).map(async (skill) => {
|
||||
const icon = Array.isArray(skill.icon) ? skill.icon[0] : skill.icon;
|
||||
const name = icon.name.split('/').at(-1)!.replace(/_\d$/, '');
|
||||
const path = `${DATA}${icon.name.replace('~res:', '')}.packed.webp.dvpl`;
|
||||
const image = sharp(await readDVPLFile(path)).trim();
|
||||
const content = (await image.toBuffer()).toString('base64');
|
||||
|
||||
return {
|
||||
content,
|
||||
encoding: 'base64',
|
||||
path: `icons/skills/${name}.webp`,
|
||||
} satisfies FileChange;
|
||||
}),
|
||||
);
|
||||
|
||||
commitAssets('skill icons', changes, production);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readdir } from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { Vector3Tuple } from 'three';
|
||||
import { NATION_IDS } from '../../src/constants/nations';
|
||||
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL';
|
||||
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
|
||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||
@@ -50,23 +51,29 @@ export async function tankIcons(production: boolean) {
|
||||
const parameters = await readYAMLDVPL<TankParameters>(
|
||||
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
|
||||
);
|
||||
const small = `${DATA}/${parameters.resourcesPath.smallIconPath
|
||||
const smallPath = `${DATA}/${parameters.resourcesPath.smallIconPath
|
||||
.replace(/~res:\//, '')
|
||||
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
||||
const big = `${DATA}/${parameters.resourcesPath.bigIconPath
|
||||
const bigPath = `${DATA}/${parameters.resourcesPath.bigIconPath
|
||||
.replace(/~res:\//, '')
|
||||
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
||||
const big = await sharp(await readDVPLFile(bigPath))
|
||||
.trim()
|
||||
.toBuffer();
|
||||
const small = await sharp(await readDVPLFile(smallPath))
|
||||
.trim()
|
||||
.toBuffer();
|
||||
|
||||
if (big) {
|
||||
changes.push({
|
||||
content: await readBase64DVPL(big),
|
||||
content: big.toString('base64'),
|
||||
encoding: 'base64',
|
||||
path: `icons/tanks/big/${id}.webp`,
|
||||
});
|
||||
}
|
||||
if (small) {
|
||||
changes.push({
|
||||
content: await readBase64DVPL(small),
|
||||
content: small.toString('base64'),
|
||||
encoding: 'base64',
|
||||
path: `icons/tanks/small/${id}.webp`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { tankDefinitions } from '../src/core/blitzkrieg/tankDefinitions';
|
||||
|
||||
// random vercel bug forces require import
|
||||
const { writeFileSync } = require('fs');
|
||||
|
||||
console.log('Building sitemap...');
|
||||
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const values = Object.values(awaitedTankDefinitions);
|
||||
const txt = values
|
||||
.map((tank) => `https://blitz-krieg.vercel.app/tools/tankopedia/${tank.id}/`)
|
||||
.join('\n');
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
${values
|
||||
.map(
|
||||
(tank) =>
|
||||
`<url><loc>https://blitz-krieg.vercel.app/tools/tankopedia/${tank.id}/</loc></url>`,
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`;
|
||||
|
||||
writeFileSync('public/sitemap.txt', txt);
|
||||
writeFileSync('public/sitemap.xml', xml);
|
||||
|
||||
console.log(`Built sitemap for ${values.length} tanks`);
|
||||
+42
-92
@@ -1,20 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes';
|
||||
import { AlertDialog, Button, Flex, Theme } from '@radix-ui/themes';
|
||||
import '@radix-ui/themes/styles.css';
|
||||
import { config } from 'dotenv';
|
||||
import { Roboto_Flex } from 'next/font/google';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { SkyLine } from '../assets/art/SkyLine';
|
||||
import { Analytics } from '../components/Analytics';
|
||||
import { Google } from '../components/Google';
|
||||
import { Footer } from '../components/Footer';
|
||||
import Navbar from '../components/Navbar';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import isDev from '../core/blitzkrieg/isDev';
|
||||
import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
|
||||
import { useFullScreen } from '../hooks/useFullScreen';
|
||||
import { useApp } from '../stores/app';
|
||||
import { mintTheme } from '../themes/mint';
|
||||
|
||||
config();
|
||||
|
||||
@@ -33,6 +31,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const isEmbed = pathname.split('/')[1] === 'embeds';
|
||||
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
|
||||
const isFullScreen = useFullScreen();
|
||||
|
||||
useEffect(() => {
|
||||
setShowDevBuildAlert(
|
||||
@@ -51,110 +50,61 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
name="description"
|
||||
content="🎉 Tools for everything World of Tanks Blitz"
|
||||
/>
|
||||
<Analytics />
|
||||
|
||||
<Google />
|
||||
</head>
|
||||
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingTop: isEmbed ? 0 : '3.25rem',
|
||||
backgroundColor: isEmbed ? 'transparent' : undefined,
|
||||
}}
|
||||
>
|
||||
<Theme
|
||||
appearance="dark"
|
||||
panelBackground="translucent"
|
||||
panelBackground="solid"
|
||||
radius="full"
|
||||
suppressHydrationWarning
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{!isEmbed && <Navbar />}
|
||||
<AlertDialog.Root open={showDevBuildAlert}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This version may have a lot of issues. Report issues to{' '}
|
||||
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
|
||||
the official Discord server
|
||||
</a>
|
||||
. Also consider using{' '}
|
||||
<a href="https://blitz-krieg.vercel.app/">
|
||||
the more stable release version
|
||||
</a>
|
||||
. You will be asked again in 8 days.
|
||||
</AlertDialog.Description>
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{ minHeight: '100vh', paddingTop: isEmbed ? undefined : 52 }}
|
||||
>
|
||||
{!isEmbed && <Navbar />}
|
||||
<AlertDialog.Root open={showDevBuildAlert}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This version may have a lot of issues. Report issues to{' '}
|
||||
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
|
||||
the official Discord server
|
||||
</a>
|
||||
. Also consider using{' '}
|
||||
<a href="https://blitz-krieg.vercel.app/">
|
||||
the more stable release version
|
||||
</a>
|
||||
. You will be asked again in 8 days.
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex justify="end">
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={() => {
|
||||
setShowDevBuildAlert(false);
|
||||
useApp.setState({ devBuildAgreementTime: Date.now() });
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<Flex direction="column">{children}</Flex>
|
||||
|
||||
{!isEmbed && (
|
||||
<PageWrapper>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: mintTheme.colors.componentInteractive,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="1"
|
||||
style={{
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
<Flex justify="end">
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={() => {
|
||||
setShowDevBuildAlert(false);
|
||||
useApp.setState({ devBuildAgreementTime: Date.now() });
|
||||
}}
|
||||
>
|
||||
<Text size="4" color="mint">
|
||||
Blitzkrieg. Free forever. For everyone.
|
||||
</Text>
|
||||
<Text color="mint">
|
||||
<b>Consider supporting today 🍀</b>
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
color="mint"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
<CaretRightIcon width={32} height={32} />
|
||||
</Flex>
|
||||
</Text>
|
||||
|
||||
<SkyLine
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 'min(8vw, 15%)',
|
||||
top: '5%',
|
||||
}}
|
||||
/>
|
||||
Continue
|
||||
</Button>
|
||||
</Flex>
|
||||
</a>
|
||||
</PageWrapper>
|
||||
)}
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
{children}
|
||||
|
||||
{!isEmbed && !isFullScreen && <Footer />}
|
||||
</Flex>
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { theme } from '../stitches.config';
|
||||
|
||||
export const tool = styleVariants({
|
||||
enabled: {
|
||||
boxShadow: `inset 0 -256px 512px -128px ${theme.colors.appBackground1}`,
|
||||
boxShadow: `inset 0 -200px 128px -128px ${theme.colors.appBackground1}`,
|
||||
|
||||
':hover': {
|
||||
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
|
||||
|
||||
+63
-79
@@ -1,95 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { CaretRightIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Text, TextField } from '@radix-ui/themes';
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
import { Search, SearchItem } from 'react-fuzzysort';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import { TOOLS } from '../constants/tools';
|
||||
import { theme } from '../stitches.config';
|
||||
import * as styles from './page.css';
|
||||
|
||||
export default function Page() {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<TextField.Root>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
<TextField.Input ref={input} placeholder="Search tools..." />
|
||||
</TextField.Root>
|
||||
<Flex direction="column" gap="4" justify="center" style={{ flex: 1 }}>
|
||||
{/* <TextField.Root placeholder="Search anything...">
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root> */}
|
||||
|
||||
<Flex gap="3" wrap="wrap" align="center" justify="center">
|
||||
<Search
|
||||
input={input}
|
||||
list={TOOLS.map(
|
||||
(tool) =>
|
||||
({
|
||||
node: (
|
||||
<Link
|
||||
href={tool.href ?? `/tools/${tool.id}`}
|
||||
target={tool.href ? '_blank' : undefined}
|
||||
className={
|
||||
tool.disabled ? styles.tool.disabled : styles.tool.enabled
|
||||
}
|
||||
style={{
|
||||
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
|
||||
cursor: tool.disabled ? 'default' : 'pointer',
|
||||
opacity: tool.disabled ? 0.25 : 1,
|
||||
height: 128,
|
||||
minWidth: 256,
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
gap: 4,
|
||||
transition: `box-shadow ${theme.durations.regular}`,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (tool.disabled) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{tool.title}
|
||||
<CaretRightIcon style={{ width: '1em', height: '1em' }} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tool.disabled ? 'Coming soon!' : tool.description}
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
query: `${tool.title} ${tool.description}`,
|
||||
}) satisfies SearchItem,
|
||||
)}
|
||||
/>
|
||||
<Flex gap="3" wrap="wrap" align="center" justify="center">
|
||||
{TOOLS.map((tool) => (
|
||||
<Link
|
||||
href={tool.href ?? `/tools/${tool.id}`}
|
||||
target={tool.href ? '_blank' : undefined}
|
||||
className={
|
||||
tool.disabled ? styles.tool.disabled : styles.tool.enabled
|
||||
}
|
||||
style={{
|
||||
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
|
||||
cursor: tool.disabled ? 'default' : 'pointer',
|
||||
opacity: tool.disabled ? 0.25 : 1,
|
||||
height: 128,
|
||||
minWidth: 256,
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
textDecoration: 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
gap: 4,
|
||||
transition: `box-shadow ${theme.durations.regular}`,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (tool.disabled) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: theme.colors.textHighContrast,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{tool.title}
|
||||
<CaretRightIcon style={{ width: '1em', height: '1em' }} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.textLowContrast,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tool.disabled ? 'Coming soon!' : tool.description}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Text size="1" color="gray" align="center">
|
||||
Images courtesy of Wargaming Art Department
|
||||
</Text>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LoopIcon, TrashIcon } from '@radix-ui/react-icons';
|
||||
import { Dialog, Flex, IconButton } from '@radix-ui/themes';
|
||||
import { use, useState } from 'react';
|
||||
import { provisionDefinitions } from '../../../../core/blitzkrieg/provisionDefinitions';
|
||||
import { tankToCompareMember } from '../../../../core/blitzkrieg/tankToCompareMember';
|
||||
import { mutateCompareTemporary } from '../../../../stores/compare';
|
||||
import { TankSearch } from '../../tankopedia/components/TankSearch';
|
||||
|
||||
interface TankControlProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TankControl({ index }: TankControlProps) {
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Flex gap="3" justify="center" style={{ width: '100%' }}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareTemporary((draft) => {
|
||||
draft.members.splice(index, 1);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
<Dialog.Root
|
||||
open={switchTankDialogOpen}
|
||||
onOpenChange={setSwitchTankDialogOpen}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<LoopIcon />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Flex gap="4" direction="column">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateCompareTemporary((draft) => {
|
||||
draft.members[index] = tankToCompareMember(
|
||||
tank,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
setSwitchTankDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,33 +1,22 @@
|
||||
import { blackA } from '@radix-ui/colors';
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { PuffLoader } from 'react-spinners';
|
||||
import { Flex, Spinner, Text } from '@radix-ui/themes';
|
||||
import { theme } from '../../../stitches.config';
|
||||
|
||||
const NAV_HEIGHT = 52.65;
|
||||
|
||||
interface LoaderProps {
|
||||
naked?: boolean;
|
||||
color?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export function Loader({ naked, color, progress }: LoaderProps) {
|
||||
export function Loader({ naked, progress }: LoaderProps) {
|
||||
const animation = (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Flex align="center" gap="2">
|
||||
<Spinner />
|
||||
{progress !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(calc(-50% + 5px), calc(-50% + 5px))',
|
||||
}}
|
||||
>
|
||||
{Math.round(progress * 100)}%
|
||||
</Text>
|
||||
<Text color="gray">{Math.round(progress * 100)}%</Text>
|
||||
)}
|
||||
<PuffLoader color={color ?? theme.colors.solidBackground} />
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (naked) return animation;
|
||||
|
||||
@@ -19,7 +19,19 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{tool && <title>{tool.title}</title>}
|
||||
{tool && (
|
||||
<>
|
||||
<title>{tool.title}</title>
|
||||
{tool.pageDescription !== undefined && (
|
||||
<meta name="description" content={tool.pageDescription} />
|
||||
)}
|
||||
|
||||
<meta
|
||||
property="og:image"
|
||||
content={`/assets/banners/${toolId}.webp`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pathSplit.length === 3 && (
|
||||
<div
|
||||
@@ -73,7 +85,15 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Suspense fallback={<Loader />}>{children}</Suspense>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ flex: 1 }}>
|
||||
<Loader />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -53,29 +53,26 @@ export function PageTurner({
|
||||
<CaretLeftIcon />
|
||||
)}
|
||||
</Button>
|
||||
<TextField.Root>
|
||||
|
||||
<TextField.Root
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
style={{ textAlign: 'center' }}
|
||||
min={1}
|
||||
max={pages}
|
||||
onBlur={(event) => {
|
||||
onPageChange(
|
||||
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
|
||||
false,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextField.Slot>Page</TextField.Slot>
|
||||
<TextField.Input
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
style={{ width: 64, textAlign: 'center' }}
|
||||
min={1}
|
||||
max={pages}
|
||||
onBlur={(event) => {
|
||||
onPageChange(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(pages - 1, event.target.valueAsNumber - 1),
|
||||
),
|
||||
false,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>out of {pages}</TextField.Slot>
|
||||
</TextField.Root>
|
||||
<Button
|
||||
|
||||
@@ -605,7 +605,7 @@ export default function Page() {
|
||||
|
||||
<Dialog.Content>
|
||||
<Flex gap="4" justify="center">
|
||||
<TextField.Input
|
||||
<TextField.Root
|
||||
ref={scoreInput}
|
||||
type="number"
|
||||
placeholder="Type a score..."
|
||||
@@ -638,7 +638,7 @@ export default function Page() {
|
||||
|
||||
<Dialog.Content>
|
||||
<Flex gap="4" justify="center">
|
||||
<TextField.Input
|
||||
<TextField.Root
|
||||
ref={positionInput}
|
||||
type="number"
|
||||
placeholder="Type a position..."
|
||||
@@ -759,105 +759,100 @@ export default function Page() {
|
||||
|
||||
<Dialog.Content>
|
||||
<Flex gap="4" direction="column">
|
||||
<TextField.Root>
|
||||
<TextField.Root
|
||||
onChange={debounce(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const trimmedSearch = event.target.value.trim();
|
||||
const encodedSearch = encodeURIComponent(trimmedSearch);
|
||||
|
||||
if (trimmedSearch) {
|
||||
if (season === 0) {
|
||||
setSearchLoading(true);
|
||||
|
||||
const accountList = await searchCurrentRatingPlayers(
|
||||
region,
|
||||
trimmedSearch,
|
||||
);
|
||||
const values = Object.values(accountList).filter(
|
||||
(searchedPlayer) => !searchedPlayer.skip,
|
||||
);
|
||||
const valuesWithTypes =
|
||||
values as ((typeof values)[number] & {
|
||||
skip: false;
|
||||
})[];
|
||||
|
||||
useLeaderboardCache.setState(
|
||||
produce((draft: LeaderboardCache) => {
|
||||
if (draft) {
|
||||
values.forEach((player) => {
|
||||
const playerWithTypes =
|
||||
player as typeof player & { skip: false };
|
||||
|
||||
draft[region][season][
|
||||
playerWithTypes.number - 1
|
||||
] = playerWithTypes.spa_id;
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
useNameCache.setState(
|
||||
produce((draft: typeof names) => {
|
||||
valuesWithTypes.forEach((searchedPlayer) => {
|
||||
if (searchedPlayer.nickname) {
|
||||
draft[region][searchedPlayer.spa_id] = {
|
||||
nickname: searchedPlayer.nickname,
|
||||
clan: searchedPlayer.clan_tag,
|
||||
};
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
setSearchResults(
|
||||
valuesWithTypes.map((searchedPlayer) => ({
|
||||
account_id: searchedPlayer.spa_id,
|
||||
nickname: searchedPlayer.nickname,
|
||||
number: searchedPlayer.number,
|
||||
})),
|
||||
);
|
||||
setSearchLoading(false);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
const accountList = await fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
|
||||
);
|
||||
const playerKeys = Object.keys(
|
||||
leaderboard[region][season],
|
||||
);
|
||||
|
||||
setSearchResults(
|
||||
accountList
|
||||
.map((searchedPlayer) => ({
|
||||
...searchedPlayer,
|
||||
number: parseInt(
|
||||
playerKeys.find(
|
||||
(indexAsString) =>
|
||||
leaderboard[region][season][
|
||||
parseInt(indexAsString)
|
||||
] === searchedPlayer.account_id,
|
||||
)!,
|
||||
),
|
||||
}))
|
||||
.filter((player) => !Number.isNaN(player.number)),
|
||||
);
|
||||
|
||||
setSearchLoading(false);
|
||||
}
|
||||
} else {
|
||||
setSearchResults(undefined);
|
||||
}
|
||||
},
|
||||
500,
|
||||
)}
|
||||
placeholder="Search for player..."
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
|
||||
<TextField.Input
|
||||
onChange={debounce(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const trimmedSearch = event.target.value.trim();
|
||||
const encodedSearch = encodeURIComponent(trimmedSearch);
|
||||
|
||||
if (trimmedSearch) {
|
||||
if (season === 0) {
|
||||
setSearchLoading(true);
|
||||
|
||||
const accountList =
|
||||
await searchCurrentRatingPlayers(
|
||||
region,
|
||||
trimmedSearch,
|
||||
);
|
||||
const values = Object.values(accountList).filter(
|
||||
(searchedPlayer) => !searchedPlayer.skip,
|
||||
);
|
||||
const valuesWithTypes =
|
||||
values as ((typeof values)[number] & {
|
||||
skip: false;
|
||||
})[];
|
||||
|
||||
useLeaderboardCache.setState(
|
||||
produce((draft: LeaderboardCache) => {
|
||||
if (draft) {
|
||||
values.forEach((player) => {
|
||||
const playerWithTypes =
|
||||
player as typeof player & { skip: false };
|
||||
|
||||
draft[region][season][
|
||||
playerWithTypes.number - 1
|
||||
] = playerWithTypes.spa_id;
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
useNameCache.setState(
|
||||
produce((draft: typeof names) => {
|
||||
valuesWithTypes.forEach((searchedPlayer) => {
|
||||
if (searchedPlayer.nickname) {
|
||||
draft[region][searchedPlayer.spa_id] = {
|
||||
nickname: searchedPlayer.nickname,
|
||||
clan: searchedPlayer.clan_tag,
|
||||
};
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
setSearchResults(
|
||||
valuesWithTypes.map((searchedPlayer) => ({
|
||||
account_id: searchedPlayer.spa_id,
|
||||
nickname: searchedPlayer.nickname,
|
||||
number: searchedPlayer.number,
|
||||
})),
|
||||
);
|
||||
setSearchLoading(false);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
const accountList = await fetchBlitz<AccountList>(
|
||||
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
|
||||
);
|
||||
const playerKeys = Object.keys(
|
||||
leaderboard[region][season],
|
||||
);
|
||||
|
||||
setSearchResults(
|
||||
accountList
|
||||
.map((searchedPlayer) => ({
|
||||
...searchedPlayer,
|
||||
number: parseInt(
|
||||
playerKeys.find(
|
||||
(indexAsString) =>
|
||||
leaderboard[region][season][
|
||||
parseInt(indexAsString)
|
||||
] === searchedPlayer.account_id,
|
||||
)!,
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(player) => !Number.isNaN(player.number),
|
||||
),
|
||||
);
|
||||
|
||||
setSearchLoading(false);
|
||||
}
|
||||
} else {
|
||||
setSearchResults(undefined);
|
||||
}
|
||||
},
|
||||
500,
|
||||
)}
|
||||
placeholder="Search for player..."
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
|
||||
@@ -37,19 +37,18 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
|
||||
<Heading size="4">Edit title</Heading>
|
||||
|
||||
<Flex gap="2" style={{ width: '100%' }}>
|
||||
<TextField.Root style={{ flex: 1 }}>
|
||||
<TextField.Input
|
||||
ref={titleInput}
|
||||
defaultValue={session.title}
|
||||
placeholder="Enter title..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && titleInput.current) {
|
||||
setShowTitleEditor(false);
|
||||
useSession.setState({ title: titleInput.current!.value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
<TextField.Root
|
||||
style={{ flex: 1 }}
|
||||
ref={titleInput}
|
||||
defaultValue={session.title}
|
||||
placeholder="Enter title..."
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && titleInput.current) {
|
||||
setShowTitleEditor(false);
|
||||
useSession.setState({ title: titleInput.current!.value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog.Close>
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
TextField,
|
||||
} from '@radix-ui/themes';
|
||||
@@ -56,38 +57,36 @@ export default function Page() {
|
||||
<PageWrapper color="blue">
|
||||
<div className={styles.toolBar}>
|
||||
<div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}>
|
||||
<TextField.Root>
|
||||
<TextField.Root
|
||||
defaultValue={session.isTracking ? session.nickname : undefined}
|
||||
ref={input}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.target.value) {
|
||||
setShowSearchResults(true);
|
||||
setSearchResults(undefined);
|
||||
} else {
|
||||
setShowSearchResults(false);
|
||||
}
|
||||
|
||||
handleChange(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 (https://github.com/radix-ui/primitives/issues/1969) is fixed
|
||||
if (showSearchResults) event.target.focus();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setShowSearchResults(false);
|
||||
input.current?.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search for a player..."
|
||||
>
|
||||
<TextField.Slot>
|
||||
<PersonIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
|
||||
<TextField.Input
|
||||
defaultValue={session.isTracking ? session.nickname : undefined}
|
||||
ref={input}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.target.value) {
|
||||
setShowSearchResults(true);
|
||||
setSearchResults(undefined);
|
||||
} else {
|
||||
setShowSearchResults(false);
|
||||
}
|
||||
|
||||
handleChange(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 is fixed
|
||||
if (showSearchResults) event.target.focus();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setShowSearchResults(false);
|
||||
input.current?.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search for a player..."
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
<DropdownMenu.Root open={showSearchResults} modal={false}>
|
||||
@@ -251,6 +250,20 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionPage naked={false} />
|
||||
|
||||
{!session.isTracking && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Heading color="gray">No player selected</Heading>
|
||||
<Text color="gray">
|
||||
Search for a <PersonIcon /> Player to get started
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
Flex,
|
||||
Heading,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@radix-ui/themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
|
||||
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
|
||||
@@ -37,58 +27,70 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
||||
const [tab, setTab] = useState('search');
|
||||
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
|
||||
useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (mode !== 'armor') return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={
|
||||
floating
|
||||
? {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" justify="between" gap="6">
|
||||
<Flex>
|
||||
{antagonist.gun.shells.map((shell, index) => {
|
||||
return (
|
||||
<ShellButton
|
||||
selected={antagonist.shell.id === shell.id}
|
||||
shell={shell.icon}
|
||||
rowChild
|
||||
first={index === 0}
|
||||
key={shell.id}
|
||||
last={index === antagonist.gun.shells.length - 1}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.shell = shell;
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
<Flex justify="center">
|
||||
<Card
|
||||
style={
|
||||
floating
|
||||
? {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
wrap="wrap"
|
||||
style={{
|
||||
gap: 'min(5vw, 32px)',
|
||||
}}
|
||||
>
|
||||
<Flex>
|
||||
{antagonist.gun.shells.map((shell, index) => {
|
||||
return (
|
||||
<ShellButton
|
||||
selected={antagonist.shell.id === shell.id}
|
||||
shell={shell.icon}
|
||||
rowChild
|
||||
first={index === 0}
|
||||
key={shell.id}
|
||||
last={index === antagonist.gun.shells.length - 1}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.shell = shell;
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="4">
|
||||
{!floating && <Text>Versus</Text>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist, draft.antagonist] = [
|
||||
draft.antagonist,
|
||||
draft.protagonist,
|
||||
];
|
||||
pushTankopediaPath(draft.antagonist!.tank.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShuffleIcon /> Swap
|
||||
</Button>
|
||||
|
||||
{floating && (
|
||||
<Flex gap="2" align="center">
|
||||
Versus {antagonist.tank.name}
|
||||
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{!floating && (
|
||||
<Flex align="center" gap="4">
|
||||
<Dialog.Root
|
||||
open={antagonistSelectorOpen}
|
||||
onOpenChange={setAntagonistSelectorVisible}
|
||||
@@ -96,9 +98,8 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
||||
<Dialog.Trigger>
|
||||
<Button variant="ghost">
|
||||
<Flex gap="2" align="center">
|
||||
{antagonist.tank.name}
|
||||
Versus {antagonist.tank.name}
|
||||
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
||||
<CaretRightIcon />
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
@@ -160,61 +161,51 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex>
|
||||
{antagonist.tank.turrets.map((turret, index) => (
|
||||
<Tooltip content={turret.name} key={turret.id}>
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index ===
|
||||
antagonist.tank.turrets.length - 1
|
||||
}
|
||||
key={turret.id}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.turret = turret;
|
||||
draft.antagonist!.gun =
|
||||
turret.guns.at(-1)!;
|
||||
draft.antagonist!.shell =
|
||||
draft.antagonist!.gun.shells[0];
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
selected={
|
||||
antagonist.turret.id === turret.id
|
||||
}
|
||||
discriminator={
|
||||
TIER_ROMAN_NUMERALS[turret.tier]
|
||||
}
|
||||
module="turret"
|
||||
/>
|
||||
</Tooltip>
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index === antagonist.tank.turrets.length - 1
|
||||
}
|
||||
key={turret.id}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.turret = turret;
|
||||
draft.antagonist!.gun =
|
||||
turret.guns.at(-1)!;
|
||||
draft.antagonist!.shell =
|
||||
draft.antagonist!.gun.shells[0];
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
selected={antagonist.turret.id === turret.id}
|
||||
discriminator={
|
||||
TIER_ROMAN_NUMERALS[turret.tier]
|
||||
}
|
||||
module="turret"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex>
|
||||
{antagonist.turret.guns.map((gun, index) => (
|
||||
<Tooltip content={gun.name} key={gun.id}>
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index ===
|
||||
antagonist.turret.guns.length - 1
|
||||
}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.gun = gun;
|
||||
draft.antagonist!.shell = gun.shells[0];
|
||||
});
|
||||
}}
|
||||
selected={antagonist.gun.id === gun.id}
|
||||
discriminator={
|
||||
TIER_ROMAN_NUMERALS[gun.tier]
|
||||
}
|
||||
module="gun"
|
||||
/>
|
||||
</Tooltip>
|
||||
<ModuleButton
|
||||
rowChild
|
||||
first={index === 0}
|
||||
last={
|
||||
index === antagonist.turret.guns.length - 1
|
||||
}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.gun = gun;
|
||||
draft.antagonist!.shell = gun.shells[0];
|
||||
});
|
||||
}}
|
||||
selected={antagonist.gun.id === gun.id}
|
||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||
module="gun"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -251,24 +242,9 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
[draft.protagonist, draft.antagonist] = [
|
||||
draft.antagonist,
|
||||
draft.protagonist,
|
||||
];
|
||||
pushTankopediaPath(draft.antagonist!.tank.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShuffleIcon /> Swap
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { ReactNode } from 'react';
|
||||
import * as styles from '../../../../page.css';
|
||||
|
||||
interface ConfigurationChildWrapperProps {
|
||||
children: ReactNode;
|
||||
@@ -8,5 +8,9 @@ interface ConfigurationChildWrapperProps {
|
||||
export function ConfigurationChildWrapper({
|
||||
children,
|
||||
}: ConfigurationChildWrapperProps) {
|
||||
return <div className={styles.configurationChild}>{children}</div>;
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+54
-42
@@ -1,6 +1,14 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Popover,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton';
|
||||
import { ConsumablesManager } from '../../../../../../../components/ConsumablesManager';
|
||||
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
|
||||
import { consumableDefinitions } from '../../../../../../../core/blitzkrieg/consumableDefinitions';
|
||||
import { useEquipment } from '../../../../../../../hooks/useEquipment';
|
||||
@@ -27,49 +35,53 @@ export function Consumables() {
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Consumables</Heading>
|
||||
<Flex gap="4" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Heading size="4">Consumables</Heading>
|
||||
|
||||
<Flex wrap="wrap">
|
||||
{consumablesList.map((consumable, index) => {
|
||||
const selected = consumables.includes(consumable.id);
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
return (
|
||||
<ConsumableButton
|
||||
duration={
|
||||
typeof consumable.duration === 'number'
|
||||
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
|
||||
: undefined
|
||||
}
|
||||
cooldown={
|
||||
consumable.cooldown *
|
||||
(hasConsumableDeliverySystem ? 0.85 : 1) *
|
||||
(1 - cooldownBooster * 0.1)
|
||||
}
|
||||
key={consumable.id}
|
||||
first={index === 0}
|
||||
last={index === consumablesList.length - 1}
|
||||
rowChild
|
||||
disabled={
|
||||
protagonist.tank.consumables === consumables.length && !selected
|
||||
}
|
||||
consumable={consumable.id}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
if (selected) {
|
||||
draft.protagonist!.consumables =
|
||||
draft.protagonist!.consumables.filter(
|
||||
(id) => id !== consumable.id,
|
||||
);
|
||||
} else {
|
||||
draft.protagonist!.consumables.push(consumable.id);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Popover.Content>
|
||||
<Text>
|
||||
<Text color="amber">Yellow consumables</Text> do not affect the
|
||||
statistics.
|
||||
</Text>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.consumables = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<ConsumablesManager
|
||||
consumables={consumablesList}
|
||||
selected={consumables}
|
||||
cooldownBooster={cooldownBooster}
|
||||
disabled={protagonist.tank.consumables === consumables.length}
|
||||
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
|
||||
hasHighEndConsumables={hasHighEndConsumables}
|
||||
timers
|
||||
onChange={(consumables) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.consumables = consumables;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button, Flex, Heading } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { EquipmentManager } from '../../../../../../../components/EquipmentManager';
|
||||
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Equipment() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const equipmentPreset =
|
||||
awaitedEquipmentDefinitions.presets[protagonist.tank.equipment];
|
||||
const equipmentMatrix = useDuel(
|
||||
(state) => state.protagonist!.equipmentMatrix,
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">Equipment</Heading>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.equipmentMatrix.forEach((row) => {
|
||||
row.forEach((_, index) => {
|
||||
row[index] = 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<EquipmentManager
|
||||
matrix={equipmentMatrix}
|
||||
preset={equipmentPreset}
|
||||
onChange={(matrix) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.equipmentMatrix = matrix;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton';
|
||||
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Equipments() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const equipmentRows =
|
||||
awaitedEquipmentDefinitions.presets[protagonist.tank.equipment];
|
||||
const equipmentMatrix = useDuel((state) => state.protagonist!.equipment);
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Equipments</Heading>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
{equipmentRows.map((equipmentRow, rowIndex) => (
|
||||
<Flex gap="2" key={rowIndex}>
|
||||
{equipmentRow.map((equipment, columnIndex) => (
|
||||
<Flex key={columnIndex}>
|
||||
<EquipmentButton
|
||||
equipment={equipment[0]}
|
||||
first
|
||||
rowChild
|
||||
selected={equipmentMatrix[rowIndex][columnIndex] === -1}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
if (equipmentMatrix[rowIndex][columnIndex] === -1) {
|
||||
draft.protagonist!.equipment[rowIndex][columnIndex] = 0;
|
||||
} else {
|
||||
draft.protagonist!.equipment[rowIndex][columnIndex] =
|
||||
-1;
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<EquipmentButton
|
||||
equipment={equipment[1]}
|
||||
last
|
||||
rowChild
|
||||
selected={equipmentMatrix[rowIndex][columnIndex] === 1}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
if (equipmentMatrix[rowIndex][columnIndex] === 1) {
|
||||
draft.protagonist!.equipment[rowIndex][columnIndex] = 0;
|
||||
} else {
|
||||
draft.protagonist!.equipment[rowIndex][columnIndex] = 1;
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
+27
-28
@@ -87,36 +87,35 @@ export function Miscellaneous() {
|
||||
direction="column"
|
||||
gap="1"
|
||||
justify="end"
|
||||
style={{ width: 180 }}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
<TextField.Root>
|
||||
<TextField.Root
|
||||
ref={crewInput}
|
||||
defaultValue={Math.round(crewMastery * 100)}
|
||||
style={{ textAlign: 'right' }}
|
||||
onBlur={() => {
|
||||
const newValue = Number(crewInput.current!.value) / 100;
|
||||
|
||||
if (isNaN(newValue)) {
|
||||
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.crewMastery = clamp(
|
||||
newValue / 100,
|
||||
0.5,
|
||||
1,
|
||||
);
|
||||
});
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
crewInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextField.Slot>Crew</TextField.Slot>
|
||||
<TextField.Input
|
||||
ref={crewInput}
|
||||
defaultValue={Math.round(crewMastery * 100)}
|
||||
style={{ textAlign: 'right' }}
|
||||
onBlur={() => {
|
||||
const newValue = Number(crewInput.current!.value) / 100;
|
||||
|
||||
if (isNaN(newValue)) {
|
||||
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.crewMastery = clamp(
|
||||
newValue / 100,
|
||||
0.5,
|
||||
1,
|
||||
);
|
||||
});
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
crewInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>%</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
|
||||
@@ -1,108 +1,79 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
|
||||
import { Button, Flex, Heading } from '@radix-ui/themes';
|
||||
import { ModuleManager } from '../../../../../../../components/ModuleManager';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
|
||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||
|
||||
export function Modules() {
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const hasUpgrades =
|
||||
protagonist.tank.turrets.length > 1 ||
|
||||
protagonist.tank.turrets[0].guns.length > 1 ||
|
||||
protagonist.tank.engines.length > 1 ||
|
||||
protagonist.tank.tracks.length > 1;
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Modules</Heading>
|
||||
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Flex>
|
||||
{protagonist.tank.turrets.map((turret, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={turret.id}
|
||||
selected={protagonist.turret.id === turret.id}
|
||||
module="turret"
|
||||
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
|
||||
first={index === 0}
|
||||
last={index === protagonist.tank.turrets.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.turret = turret;
|
||||
draft.protagonist!.gun = turret.guns.at(-1)!;
|
||||
draft.protagonist!.shell = turret.guns.at(-1)!.shells[0];
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{protagonist.turret.guns.map((gun, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={gun.id}
|
||||
module="gun"
|
||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||
selected={protagonist.gun.id === gun.id}
|
||||
first={index === 0}
|
||||
last={index === protagonist.turret.guns.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.gun = gun;
|
||||
draft.protagonist!.shell = gun.shells[0];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{protagonist.tank.engines.map((engine, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={engine.id}
|
||||
module="engine"
|
||||
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
|
||||
selected={protagonist.engine.id === engine.id}
|
||||
first={index === 0}
|
||||
last={index === protagonist.tank.engines.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.engine = engine;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
{protagonist.tank.tracks.map((track, index) => {
|
||||
return (
|
||||
<ModuleButton
|
||||
key={track.id}
|
||||
module="chassis"
|
||||
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
|
||||
selected={protagonist.track.id === track.id}
|
||||
first={index === 0}
|
||||
last={index === protagonist.tank.tracks.length - 1}
|
||||
rowChild
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.track = track;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">Modules</Heading>
|
||||
{hasUpgrades && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.turret =
|
||||
draft.protagonist!.tank.turrets[0];
|
||||
draft.protagonist!.gun = draft.protagonist!.turret.guns[0];
|
||||
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
|
||||
draft.protagonist!.engine =
|
||||
draft.protagonist!.tank.engines[0];
|
||||
draft.protagonist!.track = draft.protagonist!.tank.tracks[0];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Stock
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.turret =
|
||||
draft.protagonist!.tank.turrets.at(-1)!;
|
||||
draft.protagonist!.gun =
|
||||
draft.protagonist!.turret.guns.at(-1)!;
|
||||
draft.protagonist!.shell =
|
||||
draft.protagonist!.gun.shells.at(-1)!;
|
||||
draft.protagonist!.engine =
|
||||
draft.protagonist!.tank.engines.at(-1)!;
|
||||
draft.protagonist!.track =
|
||||
draft.protagonist!.tank.tracks.at(-1)!;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<ModuleManager
|
||||
tank={protagonist.tank}
|
||||
turret={protagonist.turret}
|
||||
gun={protagonist.gun}
|
||||
shell={protagonist.shell}
|
||||
engine={protagonist.engine}
|
||||
track={protagonist.track}
|
||||
onChange={(modules) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist = { ...draft.protagonist!, ...modules };
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
+26
-32
@@ -1,6 +1,6 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { Button, Flex, Heading } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton';
|
||||
import { ProvisionsManager } from '../../../../../../../components/ProvisionsManager';
|
||||
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
|
||||
import { provisionDefinitions } from '../../../../../../../core/blitzkrieg/provisionDefinitions';
|
||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||
@@ -18,37 +18,31 @@ export function Provisions() {
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Heading size="4">Provisions</Heading>
|
||||
|
||||
<Flex wrap="wrap">
|
||||
{provisionsList.map((provision, index) => {
|
||||
const selected = provisions.includes(provision.id);
|
||||
|
||||
return (
|
||||
<ProvisionButton
|
||||
key={provision.id}
|
||||
first={index === 0}
|
||||
last={index === provisionsList.length - 1}
|
||||
rowChild
|
||||
disabled={tank.provisions === provisions.length && !selected}
|
||||
provision={provision.id}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
if (selected) {
|
||||
draft.protagonist!.provisions =
|
||||
draft.protagonist!.provisions.filter(
|
||||
(id) => id !== provision.id,
|
||||
);
|
||||
} else {
|
||||
draft.protagonist!.provisions.push(provision.id);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Flex gap="4" align="center">
|
||||
<Heading size="4">Provisions</Heading>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.provisions = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<ProvisionsManager
|
||||
provisions={provisionsList.map((provision) => provision.id)}
|
||||
selected={provisions}
|
||||
disabled={tank.provisions === provisions.length}
|
||||
onChange={(provisions) => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.provisions = provisions;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export const permanentSkills = [
|
||||
'smooth_turn',
|
||||
'soft_recoil',
|
||||
'smooth_driving',
|
||||
'smooth_turret',
|
||||
'virtuoso',
|
||||
'camouflage',
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Kbd,
|
||||
Popover,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { CrewSkillManager } from '../../../../../../../../components/CrewSkillManager';
|
||||
import { createDefaultSkills } from '../../../../../../../../core/blitzkrieg/createDefaultSkills';
|
||||
import { skillDefinitions } from '../../../../../../../../core/blitzkrieg/skillDefinitions';
|
||||
import {
|
||||
mutateTankopediaTemporary,
|
||||
useTankopediaTemporary,
|
||||
} from '../../../../../../../../stores/tankopedia';
|
||||
import { ConfigurationChildWrapper } from '../ConfigurationChildWrapper';
|
||||
|
||||
export function Skills() {
|
||||
const awaitedSkillDefinitions = use(skillDefinitions);
|
||||
const skillLevels = useTankopediaTemporary((state) => state.skills);
|
||||
|
||||
if (Object.keys(skillLevels).length === 0) {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.skills = createDefaultSkills(awaitedSkillDefinitions);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigurationChildWrapper>
|
||||
<Flex gap="4" align="center">
|
||||
<Flex gap="2" align="center">
|
||||
<Heading size="4">Crew skills</Heading>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Flex direction="column" gap="2">
|
||||
<Text>
|
||||
<Text color="amber">Yellow skills</Text> only apply under
|
||||
special circumstances.
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Hold <Kbd>Shift</Kbd> to quickly toggle between level 0 and 7.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
Object.keys(draft.skills).forEach((skill) => {
|
||||
draft.skills[skill] = 0;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
Object.keys(draft.skills).forEach((skill) => {
|
||||
draft.skills[skill] = 7;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Maximize
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<CrewSkillManager
|
||||
skillLevels={skillLevels}
|
||||
onChange={(skills) => {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.skills = skills;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigurationChildWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,66 @@
|
||||
import { AccessibilityIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes';
|
||||
import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Popover,
|
||||
Slider,
|
||||
Text,
|
||||
TextField,
|
||||
} from '@radix-ui/themes';
|
||||
import { debounce } from 'lodash';
|
||||
import { use, useEffect, useRef, useState } from 'react';
|
||||
import { lerp } from 'three/src/math/MathUtils';
|
||||
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
|
||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
||||
import { coefficient } from '../../../../../../core/blitzkrieg/coefficient';
|
||||
import { degressiveStat } from '../../../../../../core/blitzkrieg/degressiveStat';
|
||||
import { equipmentDefinitions } from '../../../../../../core/blitzkrieg/equipmentDefinitions';
|
||||
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||
import { normalizeBoundingBox } from '../../../../../../core/blitzkrieg/normalizeBoundingBox';
|
||||
import { progressiveStat } from '../../../../../../core/blitzkrieg/progressiveStat';
|
||||
import { resolveDpm } from '../../../../../../core/blitzkrieg/resolveDpm';
|
||||
import { sum } from '../../../../../../core/blitzkrieg/sum';
|
||||
import { provisionDefinitions } from '../../../../../../core/blitzkrieg/provisionDefinitions';
|
||||
import { tankCharacteristics } from '../../../../../../core/blitzkrieg/tankCharacteristics';
|
||||
import {
|
||||
CREW_MEMBER_NAMES,
|
||||
GUN_TYPE_NAMES,
|
||||
SHELL_NAMES,
|
||||
} from '../../../../../../core/blitzkrieg/tankDefinitions/constants';
|
||||
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
|
||||
import { useConsumable } from '../../../../../../hooks/useConsumable';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { useProvision } from '../../../../../../hooks/useProvision';
|
||||
import { useProvisions } from '../../../../../../hooks/useProvisions';
|
||||
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
|
||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
|
||||
import { Info } from './components/Info';
|
||||
import { InfoWithDelta } from './components/InfoWithDelta';
|
||||
|
||||
export function Characteristics() {
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const { tank, turret, gun, engine, track, shell } = useDuel(
|
||||
(state) => state.protagonist!,
|
||||
);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const crewSkills = useTankopediaTemporary((state) => state.skills);
|
||||
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
|
||||
const isFullScreen = useFullScreen();
|
||||
const hasImprovedVentilation = useEquipment(102);
|
||||
const provisions = useProvisions();
|
||||
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
|
||||
const [penetrationDistance, setPenetrationDistance] = useState(250);
|
||||
|
||||
const provisions = useDuel((state) => state.protagonist!.provisions);
|
||||
const provisionCrewBonus =
|
||||
provisions.reduce(
|
||||
(total, provision) =>
|
||||
provision.crew ? total + provision.crew / 100 : total,
|
||||
awaitedProvisionDefinitions[provision].crew
|
||||
? total + awaitedProvisionDefinitions[provision].crew! / 100
|
||||
: total,
|
||||
0,
|
||||
) + (hasImprovedVentilation ? 0.05 : 0);
|
||||
const commanderMastery = crewMastery + provisionCrewBonus;
|
||||
const crewMemberMastery = commanderMastery * 1.1;
|
||||
const consumables = useDuel((state) => state.protagonist!.consumables);
|
||||
const camouflage = useDuel((state) => state.protagonist!.camouflage);
|
||||
const equipmentMatrix = useDuel(
|
||||
(state) => state.protagonist!.equipmentMatrix,
|
||||
);
|
||||
const { tank, turret, gun, engine, track, shell } = useDuel(
|
||||
(state) => state.protagonist!,
|
||||
);
|
||||
const stockEngine = tank.engines[0];
|
||||
const stockTrack = tank.tracks[0];
|
||||
const stockTurret = tank.turrets[0];
|
||||
@@ -51,169 +68,46 @@ export function Characteristics() {
|
||||
const tankModelDefinition = awaitedModelDefinitions[tank.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
const size = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.boundingBox,
|
||||
turretModelDefinition.boundingBox,
|
||||
),
|
||||
const stats = tankCharacteristics(
|
||||
{
|
||||
tank,
|
||||
camouflage,
|
||||
consumables,
|
||||
crewMastery,
|
||||
crewSkills,
|
||||
engine,
|
||||
equipmentMatrix,
|
||||
gun,
|
||||
provisions,
|
||||
shell,
|
||||
turret,
|
||||
track,
|
||||
stockEngine,
|
||||
stockGun,
|
||||
stockTrack,
|
||||
stockTurret,
|
||||
applyReactive: false,
|
||||
applyDynamicArmor: false,
|
||||
applySpallLiner: false,
|
||||
},
|
||||
{
|
||||
equipmentDefinitions: awaitedEquipmentDefinitions,
|
||||
modelDefinitions: awaitedModelDefinitions,
|
||||
provisionDefinitions: awaitedProvisionDefinitions,
|
||||
},
|
||||
);
|
||||
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
|
||||
const weight =
|
||||
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
|
||||
const weightTons = weight / 1000;
|
||||
const stockWeight =
|
||||
tank.weight +
|
||||
stockEngine.weight +
|
||||
stockTrack.weight +
|
||||
stockTurret.weight +
|
||||
stockGun.weight;
|
||||
const [penetrationDistance, setPenetrationDistance] = useState(250);
|
||||
const camouflage = useDuel((state) => state.protagonist!.camouflage);
|
||||
const hasRammer = useEquipment(100);
|
||||
const hasCalibratedShells = useEquipment(103);
|
||||
const hasEnhancedGunLayingDrive = useEquipment(104);
|
||||
const hasSupercharger = useEquipment(107);
|
||||
const hasVerticalStabilizer = useEquipment(105);
|
||||
const hasRefinedGun = useEquipment(106);
|
||||
const hasImprovedAssembly = useEquipment(111);
|
||||
const hasImprovedOptics = useEquipment(114);
|
||||
const hasImprovedControl = useEquipment(116);
|
||||
const hasEngineAccelerator = useEquipment(117);
|
||||
const hasCamouflageNet = useEquipment(115);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const hasImprovedSuspension = useEquipment(123);
|
||||
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
|
||||
const hasEnginePowerBoost = useConsumable(14);
|
||||
const hasImprovedEnginePowerBoost = useConsumable(27);
|
||||
const hasAdrenaline = useConsumable(18);
|
||||
const hasTungsten = useConsumable(45);
|
||||
const hasReticleCalibration = useConsumable(28);
|
||||
const hasShellReloadBoost = useConsumable(29);
|
||||
const hasStandardFuel = useProvision(18);
|
||||
const hasImprovedFuel = useProvision(19);
|
||||
const hasProtectiveKit = useProvision(22);
|
||||
const hasSandbagArmor = useProvision(28);
|
||||
const hasEnhancedSandbagArmor = useProvision(29);
|
||||
const hasImprovedGunPowder = useProvision(46);
|
||||
const hasGearOil = useProvision(44);
|
||||
const hasImprovedGearOil = useProvision(45);
|
||||
|
||||
const healthCoefficient = coefficient(
|
||||
[hasSandbagArmor, 0.03],
|
||||
[hasEnhancedSandbagArmor, 0.06],
|
||||
[hasImprovedAssembly, 0.06],
|
||||
);
|
||||
const reloadCoefficient =
|
||||
(coefficient([hasRammer, -0.07]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)])) /
|
||||
coefficient([hasAdrenaline && gun.type === 'regular', 0.2]);
|
||||
const hasSupercharger = useEquipment(107);
|
||||
const hasCalibratedShells = useEquipment(103);
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const penetrationCoefficient = coefficient([
|
||||
hasCalibratedShells,
|
||||
isExplosive(shell.type) ? 0.1 : 0.05,
|
||||
]);
|
||||
const damageCoefficient = coefficient(
|
||||
// TODO: add max rolls
|
||||
[hasTungsten, 0.15],
|
||||
);
|
||||
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
|
||||
const shellVelocityCoefficient = coefficient(
|
||||
[hasSupercharger, 0.3],
|
||||
[hasImprovedGunPowder, 0.3],
|
||||
);
|
||||
const penetrationLossOverDistanceCoefficient = coefficient([
|
||||
hasSupercharger,
|
||||
-0.5,
|
||||
]);
|
||||
const aimTimeCoefficient =
|
||||
coefficient(
|
||||
[hasEnhancedGunLayingDrive, -0.1],
|
||||
[hasReticleCalibration, -0.4],
|
||||
) * coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
const dispersionStillCoefficient =
|
||||
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
const dispersionMovingCoefficient = coefficient([
|
||||
hasVerticalStabilizer,
|
||||
-0.15,
|
||||
]);
|
||||
const enginePowerCoefficient = coefficient(
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'lightTank' || tank.class === 'mediumTank'),
|
||||
0.05,
|
||||
],
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'heavyTank' || tank.class === 'AT-SPG'),
|
||||
0.07,
|
||||
],
|
||||
[hasEnginePowerBoost, 0.2],
|
||||
[hasImprovedEnginePowerBoost, 0.4],
|
||||
[hasStandardFuel, 0.03],
|
||||
[hasImprovedFuel, 0.1],
|
||||
[hasGearOil, 0.03],
|
||||
[hasImprovedGearOil, 0.06],
|
||||
);
|
||||
const turretTraverseCoefficient =
|
||||
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
|
||||
coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
const hullTraverseCoefficient =
|
||||
coefficient(
|
||||
[hasImprovedControl, 0.1],
|
||||
[hasImprovedEnginePowerBoost, 0.05],
|
||||
) * coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
|
||||
const viewRangeCoefficient =
|
||||
coefficient([
|
||||
hasImprovedOptics,
|
||||
tank.class === 'AT-SPG' ? 0.05 : tank.class === 'heavyTank' ? 0.07 : 0.1,
|
||||
]) * coefficient([true, progressiveStat(commanderMastery)]);
|
||||
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
|
||||
|
||||
const speedForwardsSum = sum(
|
||||
[hasImprovedEnginePowerBoost, 5],
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const speedBackwardsSum = sum(
|
||||
[hasImprovedEnginePowerBoost, 10],
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const camouflageSumMoving = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
tank.class === 'heavyTank' ? 0.03 : tank.class === 'AT-SPG' ? 0.07 : 0.05,
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
const camouflageSumStill = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
2 *
|
||||
(tank.class === 'heavyTank'
|
||||
? 0.03
|
||||
: tank.class === 'AT-SPG'
|
||||
? 0.07
|
||||
: 0.05),
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
|
||||
const resolvedEnginePower = engine.power * enginePowerCoefficient;
|
||||
const dpm = resolveDpm(
|
||||
gun,
|
||||
shell,
|
||||
damageCoefficient,
|
||||
reloadCoefficient,
|
||||
intraClipCoefficient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (penetrationDistanceInput.current) {
|
||||
@@ -232,6 +126,8 @@ export function Characteristics() {
|
||||
});
|
||||
}, [hasImprovedVerticalStabilizer]);
|
||||
|
||||
if (isFullScreen) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="8" style={{ width: '100%' }}>
|
||||
<Flex direction="column" gap="2">
|
||||
@@ -262,19 +158,12 @@ export function Characteristics() {
|
||||
|
||||
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
|
||||
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
|
||||
{dpm}
|
||||
{stats.dpm}
|
||||
</InfoWithDelta>
|
||||
{gun.type === 'autoReloader' && (
|
||||
<>
|
||||
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload.at(-1)! * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
60
|
||||
: ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload[0] * reloadCoefficient)) *
|
||||
60}
|
||||
{stats.dpmMaximum!}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={0}
|
||||
@@ -282,27 +171,7 @@ export function Characteristics() {
|
||||
name="Effective at 60s"
|
||||
unit="hp / min"
|
||||
>
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload.at(-1)! * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
(60 -
|
||||
(gun.reload.slice(0, -1).length - 1) *
|
||||
gun.intraClip *
|
||||
intraClipCoefficient) +
|
||||
damageCoefficient *
|
||||
shell.damage.armor *
|
||||
gun.reload.slice(0, -1).length
|
||||
: ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload[0] * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
(60 -
|
||||
(gun.reload.slice(1).length - 1) *
|
||||
gun.intraClip *
|
||||
intraClipCoefficient) +
|
||||
damageCoefficient *
|
||||
shell.damage.armor *
|
||||
gun.reload.slice(1).length}
|
||||
{stats.dpmEffective!}
|
||||
</InfoWithDelta>
|
||||
<Info
|
||||
indent
|
||||
@@ -317,14 +186,17 @@ export function Characteristics() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{gun.type !== 'regular' && (
|
||||
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
|
||||
)}
|
||||
|
||||
{gun.type === 'autoReloader' ? (
|
||||
<>
|
||||
<InfoWithDelta name="Shells">{gun.reload.length}</InfoWithDelta>
|
||||
<Info indent name="Most optimal">
|
||||
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
|
||||
{stats.mostOptimalShellIndex}
|
||||
</Info>
|
||||
<Info name="Shell reloads" unit="s" />
|
||||
{gun.reload.map((reload, index) => (
|
||||
{stats.shellReloads!.map((reload, index) => (
|
||||
<InfoWithDelta
|
||||
key={index}
|
||||
indent
|
||||
@@ -332,7 +204,7 @@ export function Characteristics() {
|
||||
decimals={2}
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{reload * reloadCoefficient}
|
||||
{reload}
|
||||
</InfoWithDelta>
|
||||
))}
|
||||
</>
|
||||
@@ -343,7 +215,7 @@ export function Characteristics() {
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.reload * reloadCoefficient}
|
||||
{stats.shellReload!}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
{gun.type === 'autoLoader' ||
|
||||
@@ -355,14 +227,14 @@ export function Characteristics() {
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.intraClip * intraClipCoefficient}
|
||||
{stats.intraClip!}
|
||||
</InfoWithDelta>
|
||||
))}
|
||||
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
|
||||
{shell.caliber}
|
||||
{stats.caliber}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
|
||||
{resolveNearPenetration(shell.penetration) * penetrationCoefficient}
|
||||
{stats.penetration}
|
||||
</InfoWithDelta>
|
||||
{typeof shell.penetration !== 'number' && (
|
||||
<>
|
||||
@@ -390,42 +262,42 @@ export function Characteristics() {
|
||||
setPenetrationDistance(value);
|
||||
}, 500)}
|
||||
/>
|
||||
<TextField.Root style={{ width: 64 }}>
|
||||
<TextField.Input
|
||||
ref={penetrationDistanceInput}
|
||||
defaultValue={penetrationDistance}
|
||||
onBlur={() => {
|
||||
setPenetrationDistance(
|
||||
Math.min(
|
||||
parseInt(penetrationDistanceInput.current!.value),
|
||||
500,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>m</TextField.Slot>
|
||||
<TextField.Root
|
||||
style={{ width: 64 }}
|
||||
ref={penetrationDistanceInput}
|
||||
defaultValue={penetrationDistance}
|
||||
onBlur={() => {
|
||||
setPenetrationDistance(
|
||||
Math.min(
|
||||
parseInt(penetrationDistanceInput.current!.value),
|
||||
500,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextField.Slot side="right">m</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
|
||||
{shell.damage.armor * damageCoefficient}
|
||||
{stats.damage}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
|
||||
{shell.damage.module * damageCoefficient}
|
||||
{stats.moduleDamage}
|
||||
</InfoWithDelta>
|
||||
{isExplosive(shell.type) && (
|
||||
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
|
||||
{shell.explosionRadius!}
|
||||
{stats.explosionRadius!}
|
||||
</InfoWithDelta>
|
||||
)}
|
||||
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
|
||||
{shell.speed * shellVelocityCoefficient}
|
||||
{stats.shellVelocity}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={2}
|
||||
@@ -433,7 +305,7 @@ export function Characteristics() {
|
||||
unit="s"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.aimTime * aimTimeCoefficient}
|
||||
{stats.aimTime}
|
||||
</InfoWithDelta>
|
||||
<Info name="Dispersion at 100m" />
|
||||
<InfoWithDelta
|
||||
@@ -443,7 +315,7 @@ export function Characteristics() {
|
||||
unit="m"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.base * dispersionStillCoefficient}
|
||||
{stats.dispersion}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
prefix="+ "
|
||||
@@ -452,7 +324,7 @@ export function Characteristics() {
|
||||
name="Moving"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{track.dispersion.move * dispersionMovingCoefficient}
|
||||
{stats.dispersionMoving}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
@@ -461,7 +333,7 @@ export function Characteristics() {
|
||||
name="Hull traversing"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{track.dispersion.traverse * dispersionMovingCoefficient}
|
||||
{stats.dispersionHullTraversing}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
@@ -470,7 +342,7 @@ export function Characteristics() {
|
||||
name="Turret traversing"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.traverse * dispersionMovingCoefficient}
|
||||
{stats.dispersionTurretTraversing}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={3}
|
||||
@@ -479,7 +351,7 @@ export function Characteristics() {
|
||||
name="After shooting"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.shot * dispersionMovingCoefficient}
|
||||
{stats.dispersionShooting}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={1}
|
||||
@@ -489,46 +361,42 @@ export function Characteristics() {
|
||||
unit="scalar"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{gun.dispersion.damaged * dispersionMovingCoefficient}
|
||||
{stats.dispersionGunDamaged}
|
||||
</InfoWithDelta>
|
||||
<Info name="Gun flexibility" unit="°" />
|
||||
<InfoWithDelta decimals={1} indent name="Depression">
|
||||
{gunModelDefinition.pitch.max +
|
||||
gunDefaultPitch +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
||||
{stats.gunDepression}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Elevation">
|
||||
{-gunModelDefinition.pitch.min -
|
||||
gunDefaultPitch +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
||||
{stats.gunElevation}
|
||||
</InfoWithDelta>
|
||||
{gunModelDefinition.pitch.front && (
|
||||
<>
|
||||
<InfoWithDelta decimals={1} indent name="Frontal depression">
|
||||
{gunModelDefinition.pitch.front.max}
|
||||
{stats.gunFrontalDepression!}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Frontal elevation">
|
||||
{-gunModelDefinition.pitch.front.min}
|
||||
{stats.gunFrontalElevation!}
|
||||
</InfoWithDelta>
|
||||
</>
|
||||
)}
|
||||
{gunModelDefinition.pitch.back && (
|
||||
<>
|
||||
<InfoWithDelta decimals={1} indent name="Rear depression">
|
||||
{gunModelDefinition.pitch.back.max}
|
||||
{stats.gunRearDepression!}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Rear elevation">
|
||||
{-gunModelDefinition.pitch.back.min}
|
||||
{stats.gunRearElevation!}
|
||||
</InfoWithDelta>
|
||||
</>
|
||||
)}
|
||||
{turretModelDefinition.yaw && (
|
||||
<>
|
||||
<InfoWithDelta decimals={1} indent name="Azimuth left">
|
||||
{-turretModelDefinition.yaw.min}
|
||||
{stats.azimuthLeft!}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="Azimuth right">
|
||||
{turretModelDefinition.yaw.max}
|
||||
{stats.azimuthRight!}
|
||||
</InfoWithDelta>
|
||||
</>
|
||||
)}
|
||||
@@ -538,29 +406,13 @@ export function Characteristics() {
|
||||
<Heading size="5">Maneuverability</Heading>
|
||||
<Info name="Speed" unit="km/hr" />
|
||||
<InfoWithDelta decimals={0} indent name="Forwards">
|
||||
{tank.speed.forwards + speedForwardsSum}
|
||||
{stats.speedForwards}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={0} indent name="Backwards">
|
||||
{tank.speed.backwards + speedBackwardsSum}
|
||||
{stats.speedBackwards}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={0} name="Power" unit="hp">
|
||||
{resolvedEnginePower}
|
||||
</InfoWithDelta>
|
||||
<Info name="Power to weight ratio" unit="hp/tn" />
|
||||
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
||||
{resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.hard * resistanceCoefficient)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
||||
{resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.medium * resistanceCoefficient)}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
||||
{resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.soft * resistanceCoefficient)}
|
||||
<InfoWithDelta decimals={0} name="Engine power" unit="hp">
|
||||
{stats.enginePower}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta
|
||||
decimals={1}
|
||||
@@ -568,76 +420,98 @@ export function Characteristics() {
|
||||
unit="tn"
|
||||
deltaType="lowerIsBetter"
|
||||
>
|
||||
{weightTons}
|
||||
{stats.weight}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={0}>
|
||||
{turret.traverseSpeed * turretTraverseCoefficient}
|
||||
</InfoWithDelta>
|
||||
<Info name="Effective traverse speed" unit="°/s" />
|
||||
<Info name="Power to weight ratio" unit="hp/tn" />
|
||||
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
||||
{(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.hard * resistanceCoefficient)) *
|
||||
(stockWeight / weight)}
|
||||
{stats.powerToWeightRatioHardTerrain}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
||||
{(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.medium * resistanceCoefficient)) *
|
||||
(stockWeight / weight)}
|
||||
{stats.powerToWeightRatioMediumTerrain}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
||||
{(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.soft * resistanceCoefficient)) *
|
||||
(stockWeight / weight)}
|
||||
{stats.powerToWeightRatioSoftTerrain}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
|
||||
{stats.turretTraverseSpeed}
|
||||
</InfoWithDelta>
|
||||
<Info name="Hull traverse speed" unit="°/s" />
|
||||
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
||||
{stats.hullTraverseHardTerrain}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
||||
{stats.hullTraverseMediumTerrain}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
||||
{stats.hullTraverseSoftTerrain}
|
||||
</InfoWithDelta>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="5">Survivability</Heading>
|
||||
<InfoWithDelta name="Health" unit="hp" decimals={0}>
|
||||
{(tank.health + turret.health) * healthCoefficient}
|
||||
{stats.health}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="Fire chance" unit="%" deltaType="lowerIsBetter">
|
||||
{Math.round(engine.fireChance * fireChanceCoefficient * 100)}
|
||||
<InfoWithDelta
|
||||
name="Fire chance"
|
||||
unit="%"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={0}
|
||||
>
|
||||
{stats.fireChance * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta name="View range" unit="m" decimals={0}>
|
||||
{turret.viewRange * viewRangeCoefficient}
|
||||
{stats.viewRange}
|
||||
</InfoWithDelta>
|
||||
<Info name="Camouflage" unit="%" />
|
||||
<InfoWithDelta indent name="Still" decimals={2}>
|
||||
{(tank.camouflage.still + camouflageSumStill) * 100}
|
||||
<InfoWithDelta indent name="Still" decimals={0}>
|
||||
{stats.camouflageStill * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Moving" decimals={2}>
|
||||
{(tank.camouflage.moving + camouflageSumMoving) * 100}
|
||||
<InfoWithDelta indent name="Moving" decimals={0}>
|
||||
{stats.camouflageMoving * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Shooting still" decimals={2}>
|
||||
{(tank.camouflage.still * gun.camouflageLoss + camouflageSumStill) *
|
||||
100}
|
||||
<InfoWithDelta indent name="Shooting still" decimals={0}>
|
||||
{stats.camouflageShootingStill * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Shooting on move" decimals={2}>
|
||||
{(tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving) *
|
||||
100}
|
||||
<InfoWithDelta indent name="Shooting moving" decimals={0}>
|
||||
{stats.camouflageShootingMoving * 100}
|
||||
</InfoWithDelta>
|
||||
<InfoWithDelta indent name="Caught on fire" decimals={2}>
|
||||
{(tank.camouflage.onFire * tank.camouflage.still +
|
||||
camouflageSumStill) *
|
||||
100}
|
||||
<InfoWithDelta indent name="Caught on fire" decimals={0}>
|
||||
{stats.camouflageCaughtOnFire * 100}
|
||||
</InfoWithDelta>
|
||||
<Info name="Size" unit="m">
|
||||
{size[0].toFixed(2)} x {size[2].toFixed(2)} x {size[1].toFixed(2)}
|
||||
<Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
|
||||
{stats.width}
|
||||
</Info>
|
||||
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
|
||||
{stats.height}
|
||||
</Info>
|
||||
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
|
||||
{stats.length}
|
||||
</Info>
|
||||
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
|
||||
{stats.volume}
|
||||
</Info>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="5">Crew training</Heading>
|
||||
<Flex gap="2" align="center">
|
||||
<Heading size="5">Crew training</Heading>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<InfoCircledIcon />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<Flex gap="1" align="center">
|
||||
<AccessibilityIcon />
|
||||
<Text>represents the other roles a crew member can take.</Text>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
{tank.crew.map((member) => {
|
||||
const count = member.count ?? 1;
|
||||
|
||||
@@ -69,7 +69,12 @@ export function Controls() {
|
||||
);
|
||||
|
||||
async function handlePoseEvent(event: Pose) {
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(122);
|
||||
const duel = useDuel.getState();
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(
|
||||
122,
|
||||
duel.protagonist!.tank.equipment,
|
||||
duel.protagonist!.equipmentMatrix,
|
||||
);
|
||||
|
||||
switch (event) {
|
||||
case Pose.HullDown: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LightningBoltIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Card, Flex, Tabs, Theme } from '@radix-ui/themes';
|
||||
import { Button, Card, Flex, Tabs, Text, Theme } from '@radix-ui/themes';
|
||||
import { PerspectiveCamera } from '@react-three/drei';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
||||
@@ -12,7 +12,9 @@ import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTran
|
||||
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
|
||||
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
|
||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
|
||||
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
|
||||
import { BlitzkriegWormWide } from '../../../../../../icons/BlitzkriegWormWide';
|
||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||
import mutateTankopediaPersistent, {
|
||||
TankopediaMode,
|
||||
@@ -33,7 +35,6 @@ export function TankSandbox() {
|
||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const canvasWrapper = useRef<HTMLDivElement>(null);
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const mode = useTankopediaPersistent((state) => state.mode);
|
||||
const protagonist = useDuel((state) => state.protagonist!);
|
||||
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
||||
@@ -43,6 +44,7 @@ export function TankSandbox() {
|
||||
const wideFormat = useWideFormat();
|
||||
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
|
||||
const duel = useDuel();
|
||||
const isFullScreen = useFullScreen();
|
||||
|
||||
function handlePointerDown() {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
@@ -59,10 +61,6 @@ export function TankSandbox() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleFullScreenChange() {
|
||||
setIsFullScreen(document.fullscreenElement !== null);
|
||||
}
|
||||
|
||||
function handlePoseEvent(pose: Pose) {
|
||||
switch (pose) {
|
||||
case Pose.HullDown: {
|
||||
@@ -121,10 +119,8 @@ export function TankSandbox() {
|
||||
}
|
||||
|
||||
poseEvent.on(handlePoseEvent);
|
||||
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
||||
poseEvent.off(handlePoseEvent);
|
||||
};
|
||||
});
|
||||
@@ -145,117 +141,127 @@ export function TankSandbox() {
|
||||
<Theme radius={isFullScreen ? 'none' : undefined}>
|
||||
<Card
|
||||
style={{
|
||||
position: 'relative',
|
||||
position: isFullScreen ? 'fixed' : 'relative',
|
||||
border: isFullScreen ? 'none' : 'unset',
|
||||
width: isFullScreen ? '100vw' : 'unset',
|
||||
height: isFullScreen ? 'calc(100vh)' : 'unset',
|
||||
top: isFullScreen ? 0 : 'unset',
|
||||
left: isFullScreen ? 0 : 'unset',
|
||||
zIndex: isFullScreen ? 2 : 'unset',
|
||||
}}
|
||||
ref={canvasWrapper}
|
||||
>
|
||||
<Theme radius="full" style={{ height: '100%' }}>
|
||||
<Flex
|
||||
style={{
|
||||
height: isFullScreen ? '100%' : '75vh',
|
||||
maxHeight: isFullScreen ? 'unset' : 576,
|
||||
<Flex
|
||||
style={{
|
||||
height: isFullScreen ? '100%' : '75vh',
|
||||
maxHeight: isFullScreen ? 'unset' : 576,
|
||||
}}
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Tabs.Root
|
||||
value={mode}
|
||||
onValueChange={(mode) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.mode = mode as TankopediaMode;
|
||||
});
|
||||
}}
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Tabs.Root
|
||||
value={mode}
|
||||
onValueChange={(mode) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.mode = mode as TankopediaMode;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="model">
|
||||
<Flex gap="2" align="center">
|
||||
Model
|
||||
</Flex>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100% - 64px)',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{loadModel ? (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<Canvas
|
||||
shadows
|
||||
ref={canvas}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMissed={() => {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PerspectiveCamera makeDefault fov={25} far={32} />
|
||||
<Controls />
|
||||
<SceneProps />
|
||||
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
{/* TODO: add rotation cube to the top right */}
|
||||
<Lighting />
|
||||
<TankModel />
|
||||
<Armor />
|
||||
<ShotDisplay />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ height: '100%', position: 'relative' }}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
setLoadModel(true);
|
||||
}}
|
||||
>
|
||||
<LightningBoltIcon /> Load model
|
||||
</Button>
|
||||
|
||||
<Flex
|
||||
style={{
|
||||
filter: 'blur(16px)',
|
||||
width: 256,
|
||||
height: 128,
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
transform: 'translate(25%, -25%) scale(300%)',
|
||||
}}
|
||||
>
|
||||
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
|
||||
</Flex>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="model">
|
||||
<Flex gap="2" align="center">
|
||||
Model
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
<Options
|
||||
canvasWrapper={canvasWrapper}
|
||||
isFullScreen={isFullScreen}
|
||||
/>
|
||||
<Text
|
||||
color="gray"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: 11,
|
||||
}}
|
||||
>
|
||||
<BlitzkriegWormWide height={32} />
|
||||
</Text>
|
||||
|
||||
{isFullScreen && <AntagonistBar floating />}
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100% - 64px)',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{loadModel ? (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<Canvas
|
||||
shadows
|
||||
ref={canvas}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMissed={() => {
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PerspectiveCamera makeDefault fov={25} far={32} />
|
||||
<Controls />
|
||||
<SceneProps />
|
||||
|
||||
{loadModel && <RotationInputs isFullScreen={isFullScreen} />}
|
||||
</Flex>
|
||||
</Theme>
|
||||
<Suspense fallback={<ModelLoader />}>
|
||||
<Lighting />
|
||||
<TankModel />
|
||||
<Armor />
|
||||
<ShotDisplay />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ height: '100%', position: 'relative' }}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
setLoadModel(true);
|
||||
}}
|
||||
>
|
||||
<LightningBoltIcon /> Load model
|
||||
</Button>
|
||||
|
||||
<Flex
|
||||
style={{
|
||||
filter: 'blur(16px)',
|
||||
width: 256,
|
||||
height: 128,
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
transform: 'translate(25%, -25%) scale(300%)',
|
||||
}}
|
||||
>
|
||||
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Options canvasWrapper={canvasWrapper} isFullScreen={isFullScreen} />
|
||||
|
||||
{isFullScreen && <AntagonistBar floating />}
|
||||
|
||||
{loadModel && <RotationInputs isFullScreen={isFullScreen} />}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Theme>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Html, useProgress } from '@react-three/drei';
|
||||
import { theme } from '../../../../../../../stitches.config';
|
||||
import { Loader } from '../../../../../components/Loader';
|
||||
|
||||
export function ModelLoader() {
|
||||
@@ -7,11 +6,7 @@ export function ModelLoader() {
|
||||
|
||||
return (
|
||||
<Html center position={[0, 1.5, 0]}>
|
||||
<Loader
|
||||
naked
|
||||
color={theme.colors.textHighContrast_purple}
|
||||
progress={progress / 100}
|
||||
/>
|
||||
<Loader naked progress={progress / 100} />
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface OptionsProps {
|
||||
canvasWrapper: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
export function Options({ isFullScreen }: OptionsProps) {
|
||||
const showGrid = useTankopediaPersistent(
|
||||
(state) => state.model.visual.showGrid,
|
||||
);
|
||||
@@ -50,136 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
right: 18,
|
||||
}}
|
||||
>
|
||||
{!isFullScreen && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost">
|
||||
<EyeOpenIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost">
|
||||
<EyeOpenIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
|
||||
Hull down
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
|
||||
Hull down
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
|
||||
Face hug
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
|
||||
Face hug
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
|
||||
Default
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
|
||||
Default
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
{!isFullScreen && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost">
|
||||
<GearIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost">
|
||||
<GearIcon />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showSpacedArmor}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showSpacedArmor = checked;
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Spaced armor
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showSpacedArmor}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showSpacedArmor = checked;
|
||||
});
|
||||
mutateTankopediaTemporary((draft) => {
|
||||
draft.shot = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Spaced armor
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={greenPenetration}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.greenPenetration = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Green penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={greenPenetration}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.greenPenetration = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Green penetration
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={wireframe}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.wireframe = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Wireframe
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={opaque}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.opaque = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Opaque
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={opaque}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.opaque = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Opaque
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showGrid = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show grid
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showGrid}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showGrid = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show grid
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showEnvironment}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showEnvironment = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
View environment
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={showEnvironment}
|
||||
onCheckedChange={(checked) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.showEnvironment = checked;
|
||||
});
|
||||
}}
|
||||
>
|
||||
View environment
|
||||
</DropdownMenu.CheckboxItem>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.RadioGroup value={environment}>
|
||||
{ENVIRONMENTS.map((environment) => (
|
||||
<DropdownMenu.RadioItem
|
||||
key={environment}
|
||||
value={environment}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.environment = environment;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{environment[0].toUpperCase() + environment.slice(1)}
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.RadioGroup value={environment}>
|
||||
{ENVIRONMENTS.map((environment) => (
|
||||
<DropdownMenu.RadioItem
|
||||
key={environment}
|
||||
value={environment}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.model.visual.environment = environment;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{environment[0].toUpperCase() + environment.slice(1)}
|
||||
</DropdownMenu.RadioItem>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
{fullScreenAvailable && (
|
||||
<Button
|
||||
@@ -187,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
||||
onClick={() => {
|
||||
if (isFullScreen) {
|
||||
document.exitFullscreen();
|
||||
} else canvasWrapper.current?.requestFullscreen();
|
||||
} else document.body.requestFullscreen();
|
||||
}}
|
||||
>
|
||||
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||
|
||||
@@ -162,7 +162,12 @@ export const TankModel = memo(() => {
|
||||
}
|
||||
}
|
||||
async function handlePointerMove(event: PointerEvent) {
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(122);
|
||||
const duel = useDuel.getState();
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(
|
||||
122,
|
||||
duel.protagonist!.tank.equipment,
|
||||
duel.protagonist!.equipmentMatrix,
|
||||
);
|
||||
[pitch, yaw] = applyPitchYawLimits(
|
||||
pitch,
|
||||
yaw + event.movementX * (Math.PI / canvas.width),
|
||||
@@ -223,7 +228,12 @@ export const TankModel = memo(() => {
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
async function handlePointerMove(event: PointerEvent) {
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(122);
|
||||
const duel = useDuel.getState();
|
||||
const hasImprovedVerticalStabilizer = await hasEquipment(
|
||||
122,
|
||||
duel.protagonist!.tank.equipment,
|
||||
duel.protagonist!.equipmentMatrix,
|
||||
);
|
||||
[pitch, yaw] = applyPitchYawLimits(
|
||||
pitch - event.movementY * (Math.PI / canvas.height),
|
||||
yaw + event.movementX * (Math.PI / canvas.width),
|
||||
|
||||
@@ -50,11 +50,44 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
}}
|
||||
gap="4"
|
||||
>
|
||||
<Flex gap="2" style={{ width: 256, pointerEvents: 'none' }}>
|
||||
<Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
|
||||
<TextField.Root
|
||||
radius="full"
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(turretYawInput.current!.value);
|
||||
if (isNaN(value)) {
|
||||
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
|
||||
1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
protagonist.pitch,
|
||||
degToRad(value),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist!.pitch = pitch;
|
||||
state.protagonist!.yaw = yaw;
|
||||
});
|
||||
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
|
||||
1,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
turretYawInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => turretYawInput.current?.focus()}
|
||||
ref={turretYawInput}
|
||||
>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
@@ -63,40 +96,46 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
>
|
||||
Yaw
|
||||
</TextField.Slot>
|
||||
<TextField.Input
|
||||
onBlur={() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
protagonist.pitch,
|
||||
degToRad(Number(turretYawInput.current!.value)),
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist!.pitch = pitch;
|
||||
state.protagonist!.yaw = yaw;
|
||||
});
|
||||
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
|
||||
1,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
turretYawInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => turretYawInput.current?.focus()}
|
||||
ref={turretYawInput}
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
<TextField.Root
|
||||
radius="full"
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onBlur={() => {
|
||||
const value = Number(gunPitchInput.current!.value);
|
||||
if (isNaN(value)) {
|
||||
gunPitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
return;
|
||||
}
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
degToRad(-value + initialGunPitch),
|
||||
protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist!.pitch = pitch;
|
||||
state.protagonist!.yaw = yaw;
|
||||
});
|
||||
gunPitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
gunPitchInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => gunPitchInput.current?.focus()}
|
||||
ref={gunPitchInput}
|
||||
>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
@@ -105,35 +144,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
>
|
||||
Pitch
|
||||
</TextField.Slot>
|
||||
<TextField.Input
|
||||
onBlur={() => {
|
||||
const [pitch, yaw] = applyPitchYawLimits(
|
||||
degToRad(
|
||||
-Number(gunPitchInput.current!.value) + initialGunPitch,
|
||||
),
|
||||
protagonist.yaw,
|
||||
gunModelDefinition.pitch,
|
||||
turretModelDefinition.yaw,
|
||||
hasImprovedVerticalStabilizer,
|
||||
);
|
||||
modelTransformEvent.emit({ pitch, yaw });
|
||||
mutateDuel((state) => {
|
||||
state.protagonist!.pitch = pitch;
|
||||
state.protagonist!.yaw = yaw;
|
||||
});
|
||||
gunPitchInput.current!.value = (
|
||||
-radToDeg(protagonist.pitch) + initialGunPitch
|
||||
).toFixed(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
gunPitchInput.current?.blur();
|
||||
}
|
||||
}}
|
||||
onFocus={() => gunPitchInput.current?.focus()}
|
||||
ref={gunPitchInput}
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
<TextField.Slot
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
@@ -145,13 +155,15 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
</Flex>
|
||||
|
||||
{mode === 'armor' && (
|
||||
<Flex gap="4">
|
||||
<Flex gap="4" style={{ pointerEvents: 'auto' }}>
|
||||
<QuickEquipmentButton
|
||||
equipment={103}
|
||||
active={hasCalibratedShells}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.antagonist!.equipment[0][0] = hasCalibratedShells ? 0 : 1;
|
||||
draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
|
||||
? 0
|
||||
: 1;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -160,7 +172,9 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
||||
active={hasEnhancedArmor}
|
||||
onClick={() => {
|
||||
mutateDuel((draft) => {
|
||||
draft.protagonist!.equipment[1][1] = hasEnhancedArmor ? 0 : -1;
|
||||
draft.protagonist!.equipmentMatrix[1][1] = hasEnhancedArmor
|
||||
? 0
|
||||
: -1;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -13,8 +13,6 @@ export function Title() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{protagonist.tank.name}</title>
|
||||
|
||||
<Flex gap="6" justify="center" align="center" style={{ padding: 16 }}>
|
||||
<Flex gap="2" align="center">
|
||||
<Heading
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { tankDefinitions } from '../../../../core/blitzkrieg/tankDefinitions';
|
||||
import { tankIcon } from '../../../../core/blitzkrieg/tankIcon';
|
||||
|
||||
interface TankopediaLayoutProps {
|
||||
children: ReactNode;
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function TankopediaLayout({
|
||||
children,
|
||||
params,
|
||||
}: TankopediaLayoutProps) {
|
||||
const id = Number(params.id);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{tank.name}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={`Statistics, armor profiles, equipment, and more about ${tank.name}`}
|
||||
/>
|
||||
<meta property="og:image" content={tankIcon(id)} />
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
|
||||
import { AntagonistBar } from './components/AntagonistBar';
|
||||
import { Characteristics } from './components/Characteristics';
|
||||
import { Consumables } from './components/Characteristics/components/Consumables';
|
||||
import { Equipments } from './components/Characteristics/components/Equipments';
|
||||
import { Equipment } from './components/Characteristics/components/Equipment';
|
||||
import { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
|
||||
import { Modules } from './components/Characteristics/components/Modules';
|
||||
import { Provisions } from './components/Characteristics/components/Provisions';
|
||||
import { Skills } from './components/Characteristics/components/Skills';
|
||||
import { TankSandbox } from './components/Model/TankSandbox';
|
||||
import { Title } from './components/Title';
|
||||
|
||||
@@ -63,10 +64,8 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
};
|
||||
}, [initialId, assigned]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<PageWrapper color="purple" size="double">
|
||||
<PageWrapper color="purple" size={1600}>
|
||||
{assigned && (
|
||||
<>
|
||||
<Title />
|
||||
@@ -79,7 +78,7 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
justify="center"
|
||||
>
|
||||
<Flex
|
||||
gap="4"
|
||||
gap="8"
|
||||
direction="column"
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -88,15 +87,22 @@ export default function Page({ params }: { params: { id: string } }) {
|
||||
position: wideFormat ? 'sticky' : undefined,
|
||||
}}
|
||||
>
|
||||
<TankSandbox />
|
||||
<AntagonistBar />
|
||||
<Flex gap="4" direction="column">
|
||||
<TankSandbox />
|
||||
<AntagonistBar />
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex gap="5" wrap="wrap">
|
||||
<Modules />
|
||||
<Provisions />
|
||||
<Equipments />
|
||||
<Miscellaneous />
|
||||
<Consumables />
|
||||
|
||||
<Flex gap="5" wrap="wrap">
|
||||
<Skills />
|
||||
<Equipment />
|
||||
</Flex>
|
||||
|
||||
<Miscellaneous />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -32,32 +32,31 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</Button>
|
||||
<TextField.Root>
|
||||
<TextField.Root
|
||||
defaultValue={1}
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
min={1}
|
||||
max={Math.floor(searchedList.length / tanksPerPage) + 1}
|
||||
style={{ textAlign: 'center' }}
|
||||
onBlur={(event) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.floor(searchedList.length / tanksPerPage),
|
||||
event.target.valueAsNumber - 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextField.Slot>Page</TextField.Slot>
|
||||
<TextField.Input
|
||||
defaultValue={1}
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
min={1}
|
||||
max={Math.floor(searchedList.length / tanksPerPage) + 1}
|
||||
style={{ width: 64, textAlign: 'center' }}
|
||||
onBlur={(event) => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.floor(searchedList.length / tanksPerPage),
|
||||
event.target.valueAsNumber - 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField.Slot>
|
||||
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
|
||||
</TextField.Slot>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { slateDark } from '@radix-ui/colors';
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretRightIcon,
|
||||
MagnifyingGlassIcon,
|
||||
TrashIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import {
|
||||
AlertDialog,
|
||||
Button,
|
||||
Card,
|
||||
DropdownMenu,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
TextField,
|
||||
} from '@radix-ui/themes';
|
||||
import { go } from 'fuzzysort';
|
||||
import Link from 'next/link';
|
||||
import { use, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TreeType } from '../../../../../components/Tanks';
|
||||
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
|
||||
@@ -50,11 +53,16 @@ import { Options } from './components/Options';
|
||||
interface TankSearchProps {
|
||||
compact?: boolean;
|
||||
onSelect?: (tank: TankDefinition) => void;
|
||||
onSelectAll?: (tanks: TankDefinition[]) => void;
|
||||
}
|
||||
|
||||
const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector'];
|
||||
|
||||
export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
export function TankSearch({
|
||||
compact,
|
||||
onSelect = () => {},
|
||||
onSelectAll,
|
||||
}: TankSearchProps) {
|
||||
const tanksPerPage = compact ? 16 : 96;
|
||||
const nations = use(NATIONS);
|
||||
const filters = useTankopediaPersistent((state) => state.filters);
|
||||
@@ -151,7 +159,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
);
|
||||
break;
|
||||
|
||||
case 'survivability.size':
|
||||
case 'survivability.volume':
|
||||
sorted = filtered.sort((a, b) => {
|
||||
const aTankModelDefinition = awaitedModelDefinitions[a.id];
|
||||
const bTankModelDefinition = awaitedModelDefinitions[b.id];
|
||||
@@ -178,6 +186,33 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
});
|
||||
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]);
|
||||
|
||||
return aLength - bLength;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'fire.dpm':
|
||||
sorted = filtered.sort(
|
||||
(a, b) =>
|
||||
@@ -365,20 +400,28 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
.filter(([name]) => name !== 'page')
|
||||
.map(([, value]) => value),
|
||||
]);
|
||||
const [searchResults, setSearchedList] = useState(searchableTanks);
|
||||
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 wideFormat = typeof window !== 'undefined' && window.innerWidth > 512;
|
||||
const chunkSize = wideFormat ? tanksPerPage / 2 : Infinity;
|
||||
const searchResultsPageSlice = searchResults.slice(
|
||||
page * tanksPerPage,
|
||||
(page + 1) * tanksPerPage,
|
||||
);
|
||||
const firstChunk = searchResultsPageSlice.slice(0, chunkSize);
|
||||
const secondChunk = searchResultsPageSlice.slice(chunkSize);
|
||||
const firstChunk = searchResultsPageSlice.slice(
|
||||
0,
|
||||
Math.ceil(searchResultsPageSlice.length / 2),
|
||||
);
|
||||
const secondChunk = searchResultsPageSlice.slice(
|
||||
Math.ceil(searchResultsPageSlice.length / 2),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchedList(searchableTanks);
|
||||
}, [searchableTanks]);
|
||||
useEffect(() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.filters.page = Math.min(
|
||||
@@ -392,27 +435,41 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
<>
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex gap="2">
|
||||
<TextField.Root style={{ flex: 1 }}>
|
||||
<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>
|
||||
|
||||
<TextField.Input
|
||||
ref={input}
|
||||
placeholder="Search tanks..."
|
||||
onChange={(event) => {
|
||||
const results = go(event.target.value, searchableTanks, {
|
||||
keys: [
|
||||
'name',
|
||||
'nameFull',
|
||||
'id',
|
||||
] satisfies (keyof TankDefinition)[],
|
||||
all: true,
|
||||
}).map(({ obj }) => obj);
|
||||
|
||||
setSearchedList(results);
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -699,14 +756,24 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
Camouflage shooting
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.size'}
|
||||
checked={sort.by === 'survivability.volume'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.size';
|
||||
draft.sort.by = 'survivability.volume';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Size
|
||||
Volume
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={sort.by === 'survivability.length'}
|
||||
onClick={() => {
|
||||
mutateTankopediaPersistent((draft) => {
|
||||
draft.sort.by = 'survivability.length';
|
||||
});
|
||||
}}
|
||||
>
|
||||
Length
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
@@ -759,132 +826,185 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
<Options />
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center">
|
||||
<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} />
|
||||
)}
|
||||
<Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}>
|
||||
|
||||
<Flex
|
||||
wrap="wrap"
|
||||
gap="2"
|
||||
style={{
|
||||
justifyContent:
|
||||
compact && firstChunk.length > 0 && secondChunk.length > 0
|
||||
? 'space-around'
|
||||
: 'center',
|
||||
}}
|
||||
>
|
||||
{!compact &&
|
||||
searchResultsPageSlice.map((tank) => (
|
||||
<Card
|
||||
key={tank.id}
|
||||
onClick={() => onSelect(tank)}
|
||||
className={styles.listing}
|
||||
<Link
|
||||
href={`/tools/tankopedia/${tank.id}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
height: 64,
|
||||
minWidth: 256,
|
||||
}}
|
||||
>
|
||||
<Inset
|
||||
<Card
|
||||
key={tank.id}
|
||||
className={styles.listing}
|
||||
style={{
|
||||
minWidth: 256,
|
||||
minHeight: 64,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
cursor: 'pointer',
|
||||
height: 64,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.flag}
|
||||
src={asset(`flags/scratched/${tank.nation}.webp`)}
|
||||
<Inset
|
||||
key={tank.id}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
minHeight: 64,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
src={tankIcon(tank.id)}
|
||||
className={styles.flag}
|
||||
src={asset(`flags/scratched/${tank.nation}.webp`)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'left center',
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<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
|
||||
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"
|
||||
src={tankIcon(tank.id)}
|
||||
style={{
|
||||
color:
|
||||
tank.treeType === 'researchable'
|
||||
? slateDark.slate12
|
||||
: undefined,
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 160,
|
||||
display: 'block',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'left center',
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
/>
|
||||
</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
|
||||
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>
|
||||
|
||||
<Text size="4" color="gray" highContrast={false}>
|
||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Inset>
|
||||
</Card>
|
||||
</Inset>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{compact && (
|
||||
{compact && firstChunk.length > 0 && (
|
||||
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
|
||||
)}
|
||||
|
||||
@@ -892,6 +1012,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
||||
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -74,12 +74,6 @@ export const characteristics = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const configurationChild = style({
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const configuration = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<PageWrapper size="double" color="purple">
|
||||
<PageWrapper size={1200} color="purple">
|
||||
<TankSearch
|
||||
onSelect={(tank) => {
|
||||
router.push(`./tankopedia/${tank.id}`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Glow } from '../components/AllStatsOverview/components/HeroStat/compone
|
||||
import CommandWrapper from '../components/CommandWrapper';
|
||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz';
|
||||
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitzLink';
|
||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
async handler(interaction) {
|
||||
const { t, translate } = translator(interaction.locale);
|
||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||
const discordId = parseInt(interaction.user.id);
|
||||
const discordId = BigInt(interaction.user.id);
|
||||
const accountInfo = await getAccountInfo(region, id);
|
||||
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
|
||||
|
||||
await linkBlitzAndDiscord(discordId, region, id);
|
||||
await linkBlitzAndDiscord(discordId, id);
|
||||
|
||||
if (interaction.guildId === discord.sklld_guild_id) {
|
||||
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
|
||||
|
||||
@@ -385,26 +385,17 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
||||
right: '5%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<img
|
||||
src={await tankIconPng(id)}
|
||||
style={{
|
||||
height: 120,
|
||||
width: 240,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
height: 100,
|
||||
width: 100,
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
left: '25%',
|
||||
top: '5%',
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={await tankIconPng(id)}
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -53,6 +53,7 @@ export function HeroStat({ stats, locale }: WN8DisplayProps) {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: -4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -20,6 +20,7 @@ export const CoreArmorScene = memo(() => {
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
const protagonist = useDuel((draft) => draft.protagonist!);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(useDuel.getState().protagonist!);
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -70,8 +70,8 @@ export function CoreArmorSceneComponent({
|
||||
material.uniforms.explosionRadius.value = shell.explosionRadius;
|
||||
|
||||
const duel = useDuel.getState();
|
||||
await handleProtagonistEquipmentChange(duel.protagonist!.equipment);
|
||||
await handleAntagonistEquipmentChange(duel.antagonist!.equipment);
|
||||
await handleProtagonistEquipmentChange(duel.protagonist!.equipmentMatrix);
|
||||
await handleAntagonistEquipmentChange(duel.antagonist!.equipmentMatrix);
|
||||
}
|
||||
function handleVisualChange(
|
||||
visual: TankopediaPersistent['model']['visual'],
|
||||
@@ -84,25 +84,40 @@ export function CoreArmorSceneComponent({
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
const duel = useDuel.getState();
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
110,
|
||||
duel.protagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
const duel = useDuel.getState();
|
||||
const penetration = resolveNearPenetration(
|
||||
duel.antagonist!.shell.penetration,
|
||||
);
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleVisualChange(useTankopediaPersistent.getState().model.visual);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
handleProtagonistEquipmentChange(
|
||||
useDuel.getState().protagonist!.equipmentMatrix,
|
||||
);
|
||||
handleAntagonistEquipmentChange(
|
||||
useDuel.getState().antagonist!.equipmentMatrix,
|
||||
);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
@@ -111,11 +126,11 @@ export function CoreArmorSceneComponent({
|
||||
handleVisualChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
(state) => state.protagonist!.equipmentMatrix,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
(state) => state.antagonist!.equipmentMatrix,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
@@ -123,7 +138,7 @@ export function CoreArmorSceneComponent({
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
useFrame(({ gl, camera }) => {
|
||||
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
|
||||
|
||||
@@ -31,6 +31,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
const turretContainer = useRef<Group>(null);
|
||||
const gunContainer = useRef<Group>(null);
|
||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||
const protagonist = useDuel((draft) => draft.protagonist!);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelDefinitions) return;
|
||||
@@ -99,7 +100,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
||||
turretContainer.current?.rotation.copy(turretRotation);
|
||||
}
|
||||
|
||||
handleModelTransform(useDuel.getState().protagonist!);
|
||||
handleModelTransform(protagonist);
|
||||
modelTransformEvent.on(handleModelTransform);
|
||||
|
||||
return () => {
|
||||
|
||||
+25
-10
@@ -50,32 +50,47 @@ export function SpacedArmorSubExternal({
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
const duel = useDuel.getState();
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
110,
|
||||
duel.protagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
const duel = useDuel.getState();
|
||||
const penetration = resolveNearPenetration(
|
||||
duel.antagonist!.shell.penetration,
|
||||
);
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
handleProtagonistEquipmentChange(
|
||||
useDuel.getState().protagonist!.equipmentMatrix,
|
||||
);
|
||||
handleAntagonistEquipmentChange(
|
||||
useDuel.getState().antagonist!.equipmentMatrix,
|
||||
);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
(state) => state.protagonist!.equipmentMatrix,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
(state) => state.antagonist!.equipmentMatrix,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
@@ -83,7 +98,7 @@ export function SpacedArmorSubExternal({
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+21
-7
@@ -62,32 +62,46 @@ export function SpacedArmorSubSpaced({
|
||||
async function handleProtagonistEquipmentChange(
|
||||
equipment: EquipmentMatrix,
|
||||
) {
|
||||
const hasEnhancedArmor = await hasEquipment(110, false, equipment);
|
||||
const duel = useDuel.getState();
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
110,
|
||||
duel.protagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.thickness.value = hasEnhancedArmor
|
||||
? thickness * 1.04
|
||||
: thickness;
|
||||
}
|
||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||
const duel = useDuel.getState();
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const penetration = resolveNearPenetration(shell.penetration);
|
||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
equipment,
|
||||
);
|
||||
material.uniforms.penetration.value = hasCalibratedShells
|
||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||
: penetration;
|
||||
}
|
||||
|
||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
||||
handleProtagonistEquipmentChange(
|
||||
useDuel.getState().protagonist!.equipmentMatrix,
|
||||
);
|
||||
handleAntagonistEquipmentChange(
|
||||
useDuel.getState().antagonist!.equipmentMatrix,
|
||||
);
|
||||
|
||||
const unsubscribes = [
|
||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||
useDuel.subscribe(
|
||||
(state) => state.protagonist!.equipment,
|
||||
(state) => state.protagonist!.equipmentMatrix,
|
||||
handleProtagonistEquipmentChange,
|
||||
),
|
||||
useDuel.subscribe(
|
||||
(state) => state.antagonist!.equipment,
|
||||
(state) => state.antagonist!.equipmentMatrix,
|
||||
handleAntagonistEquipmentChange,
|
||||
),
|
||||
];
|
||||
@@ -95,7 +109,7 @@ export function SpacedArmorSubSpaced({
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -89,8 +89,17 @@ export function SpacedArmorSceneComponent({
|
||||
async onClick(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const hasCalibratedShells = await hasEquipment(103, true);
|
||||
const hasEnhancedArmor = await hasEquipment(110);
|
||||
const duel = useDuel.getState();
|
||||
const hasCalibratedShells = await hasEquipment(
|
||||
103,
|
||||
duel.antagonist!.tank.equipment,
|
||||
duel.antagonist!.equipmentMatrix,
|
||||
);
|
||||
const hasEnhancedArmor = await hasEquipment(
|
||||
110,
|
||||
duel.protagonist!.tank.equipment,
|
||||
duel.protagonist!.equipmentMatrix,
|
||||
);
|
||||
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
|
||||
const shell = useDuel.getState().antagonist!.shell;
|
||||
const explosive = isExplosive(shell.type);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { ConsumableEntry } from '../core/blitzkrieg/consumableDefinitions';
|
||||
import { ConsumableButton } from './ModuleButtons/ConsumableButton';
|
||||
|
||||
interface ConsumablesManagerProps {
|
||||
consumables: ConsumableEntry[];
|
||||
selected: number[];
|
||||
onChange?: (consumables: number[]) => void;
|
||||
disabled?: boolean;
|
||||
timers?: boolean;
|
||||
hasHighEndConsumables?: boolean;
|
||||
hasConsumableDeliverySystem?: boolean;
|
||||
cooldownBooster?: number;
|
||||
}
|
||||
|
||||
export function ConsumablesManager({
|
||||
consumables,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
timers,
|
||||
hasConsumableDeliverySystem,
|
||||
cooldownBooster,
|
||||
hasHighEndConsumables,
|
||||
}: ConsumablesManagerProps) {
|
||||
return (
|
||||
<Flex wrap="wrap">
|
||||
{consumables.map((consumable, index) => {
|
||||
const isSelected = selected.some((id) => id === consumable.id);
|
||||
|
||||
return (
|
||||
<ConsumableButton
|
||||
special={consumable.duration === undefined}
|
||||
duration={
|
||||
timers
|
||||
? consumable.duration === undefined
|
||||
? undefined
|
||||
: consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
|
||||
: undefined
|
||||
}
|
||||
cooldown={
|
||||
timers
|
||||
? consumable.cooldown *
|
||||
(hasConsumableDeliverySystem ? 0.85 : 1) *
|
||||
(1 - cooldownBooster! * 0.1)
|
||||
: undefined
|
||||
}
|
||||
key={consumable.id}
|
||||
first={index === 0}
|
||||
last={index === consumables.length - 1}
|
||||
rowChild
|
||||
disabled={disabled && !isSelected}
|
||||
consumable={consumable.id}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!onChange) return;
|
||||
|
||||
const draft = [...selected];
|
||||
|
||||
if (draft.some((id) => id === consumable.id)) {
|
||||
draft.splice(draft.indexOf(consumable.id), 1);
|
||||
} else {
|
||||
draft.push(consumable.id);
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { permanentSkills } from '../app/tools/tankopedia/[id]/components/Characteristics/components/Skills/constants';
|
||||
import { asset } from '../core/blitzkrieg/asset';
|
||||
import { skillDefinitions } from '../core/blitzkrieg/skillDefinitions';
|
||||
import { Tier } from '../core/blitzkrieg/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../core/blitzkrieg/tankDefinitions/constants';
|
||||
import { GenericTankComponentButton } from './ModuleButtons/GenericTankComponentButton';
|
||||
|
||||
interface CrewSkillManagerProps {
|
||||
skillLevels: Record<string, number>;
|
||||
onChange?: (skillLevels: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
export function CrewSkillManager({
|
||||
skillLevels,
|
||||
onChange,
|
||||
}: CrewSkillManagerProps) {
|
||||
const awaitedSkillDefinitions = use(skillDefinitions);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
{Object.entries(awaitedSkillDefinitions.classes).map(
|
||||
([tankClass, skills]) => (
|
||||
<Flex gap="2" key={tankClass}>
|
||||
{skills.map((skill) => {
|
||||
const level = skillLevels[skill];
|
||||
|
||||
return (
|
||||
<GenericTankComponentButton
|
||||
special={!permanentSkills.includes(skill)}
|
||||
first
|
||||
last
|
||||
selected={level > 0}
|
||||
discriminator={
|
||||
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
|
||||
}
|
||||
icon={asset(`icons/skills/${skill}.webp`)}
|
||||
onClick={(event) => {
|
||||
if (!onChange) return;
|
||||
|
||||
const draft = { ...skillLevels };
|
||||
|
||||
if (event.shiftKey) {
|
||||
draft[skill] = draft[skill] === 0 ? 7 : 0;
|
||||
} else {
|
||||
draft[skill] = level - 1;
|
||||
if (draft[skill] < 0) draft[skill] = 7;
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { EquipmentPreset } from '../core/blitzkrieg/equipmentDefinitions';
|
||||
import { EquipmentMatrix } from '../stores/duel';
|
||||
import { EquipmentButton } from './ModuleButtons/EquipmentButton';
|
||||
|
||||
interface EquipmentManagerProps {
|
||||
preset: EquipmentPreset;
|
||||
matrix: EquipmentMatrix;
|
||||
onChange: (matrix: EquipmentMatrix) => void;
|
||||
}
|
||||
|
||||
export function EquipmentManager({
|
||||
preset,
|
||||
matrix,
|
||||
onChange,
|
||||
}: EquipmentManagerProps) {
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
{preset.map((equipmentRow, rowIndex) => (
|
||||
<Flex gap="2" key={rowIndex}>
|
||||
{equipmentRow.map((equipment, columnIndex) => (
|
||||
<Flex key={columnIndex}>
|
||||
<EquipmentButton
|
||||
equipment={equipment[0]}
|
||||
first
|
||||
rowChild
|
||||
selected={matrix[rowIndex][columnIndex] === -1}
|
||||
onClick={() => {
|
||||
const draft = cloneDeep(matrix);
|
||||
|
||||
if (draft[rowIndex][columnIndex] === -1) {
|
||||
draft[rowIndex][columnIndex] = 0;
|
||||
} else {
|
||||
draft[rowIndex][columnIndex] = -1;
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
<EquipmentButton
|
||||
equipment={equipment[1]}
|
||||
last
|
||||
rowChild
|
||||
selected={matrix[rowIndex][columnIndex] === 1}
|
||||
onClick={() => {
|
||||
const draft = cloneDeep(matrix);
|
||||
|
||||
if (draft[rowIndex][columnIndex] === 1) {
|
||||
draft[rowIndex][columnIndex] = 0;
|
||||
} else {
|
||||
draft[rowIndex][columnIndex] = 1;
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Code } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { gameDefinitions } from '../../../core/blitzkrieg/gameDefinitions';
|
||||
|
||||
export function WoTBVersion() {
|
||||
const awaitedGameDefinitions = use(gameDefinitions);
|
||||
|
||||
return (
|
||||
<Code color="gray" size="2">
|
||||
{awaitedGameDefinitions.version}
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Card, Code, Flex, Link, Spinner, Text, Theme } from '@radix-ui/themes';
|
||||
import { Suspense } from 'react';
|
||||
import packageJSON from '../../../package.json';
|
||||
import { useWideFormat } from '../../hooks/useWideFormat';
|
||||
import { WoTBVersion } from './components/WoTBVersion';
|
||||
|
||||
export function Footer() {
|
||||
const wideFormat = useWideFormat();
|
||||
|
||||
return (
|
||||
<Theme radius="none">
|
||||
<Card>
|
||||
<Flex justify="center">
|
||||
<Flex
|
||||
justify={wideFormat ? 'between' : 'center'}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 800,
|
||||
padding: '0 16px',
|
||||
}}
|
||||
>
|
||||
<Flex gap="1">
|
||||
<Text color="gray" size="2">
|
||||
Blitzkrieg
|
||||
</Text>
|
||||
|
||||
<Code color="gray" size="2">
|
||||
{packageJSON.version}
|
||||
</Code>
|
||||
|
||||
<Text color="gray" size="2">
|
||||
for WoTB
|
||||
</Text>
|
||||
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<WoTBVersion />
|
||||
</Suspense>
|
||||
</Flex>
|
||||
|
||||
{wideFormat && (
|
||||
<Link
|
||||
size="2"
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
target="_blank"
|
||||
>
|
||||
Consider donating on Patreon
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export function Analytics() {
|
||||
export function Google() {
|
||||
return (
|
||||
<>
|
||||
{/* analytics */}
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-98NTMRHRP9"
|
||||
@@ -16,6 +17,13 @@ export function Analytics() {
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* adsense */}
|
||||
<script
|
||||
async
|
||||
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1289406790225052"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { TankComponentButtonProps } from './TankComponentButton';
|
||||
interface ConsumableButtonProps extends TankComponentButtonProps {
|
||||
consumable: number;
|
||||
duration?: number;
|
||||
cooldown: number;
|
||||
cooldown?: number;
|
||||
}
|
||||
|
||||
export function ConsumableButton({
|
||||
@@ -17,7 +17,7 @@ export function ConsumableButton({
|
||||
duration,
|
||||
...props
|
||||
}: ConsumableButtonProps) {
|
||||
const cooldownDelta = useDelta(cooldown);
|
||||
const cooldownDelta = useDelta(cooldown ?? 0);
|
||||
const durationDelta = useDelta(duration ?? 0);
|
||||
|
||||
return (
|
||||
@@ -25,38 +25,42 @@ export function ConsumableButton({
|
||||
icon={asset(`icons/consumables/${consumable}.webp`)}
|
||||
{...props}
|
||||
>
|
||||
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
|
||||
{duration && (
|
||||
<Text
|
||||
color={
|
||||
durationDelta < 0
|
||||
? 'green'
|
||||
: durationDelta > 0
|
||||
? 'tomato'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="1" style={{ marginBottom: -4 }}>
|
||||
<ClockIcon width={12} height={12} />
|
||||
{Math.round(duration)}s
|
||||
</Flex>
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
color={
|
||||
cooldownDelta < 0
|
||||
? 'green'
|
||||
: cooldownDelta > 0
|
||||
? 'tomato'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="1">
|
||||
<ReloadIcon width={12} height={12} />
|
||||
{Math.round(cooldown)}s
|
||||
</Flex>
|
||||
</Text>
|
||||
</Flex>
|
||||
{(cooldown || duration) && (
|
||||
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
|
||||
{duration && (
|
||||
<Text
|
||||
color={
|
||||
durationDelta < 0
|
||||
? 'green'
|
||||
: durationDelta > 0
|
||||
? 'tomato'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="1" style={{ marginBottom: -4 }}>
|
||||
<ClockIcon width={12} height={12} />
|
||||
{Math.round(duration)}s
|
||||
</Flex>
|
||||
</Text>
|
||||
)}
|
||||
{cooldown && (
|
||||
<Text
|
||||
color={
|
||||
cooldownDelta < 0
|
||||
? 'green'
|
||||
: cooldownDelta > 0
|
||||
? 'tomato'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="1">
|
||||
<ReloadIcon width={12} height={12} />
|
||||
{Math.round(cooldown)}s
|
||||
</Flex>
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</GenericTankComponentButton>
|
||||
);
|
||||
}
|
||||
@@ -11,23 +11,28 @@ export interface TankComponentButtonProps
|
||||
discriminator?: ReactNode;
|
||||
disabled?: boolean;
|
||||
banner?: string;
|
||||
special?: boolean;
|
||||
}
|
||||
|
||||
export function TankComponentButton({
|
||||
variant,
|
||||
selected,
|
||||
first = false,
|
||||
last = false,
|
||||
rowChild,
|
||||
discriminator,
|
||||
special,
|
||||
children,
|
||||
banner,
|
||||
disabled,
|
||||
style,
|
||||
...props
|
||||
}: TankComponentButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
radius="medium"
|
||||
color={selected ? undefined : 'gray'}
|
||||
variant={selected ? 'surface' : 'soft'}
|
||||
color={selected ? (special ? 'amber' : undefined) : 'gray'}
|
||||
variant={variant ?? (selected ? 'surface' : 'soft')}
|
||||
style={{
|
||||
padding: 0,
|
||||
minWidth: 48,
|
||||
@@ -39,7 +44,11 @@ export function TankComponentButton({
|
||||
borderBottomRightRadius: last ? undefined : 0,
|
||||
margin: rowChild ? -0.5 : 'unset',
|
||||
overflow: 'hidden',
|
||||
cursor: 'unset',
|
||||
pointerEvents: variant === 'ghost' ? 'none' : undefined,
|
||||
...style,
|
||||
}}
|
||||
disabled={variant === 'ghost' || disabled}
|
||||
{...props}
|
||||
>
|
||||
{banner !== undefined && (
|
||||
@@ -56,23 +65,23 @@ export function TankComponentButton({
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{discriminator !== undefined && (
|
||||
<Text
|
||||
size="1"
|
||||
style={{
|
||||
textShadow: '0 0 4px black',
|
||||
zIndex: 1,
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: 8,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
color={variant === 'ghost' ? 'gray' : undefined}
|
||||
>
|
||||
{discriminator}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { DropdownMenu, Flex } from '@radix-ui/themes';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
EngineDefinition,
|
||||
GunDefinition,
|
||||
ShellDefinition,
|
||||
TankDefinition,
|
||||
TrackDefinition,
|
||||
TurretDefinition,
|
||||
} from '../core/blitzkrieg/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../core/blitzkrieg/tankDefinitions/constants';
|
||||
import { ModuleButton } from './ModuleButtons/ModuleButton';
|
||||
|
||||
interface ModuleManagerProps {
|
||||
tank: TankDefinition;
|
||||
turret: TurretDefinition;
|
||||
gun: GunDefinition;
|
||||
shell: ShellDefinition;
|
||||
engine: EngineDefinition;
|
||||
track: TrackDefinition;
|
||||
onChange?: (modules: {
|
||||
turret: TurretDefinition;
|
||||
gun: GunDefinition;
|
||||
engine: EngineDefinition;
|
||||
track: TrackDefinition;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function ModuleManager({
|
||||
tank,
|
||||
onChange,
|
||||
...modules
|
||||
}: ModuleManagerProps) {
|
||||
const [turretMenuOpen, setTurretMenuOpen] = useState(false);
|
||||
const [gunMenuOpen, setGunMenuOpen] = useState(false);
|
||||
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
|
||||
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Flex gap="1" wrap="wrap">
|
||||
<ModuleButton
|
||||
module="turret"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setTurretMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={turretMenuOpen} onOpenChange={setTurretMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.turret.id}`}>
|
||||
{[...tank.turrets].reverse().map((turret) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={turret.id}
|
||||
value={`${turret.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.turret = turret;
|
||||
draft.gun = draft.turret.guns.at(-1)!;
|
||||
draft.shell = draft.gun.shells.at(-1)!;
|
||||
|
||||
onChange?.(draft);
|
||||
setTurretMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="turret"
|
||||
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
|
||||
/>{' '}
|
||||
{turret.name}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<ModuleButton
|
||||
module="gun"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setGunMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.gun.id}`}>
|
||||
{[...modules.turret.guns].reverse().map((gun) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={gun.id}
|
||||
value={`${gun.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.gun = gun;
|
||||
draft.shell = draft.gun.shells.at(-1)!;
|
||||
|
||||
onChange?.(draft);
|
||||
setGunMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="gun"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
|
||||
/>{' '}
|
||||
{gun.name}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<ModuleButton
|
||||
module="engine"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setEngineMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={engineMenuOpen} onOpenChange={setEngineMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.engine.id}`}>
|
||||
{[...tank.engines].reverse().map((engine) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={engine.id}
|
||||
value={`${engine.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.engine = engine;
|
||||
|
||||
onChange?.(draft);
|
||||
setEngineMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="engine"
|
||||
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
|
||||
/>{' '}
|
||||
{engine.name}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<ModuleButton
|
||||
module="chassis"
|
||||
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
|
||||
first
|
||||
last
|
||||
rowChild
|
||||
onClick={() => setTrackMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<DropdownMenu.Root open={trackMenuOpen} onOpenChange={setTrackMenuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<div />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.RadioGroup value={`${modules.track.id}`}>
|
||||
{[...tank.tracks].reverse().map((track) => {
|
||||
return (
|
||||
<DropdownMenu.RadioItem
|
||||
key={track.id}
|
||||
value={`${track.id}`}
|
||||
onClick={() => {
|
||||
const draft = { ...modules };
|
||||
draft.track = track;
|
||||
|
||||
onChange?.(draft);
|
||||
setTrackMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModuleButton
|
||||
variant="ghost"
|
||||
module="chassis"
|
||||
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
|
||||
/>{' '}
|
||||
{track.name}
|
||||
</DropdownMenu.RadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { DiscordLogoIcon } from '@radix-ui/react-icons';
|
||||
import { Flex, Text, Theme } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { useFullScreen } from '../../hooks/useFullScreen';
|
||||
import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide';
|
||||
import { PatreonIcon } from '../../icons/Patreon';
|
||||
import { theme } from '../../stitches.config';
|
||||
|
||||
export default function Navbar() {
|
||||
const isFullScreen = useFullScreen();
|
||||
|
||||
if (isFullScreen) return null;
|
||||
|
||||
return (
|
||||
<Theme radius="none">
|
||||
<div
|
||||
@@ -51,9 +57,10 @@ export default function Navbar() {
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="center" align="center" gap="2">
|
||||
<Flex justify="center" align="center" gap="4">
|
||||
<Link
|
||||
href="https://discord.gg/nDt7AjGJQH"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
@@ -61,7 +68,40 @@ export default function Navbar() {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DiscordLogoIcon />
|
||||
<DiscordLogoIcon width={16} height={16} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://ko-fi.com/tresabhi"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="https://i.imgur.com/QdqgdP2.png"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://www.patreon.com/tresabhi"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<PatreonIcon width={14} height={14} />
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import { Flex, Theme } from '@radix-ui/themes';
|
||||
import { Flex, FlexProps, Theme } from '@radix-ui/themes';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
interface PageWrapperProps extends ComponentProps<typeof Flex> {
|
||||
type PageWrapperProps = FlexProps & {
|
||||
color?: ComponentProps<typeof Theme>['accentColor'];
|
||||
size?: 'regular' | 'wide' | 'double';
|
||||
}
|
||||
size?: number | string;
|
||||
};
|
||||
|
||||
export default function PageWrapper({
|
||||
style,
|
||||
color,
|
||||
size = 'regular',
|
||||
size = 800,
|
||||
...props
|
||||
}: PageWrapperProps) {
|
||||
return (
|
||||
<Theme accentColor={color}>
|
||||
<Theme
|
||||
accentColor={color}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: size === 'regular' ? 800 : size === 'double' ? 1200 : 960,
|
||||
maxWidth: size,
|
||||
margin: 'auto',
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
flex: 1,
|
||||
|
||||
...style,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { ProvisionButton } from './ModuleButtons/ProvisionButton';
|
||||
|
||||
interface ProvisionsManagerProps {
|
||||
provisions: number[];
|
||||
selected: number[];
|
||||
disabled?: boolean;
|
||||
onChange?: (provisions: number[]) => void;
|
||||
}
|
||||
|
||||
export function ProvisionsManager({
|
||||
provisions,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ProvisionsManagerProps) {
|
||||
return (
|
||||
<Flex wrap="wrap">
|
||||
{provisions.map((provision, index) => {
|
||||
const isSelected = selected.includes(provision);
|
||||
|
||||
return (
|
||||
<ProvisionButton
|
||||
key={provision}
|
||||
first={index === 0}
|
||||
last={index === provisions.length - 1}
|
||||
rowChild
|
||||
disabled={disabled && !isSelected}
|
||||
provision={provision}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!onChange) return;
|
||||
|
||||
const draft = [...selected];
|
||||
|
||||
if (isSelected) {
|
||||
draft.splice(draft.indexOf(provision), 1);
|
||||
} else {
|
||||
draft.push(provision);
|
||||
}
|
||||
|
||||
onChange(draft);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
+14
-12
@@ -4,6 +4,7 @@ interface Tool {
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
href?: string;
|
||||
pageDescription?: string;
|
||||
}
|
||||
|
||||
export const TOOLS: Tool[] = [
|
||||
@@ -11,16 +12,29 @@ export const TOOLS: Tool[] = [
|
||||
id: 'session',
|
||||
title: 'Session tracker',
|
||||
description: 'Live session performance tracker',
|
||||
pageDescription:
|
||||
'Track your stats in real time as you play World of Tanks Blitz',
|
||||
},
|
||||
{
|
||||
id: 'tankopedia',
|
||||
title: 'Tankopedia',
|
||||
description: 'Blitz tank encyclopedia',
|
||||
pageDescription:
|
||||
'Statistics, armor, and more for all tanks in World of Tanks Blitz',
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
title: 'Rating leaderboard',
|
||||
description: 'Full rating gamemode leaderboard',
|
||||
pageDescription:
|
||||
'Live and archived full rating leaderboard for World of Tanks Blitz',
|
||||
},
|
||||
{
|
||||
id: 'compare',
|
||||
title: 'Compare',
|
||||
description: 'Compare tank statistics and loadouts',
|
||||
pageDescription:
|
||||
'Compare tanks statistics and loadouts in World of Tanks Blitz',
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
@@ -28,18 +42,6 @@ export const TOOLS: Tool[] = [
|
||||
description: 'Stats and tools right in Discord',
|
||||
href: 'https://discord.com/application-directory/1097673957865443370',
|
||||
},
|
||||
// {
|
||||
// id: 'inactive',
|
||||
// title: 'Inactivity tracker',
|
||||
// description: 'Find inactive members of a clan',
|
||||
// disabled: true,
|
||||
// },
|
||||
// {
|
||||
// id: 'profile',
|
||||
// title: 'Player profile',
|
||||
// description: "A player's basic non-statistical info",
|
||||
// disabled: true,
|
||||
// },
|
||||
{
|
||||
id: 'more',
|
||||
title: 'More coming soon',
|
||||
|
||||
@@ -161,15 +161,6 @@ export async function extractModel(data: string, path: string) {
|
||||
const rotation = new Quaternion();
|
||||
const scale = new Vector3();
|
||||
|
||||
if (
|
||||
!component['tc.worldTranslation'] ||
|
||||
!component['tc.worldScale'] ||
|
||||
!component['tc.localTranslation'] ||
|
||||
!component['tc.localScale']
|
||||
) {
|
||||
console.log(component);
|
||||
}
|
||||
|
||||
new Matrix4()
|
||||
.multiplyMatrices(
|
||||
new Matrix4().compose(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
|
||||
export function idToRange(id: number): Region {
|
||||
if (id >= 42e8) {
|
||||
throw new Error('bot id');
|
||||
} else if (id >= 31e8) {
|
||||
throw new Error('china id');
|
||||
} else if (id >= 20e8) {
|
||||
return 'asia';
|
||||
} else if (id >= 10e8) {
|
||||
return 'com';
|
||||
} else if (id >= 5e8) {
|
||||
return 'eu';
|
||||
} else if (id >= 0) {
|
||||
throw new Error('ru id');
|
||||
} else {
|
||||
throw new Error(`id ${id} is out of range`);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { degToRad } from 'three/src/math/MathUtils';
|
||||
import { DuelMember, mutateDuel } from '../../stores/duel';
|
||||
import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants';
|
||||
import { availableProvisions } from './availableProvisions';
|
||||
import { modelDefinitions } from './modelDefinitions';
|
||||
import { mutateDuel } from '../../stores/duel';
|
||||
import { provisionDefinitions } from './provisionDefinitions';
|
||||
import { tankDefinitions } from './tankDefinitions';
|
||||
import { tankToDuelMember } from './tankToDuelMember';
|
||||
|
||||
export async function assignDuelMember(
|
||||
type: 'antagonist' | 'protagonist' | 'both',
|
||||
@@ -12,37 +9,8 @@ export async function assignDuelMember(
|
||||
) {
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
const awaitedProvisionDefinitions = await provisionDefinitions;
|
||||
const awaitedModelDefinitions = await modelDefinitions;
|
||||
const tankModelDefinition = awaitedModelDefinitions[id];
|
||||
const tank = awaitedTankDefinitions[id];
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
const shell = gun.shells[0];
|
||||
const engine = tank.engines.at(-1)!;
|
||||
const track = tank.tracks.at(-1)!;
|
||||
const provisionsList = availableProvisions(
|
||||
tank,
|
||||
gun,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
const member: DuelMember = {
|
||||
tank,
|
||||
engine,
|
||||
turret,
|
||||
gun,
|
||||
shell,
|
||||
track,
|
||||
equipment: genericDefaultEquipmentMatrix,
|
||||
pitch: degToRad(tankModelDefinition.turretRotation?.pitch ?? 0),
|
||||
yaw: 0,
|
||||
camouflage: true,
|
||||
consumables: [],
|
||||
cooldownBooster: 0,
|
||||
crewMastery: 1,
|
||||
provisions: provisionsList
|
||||
.filter((provision) => provision.crew !== undefined)
|
||||
.map((provision) => provision.id),
|
||||
};
|
||||
const member = tankToDuelMember(tank, awaitedProvisionDefinitions);
|
||||
|
||||
mutateDuel((draft) => {
|
||||
draft.assigned = true;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { permanentSkills } from '../../app/tools/tankopedia/[id]/components/Characteristics/components/Skills/constants';
|
||||
import { SkillDefinitions } from './skillDefinitions';
|
||||
|
||||
export function createDefaultSkills(skillDefinitions: SkillDefinitions) {
|
||||
const skills: Record<string, number> = {};
|
||||
|
||||
Object.values(skillDefinitions.classes).forEach((tankSkills) => {
|
||||
tankSkills.forEach((skill) => {
|
||||
skills[skill] = permanentSkills.includes(skill) ? 7 : 0;
|
||||
});
|
||||
});
|
||||
|
||||
return skills;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
import { discordBlitzDB } from './discordBlitzDB';
|
||||
|
||||
export function linkBlitzAndDiscord(
|
||||
discord: number,
|
||||
region: Region,
|
||||
blitz: number,
|
||||
) {
|
||||
return discordBlitzDB.discord_blitz.upsert({
|
||||
where: { discord },
|
||||
update: { region, blitz },
|
||||
create: { discord, region, blitz },
|
||||
});
|
||||
}
|
||||
|
||||
export function getBlitzFromDiscord(discord: number) {
|
||||
return discordBlitzDB.discord_blitz.findUnique({
|
||||
where: { discord },
|
||||
select: { region: true, blitz: true },
|
||||
}) as Promise<null | { region: Region; blitz: number }>;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const discordBlitzDB = new PrismaClient();
|
||||
@@ -0,0 +1,30 @@
|
||||
import { usersDatabase } from '../../databases/users';
|
||||
import { idToRange } from '../blitz/idToRegion';
|
||||
|
||||
export async function flagUserActivity(blitz: number) {
|
||||
await usersDatabase.users.upsert({
|
||||
where: { blitz },
|
||||
update: {},
|
||||
create: { blitz },
|
||||
});
|
||||
}
|
||||
|
||||
export async function linkBlitzAndDiscord(discord: bigint, blitz: number) {
|
||||
await usersDatabase.discordUsers.upsert({
|
||||
where: { discord },
|
||||
update: { blitz },
|
||||
create: { discord, blitz },
|
||||
});
|
||||
await flagUserActivity(blitz);
|
||||
}
|
||||
|
||||
export async function getBlitzFromDiscord(discord: bigint) {
|
||||
const unique = await usersDatabase.discordUsers.findUnique({
|
||||
where: { discord },
|
||||
});
|
||||
|
||||
if (unique === null) return null;
|
||||
|
||||
await flagUserActivity(unique.blitz);
|
||||
return { id: unique.blitz, region: idToRange(unique.blitz) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { asset } from './asset';
|
||||
import { fetchCdonLz4 } from './fetchCdonLz4';
|
||||
|
||||
export interface GameDefinitions {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
|
||||
asset('definitions/game.cdon.lz4'),
|
||||
);
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useDuel } from '../../stores/duel';
|
||||
import { EquipmentMatrix } from '../../stores/duel';
|
||||
import { equipmentDefinitions } from './equipmentDefinitions';
|
||||
|
||||
export async function hasEquipment(
|
||||
id: number,
|
||||
antagonist = false,
|
||||
equipmentMatrix = useDuel.getState()[
|
||||
antagonist ? 'antagonist' : 'protagonist'
|
||||
]!.equipment,
|
||||
equipmentPreset: string,
|
||||
equipmentMatrix: EquipmentMatrix,
|
||||
) {
|
||||
const awaitedEquipmentDefinitions = await equipmentDefinitions;
|
||||
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!;
|
||||
const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment];
|
||||
const presetRows = awaitedEquipmentDefinitions.presets[equipmentPreset];
|
||||
return presetRows.some((equipmentRow, rowIndex) => {
|
||||
return equipmentRow.some((equipment, columnIndex) => {
|
||||
const choice = equipmentMatrix[rowIndex][columnIndex];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usersDatabase } from '../../databases/users';
|
||||
import { client } from '../discord/client';
|
||||
import { discordBlitzDB } from './discordBlitzDB';
|
||||
|
||||
const EXIT_EVENTS = [
|
||||
'beforeExit',
|
||||
@@ -13,7 +13,7 @@ const EXIT_EVENTS = [
|
||||
async function cleanup() {
|
||||
console.log('Cleaning up...');
|
||||
|
||||
await Promise.all([discordBlitzDB.$disconnect(), client.destroy()]);
|
||||
await Promise.all([usersDatabase.$disconnect(), client.destroy()]);
|
||||
|
||||
console.log('Gracefully exiting...');
|
||||
|
||||
|
||||
@@ -17,3 +17,5 @@ export interface ProvisionDefinitions {
|
||||
export const provisionDefinitions = fetchCdonLz4<ProvisionDefinitions>(
|
||||
asset('definitions/provisions.cdon.lz4'),
|
||||
);
|
||||
|
||||
provisionDefinitions.then(console.log);
|
||||
@@ -5,7 +5,7 @@ config();
|
||||
const SECRET_KEYS = [
|
||||
'DISCORD_TOKEN',
|
||||
'GH_TOKEN',
|
||||
'DISCORD_BLITZ_DB',
|
||||
'DATABASE_URL',
|
||||
'WOTB_DLC_CDN',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { TankClass } from '../../components/Tanks';
|
||||
import { asset } from './asset';
|
||||
import { fetchCdonLz4 } from './fetchCdonLz4';
|
||||
|
||||
export interface SkillDefinitions {
|
||||
classes: Record<TankClass, string[]>;
|
||||
}
|
||||
|
||||
export const skillDefinitions = fetchCdonLz4<SkillDefinitions>(
|
||||
asset('definitions/skills.cdon.lz4'),
|
||||
);
|
||||
@@ -0,0 +1,484 @@
|
||||
import { EquipmentMatrix } from '../../stores/duel';
|
||||
import { isExplosive } from '../blitz/isExplosive';
|
||||
import { resolveNearPenetration } from '../blitz/resolveNearPenetration';
|
||||
import { coefficient } from './coefficient';
|
||||
import { degressiveStat } from './degressiveStat';
|
||||
import { EquipmentDefinitions } from './equipmentDefinitions';
|
||||
import { ModelDefinitions } from './modelDefinitions';
|
||||
import { normalizeBoundingBox } from './normalizeBoundingBox';
|
||||
import { progressiveStat } from './progressiveStat';
|
||||
import { ProvisionDefinitions } from './provisionDefinitions';
|
||||
import { resolveDpm } from './resolveDpm';
|
||||
import { sum } from './sum';
|
||||
import {
|
||||
EngineDefinition,
|
||||
GunDefinition,
|
||||
ShellDefinition,
|
||||
TankDefinition,
|
||||
TrackDefinition,
|
||||
TurretDefinition,
|
||||
} from './tankDefinitions';
|
||||
import { unionBoundingBox } from './unionBoundingBox';
|
||||
|
||||
export function tankCharacteristics(
|
||||
{
|
||||
tank,
|
||||
turret,
|
||||
gun,
|
||||
shell,
|
||||
consumables,
|
||||
equipmentMatrix,
|
||||
crewMastery,
|
||||
provisions,
|
||||
engine,
|
||||
crewSkills,
|
||||
camouflage,
|
||||
track,
|
||||
stockEngine,
|
||||
stockTrack,
|
||||
stockTurret,
|
||||
stockGun,
|
||||
applyReactive,
|
||||
applyDynamicArmor,
|
||||
applySpallLiner,
|
||||
}: {
|
||||
tank: TankDefinition;
|
||||
turret: TurretDefinition;
|
||||
gun: GunDefinition;
|
||||
shell: ShellDefinition;
|
||||
track: TrackDefinition;
|
||||
engine: EngineDefinition;
|
||||
stockEngine: EngineDefinition;
|
||||
stockTrack: TrackDefinition;
|
||||
stockTurret: TurretDefinition;
|
||||
stockGun: GunDefinition;
|
||||
consumables: number[];
|
||||
equipmentMatrix: EquipmentMatrix;
|
||||
crewMastery: number;
|
||||
provisions: number[];
|
||||
crewSkills: Record<string, number>;
|
||||
camouflage: boolean;
|
||||
applyReactive: boolean;
|
||||
applyDynamicArmor: boolean;
|
||||
applySpallLiner: boolean;
|
||||
},
|
||||
{
|
||||
modelDefinitions,
|
||||
equipmentDefinitions,
|
||||
provisionDefinitions,
|
||||
}: {
|
||||
modelDefinitions: ModelDefinitions;
|
||||
equipmentDefinitions: EquipmentDefinitions;
|
||||
provisionDefinitions: ProvisionDefinitions;
|
||||
},
|
||||
) {
|
||||
const presetRows = equipmentDefinitions.presets[tank.equipment];
|
||||
const tankModelDefinition = modelDefinitions[tank.id];
|
||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||
|
||||
function equipment(id: number) {
|
||||
return presetRows.some((equipmentRow, rowIndex) => {
|
||||
return equipmentRow.some((equipment, columnIndex) => {
|
||||
const choice = equipmentMatrix[rowIndex][columnIndex];
|
||||
if (choice === 0) return false;
|
||||
const equipped = equipment[choice === -1 ? 0 : 1];
|
||||
return equipped === id;
|
||||
});
|
||||
});
|
||||
}
|
||||
function consumable(id: number) {
|
||||
return consumables.includes(id);
|
||||
}
|
||||
function provision(id: number) {
|
||||
return provisions.includes(id);
|
||||
}
|
||||
|
||||
const hasRammer = equipment(100);
|
||||
const hasImprovedVentilation = equipment(102);
|
||||
const hasCalibratedShells = equipment(103);
|
||||
const hasEnhancedGunLayingDrive = equipment(104);
|
||||
const hasSupercharger = equipment(107);
|
||||
const hasVerticalStabilizer = equipment(105);
|
||||
const hasRefinedGun = equipment(106);
|
||||
const hasImprovedAssembly = equipment(111);
|
||||
const hasImprovedOptics = equipment(114);
|
||||
const hasImprovedControl = equipment(116);
|
||||
const hasEngineAccelerator = equipment(117);
|
||||
const hasCamouflageNet = equipment(115);
|
||||
const hasImprovedVerticalStabilizer = equipment(122);
|
||||
const hasImprovedSuspension = equipment(123);
|
||||
|
||||
const hasTungsten = consumable(45);
|
||||
const hasAdrenaline = consumable(18);
|
||||
const hasShellReloadBoost = consumable(29);
|
||||
const hasEnginePowerBoost = consumable(14);
|
||||
const hasImprovedEnginePowerBoost = consumable(27);
|
||||
const hasReticleCalibration = consumable(28);
|
||||
|
||||
const hasStandardFuel = provision(18);
|
||||
const hasImprovedFuel = provision(19);
|
||||
const hasProtectiveKit = provision(22);
|
||||
const hasSandbagArmor = provision(28);
|
||||
const hasEnhancedSandbagArmor = provision(29);
|
||||
const hasImprovedGunPowder = provision(46);
|
||||
const hasGearOil = provision(44);
|
||||
const hasImprovedGearOil = provision(45);
|
||||
|
||||
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
|
||||
const provisionCrewBonus =
|
||||
provisions.reduce(
|
||||
(total, provision) =>
|
||||
provisionDefinitions[provision].crew
|
||||
? total + provisionDefinitions[provision].crew! / 100
|
||||
: total,
|
||||
0,
|
||||
) + (hasImprovedVentilation ? 0.05 : 0);
|
||||
const commanderMastery = crewMastery + provisionCrewBonus;
|
||||
const crewMemberMastery = commanderMastery * 1.1;
|
||||
|
||||
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
|
||||
const damageCoefficient =
|
||||
coefficient([hasTungsten, 0.15]) *
|
||||
coefficient(
|
||||
[applyReactive, -0.2],
|
||||
[applyDynamicArmor, -0.1],
|
||||
[applySpallLiner && shell.type === 'he', -0.2],
|
||||
);
|
||||
const reloadCoefficient =
|
||||
(coefficient([hasRammer, -0.07]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)])) /
|
||||
coefficient([hasAdrenaline && gun.type === 'regular', 0.2]);
|
||||
const penetrationCoefficient = coefficient([
|
||||
hasCalibratedShells,
|
||||
isExplosive(shell.type) ? 0.1 : 0.05,
|
||||
]);
|
||||
const healthCoefficient = coefficient(
|
||||
[hasSandbagArmor, 0.03],
|
||||
[hasEnhancedSandbagArmor, 0.06],
|
||||
[hasImprovedAssembly, 0.06],
|
||||
);
|
||||
const shellVelocityCoefficient = coefficient(
|
||||
[hasSupercharger, 0.3],
|
||||
[hasImprovedGunPowder, 0.3],
|
||||
);
|
||||
const penetrationLossOverDistanceCoefficient = coefficient([
|
||||
hasSupercharger,
|
||||
-0.5,
|
||||
]);
|
||||
const aimTimeCoefficient =
|
||||
coefficient(
|
||||
[hasEnhancedGunLayingDrive, -0.1],
|
||||
[hasReticleCalibration, -0.4],
|
||||
) * coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
const dispersionStillCoefficient =
|
||||
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
|
||||
coefficient([true, degressiveStat(crewMemberMastery)]);
|
||||
const dispersionDamagedCoefficient = coefficient([
|
||||
hasVerticalStabilizer,
|
||||
-0.15,
|
||||
]);
|
||||
const dispersionHullTraverseCoefficient = coefficient(
|
||||
[hasVerticalStabilizer, -0.15],
|
||||
[true, -crewSkills.smooth_turn / 100],
|
||||
);
|
||||
const dispersionAfterShotCoefficient = coefficient(
|
||||
[hasVerticalStabilizer, -0.15],
|
||||
[true, -crewSkills.soft_recoil / 100],
|
||||
);
|
||||
const dispersionMovingCoefficient = coefficient(
|
||||
[hasVerticalStabilizer, -0.15],
|
||||
[true, -crewSkills.smooth_driving / 100],
|
||||
);
|
||||
const dispersionTurretTraverseCoefficient = coefficient(
|
||||
[hasVerticalStabilizer, -0.15],
|
||||
[true, -crewSkills.smooth_turret * (2 / 100)],
|
||||
);
|
||||
const enginePowerCoefficient = coefficient(
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'lightTank' || tank.class === 'mediumTank'),
|
||||
0.05,
|
||||
],
|
||||
[
|
||||
hasEngineAccelerator &&
|
||||
(tank.class === 'heavyTank' || tank.class === 'AT-SPG'),
|
||||
0.07,
|
||||
],
|
||||
[hasEnginePowerBoost, 0.2],
|
||||
[hasImprovedEnginePowerBoost, 0.4],
|
||||
[hasStandardFuel, 0.03],
|
||||
[hasImprovedFuel, 0.1],
|
||||
[hasGearOil, 0.03],
|
||||
[hasImprovedGearOil, 0.06],
|
||||
);
|
||||
const turretTraverseCoefficient =
|
||||
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
|
||||
coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
const hullTraverseCoefficient =
|
||||
coefficient(
|
||||
[hasImprovedControl, 0.1],
|
||||
[hasImprovedEnginePowerBoost, 0.05],
|
||||
[true, crewSkills.virtuoso / 100],
|
||||
) * coefficient([true, progressiveStat(crewMemberMastery)]);
|
||||
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
|
||||
const viewRangeCoefficient =
|
||||
coefficient([
|
||||
hasImprovedOptics,
|
||||
tank.class === 'AT-SPG' ? 0.05 : tank.class === 'heavyTank' ? 0.07 : 0.1,
|
||||
]) * coefficient([true, progressiveStat(commanderMastery)]);
|
||||
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
|
||||
|
||||
const speedForwardsSum = sum(
|
||||
[hasImprovedEnginePowerBoost, 5],
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const speedBackwardsSum = sum(
|
||||
[hasImprovedEnginePowerBoost, 10],
|
||||
[hasGearOil, 2],
|
||||
[hasImprovedGearOil, 4],
|
||||
);
|
||||
const camouflageSumMoving = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
tank.class === 'heavyTank' ? 0.03 : tank.class === 'AT-SPG' ? 0.07 : 0.05,
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
);
|
||||
const camouflageSumStill = sum(
|
||||
[
|
||||
hasCamouflageNet,
|
||||
2 *
|
||||
(tank.class === 'heavyTank'
|
||||
? 0.03
|
||||
: tank.class === 'AT-SPG'
|
||||
? 0.07
|
||||
: 0.05),
|
||||
],
|
||||
[
|
||||
camouflage,
|
||||
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
|
||||
],
|
||||
[true, crewSkills.camouflage * (3 / 100)],
|
||||
);
|
||||
|
||||
const size = normalizeBoundingBox(
|
||||
unionBoundingBox(
|
||||
tankModelDefinition.boundingBox,
|
||||
turretModelDefinition.boundingBox,
|
||||
),
|
||||
);
|
||||
const weightKg =
|
||||
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
|
||||
const weightTons = weightKg / 1000;
|
||||
const stockWeight =
|
||||
tank.weight +
|
||||
stockEngine.weight +
|
||||
stockTrack.weight +
|
||||
stockTurret.weight +
|
||||
stockGun.weight;
|
||||
const resolvedEnginePower = engine.power * enginePowerCoefficient;
|
||||
const dpm = resolveDpm(
|
||||
gun,
|
||||
shell,
|
||||
damageCoefficient,
|
||||
reloadCoefficient,
|
||||
intraClipCoefficient,
|
||||
);
|
||||
const dpmMaximum =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload.at(-1)! * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
60
|
||||
: ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload[0] * reloadCoefficient)) *
|
||||
60
|
||||
: undefined;
|
||||
const dpmEffective =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload.at(-1)! * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
(60 -
|
||||
(gun.reload.slice(0, -1).length - 1) *
|
||||
gun.intraClip *
|
||||
intraClipCoefficient) +
|
||||
damageCoefficient *
|
||||
shell.damage.armor *
|
||||
gun.reload.slice(0, -1).length
|
||||
: ((damageCoefficient * shell.damage.armor) /
|
||||
(gun.reload[0] * reloadCoefficient +
|
||||
gun.intraClip * intraClipCoefficient)) *
|
||||
(60 -
|
||||
(gun.reload.slice(1).length - 1) *
|
||||
gun.intraClip *
|
||||
intraClipCoefficient) +
|
||||
damageCoefficient * shell.damage.armor * gun.reload.slice(1).length
|
||||
: undefined;
|
||||
const shells = gun.type === 'regular' ? 1 : gun.count;
|
||||
const mostOptimalShellIndex =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.at(-1)! < gun.reload.at(-2)!
|
||||
? gun.reload.length
|
||||
: 1
|
||||
: undefined;
|
||||
const shellReloads =
|
||||
gun.type === 'autoReloader'
|
||||
? gun.reload.map((reload) => reload * reloadCoefficient)
|
||||
: undefined;
|
||||
const shellReload =
|
||||
gun.type === 'autoReloader' ? undefined : gun.reload * reloadCoefficient;
|
||||
const intraClip =
|
||||
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
|
||||
const caliber = shell.caliber;
|
||||
const penetration =
|
||||
resolveNearPenetration(shell.penetration) * penetrationCoefficient;
|
||||
const damage = shell.damage.armor * damageCoefficient;
|
||||
const moduleDamage = shell.damage.module * damageCoefficient;
|
||||
const explosionRadius = shell.explosionRadius;
|
||||
const shellVelocity = shell.speed * shellVelocityCoefficient;
|
||||
const aimTime = gun.aimTime * aimTimeCoefficient;
|
||||
const dispersion = gun.dispersion.base * dispersionStillCoefficient;
|
||||
const dispersionMoving = track.dispersion.move * dispersionMovingCoefficient;
|
||||
const dispersionHullTraversing =
|
||||
track.dispersion.traverse * dispersionHullTraverseCoefficient;
|
||||
const dispersionTurretTraversing =
|
||||
gun.dispersion.traverse * dispersionTurretTraverseCoefficient;
|
||||
const dispersionShooting =
|
||||
gun.dispersion.shot * dispersionAfterShotCoefficient;
|
||||
const dispersionGunDamaged =
|
||||
gun.dispersion.damaged * dispersionDamagedCoefficient;
|
||||
const gunDepression =
|
||||
gunModelDefinition.pitch.max +
|
||||
gunDefaultPitch +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0);
|
||||
const gunElevation =
|
||||
-gunModelDefinition.pitch.min -
|
||||
gunDefaultPitch +
|
||||
(hasImprovedVerticalStabilizer ? 3 : 0);
|
||||
const gunFrontalDepression = gunModelDefinition.pitch.front?.max;
|
||||
const gunFrontalElevation = gunModelDefinition.pitch.front
|
||||
? -gunModelDefinition.pitch.front.min
|
||||
: undefined;
|
||||
const gunRearDepression = gunModelDefinition.pitch.back?.max;
|
||||
const gunRearElevation = gunModelDefinition.pitch.back
|
||||
? -gunModelDefinition.pitch.back.min
|
||||
: undefined;
|
||||
const azimuthLeft = turretModelDefinition.yaw
|
||||
? -turretModelDefinition.yaw.min
|
||||
: undefined;
|
||||
const azimuthRight = turretModelDefinition.yaw?.max;
|
||||
const speedForwards = tank.speed.forwards + speedForwardsSum;
|
||||
const speedBackwards = tank.speed.backwards + speedBackwardsSum;
|
||||
const enginePower = resolvedEnginePower;
|
||||
const powerToWeightRatioHardTerrain =
|
||||
resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.hard * resistanceCoefficient);
|
||||
const powerToWeightRatioMediumTerrain =
|
||||
resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.medium * resistanceCoefficient);
|
||||
const powerToWeightRatioSoftTerrain =
|
||||
resolvedEnginePower /
|
||||
weightTons /
|
||||
(track.resistance.soft * resistanceCoefficient);
|
||||
const weight = weightTons;
|
||||
const turretTraverseSpeed = turret.traverseSpeed * turretTraverseCoefficient;
|
||||
const hullTraverseHardTerrain =
|
||||
(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard / (track.resistance.hard * resistanceCoefficient)) *
|
||||
(stockWeight / weightKg);
|
||||
const hullTraverseMediumTerrain =
|
||||
(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard /
|
||||
(track.resistance.medium * resistanceCoefficient)) *
|
||||
(stockWeight / weightKg);
|
||||
const hullTraverseSoftTerrain =
|
||||
(resolvedEnginePower / stockEngine.power) *
|
||||
track.traverseSpeed *
|
||||
hullTraverseCoefficient *
|
||||
(track.resistance.hard / (track.resistance.soft * resistanceCoefficient)) *
|
||||
(stockWeight / weightKg);
|
||||
const health = (tank.health + turret.health) * healthCoefficient;
|
||||
const fireChance = engine.fireChance * fireChanceCoefficient;
|
||||
const viewRange = turret.viewRange * viewRangeCoefficient;
|
||||
const camouflageStill = tank.camouflage.still + camouflageSumStill;
|
||||
const camouflageMoving = tank.camouflage.moving + camouflageSumMoving;
|
||||
const camouflageShootingStill =
|
||||
tank.camouflage.still * gun.camouflageLoss + camouflageSumStill;
|
||||
const camouflageShootingMoving =
|
||||
tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving;
|
||||
const camouflageCaughtOnFire =
|
||||
tank.camouflage.onFire * tank.camouflage.still + camouflageSumStill;
|
||||
const width = size[2];
|
||||
const height = size[0];
|
||||
const length = size[1];
|
||||
const volume = width * height * length;
|
||||
|
||||
return {
|
||||
dpm,
|
||||
dpmMaximum,
|
||||
dpmEffective,
|
||||
shells,
|
||||
mostOptimalShellIndex,
|
||||
shellReloads,
|
||||
shellReload,
|
||||
intraClip,
|
||||
caliber,
|
||||
penetration,
|
||||
damage,
|
||||
moduleDamage,
|
||||
explosionRadius,
|
||||
shellVelocity,
|
||||
aimTime,
|
||||
dispersion,
|
||||
dispersionMoving,
|
||||
dispersionHullTraversing,
|
||||
dispersionTurretTraversing,
|
||||
dispersionShooting,
|
||||
dispersionGunDamaged,
|
||||
gunDepression,
|
||||
gunElevation,
|
||||
gunFrontalDepression,
|
||||
gunFrontalElevation,
|
||||
gunRearDepression,
|
||||
gunRearElevation,
|
||||
azimuthLeft,
|
||||
azimuthRight,
|
||||
speedForwards,
|
||||
speedBackwards,
|
||||
enginePower,
|
||||
powerToWeightRatioHardTerrain,
|
||||
powerToWeightRatioMediumTerrain,
|
||||
powerToWeightRatioSoftTerrain,
|
||||
weight,
|
||||
turretTraverseSpeed,
|
||||
hullTraverseHardTerrain,
|
||||
hullTraverseMediumTerrain,
|
||||
hullTraverseSoftTerrain,
|
||||
health,
|
||||
fireChance,
|
||||
viewRange,
|
||||
camouflageStill,
|
||||
camouflageMoving,
|
||||
camouflageShootingStill,
|
||||
camouflageShootingMoving,
|
||||
camouflageCaughtOnFire,
|
||||
width,
|
||||
height,
|
||||
length,
|
||||
volume,
|
||||
};
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export interface TankDefinition {
|
||||
export interface TrackDefinition {
|
||||
id: number;
|
||||
tier: Tier;
|
||||
name: string;
|
||||
weight: number;
|
||||
traverseSpeed: number;
|
||||
xp?: number;
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user