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 | ||
|
|
958f0f2a52 | ||
|
|
b34fef2147 | ||
|
|
fe661124fa | ||
|
|
457dcbcad5 | ||
|
|
c18ee70eba | ||
|
|
095ffc3d6a | ||
|
|
e8b91648b1 | ||
|
|
1b43f6ba99 | ||
|
|
fc5360363c | ||
|
|
47b3c3029e | ||
|
|
dea7f34ca3 | ||
|
|
1e5026ea4a | ||
|
|
98f568ff6a | ||
|
|
10a6f8371d | ||
|
|
0933aa6615 | ||
|
|
746592418d | ||
|
|
e2404b063c | ||
|
|
bae1301556 | ||
|
|
c2de4952ad | ||
|
|
cce61d8075 | ||
|
|
34a0388f45 | ||
|
|
9d62965fc3 | ||
|
|
892434e28f | ||
|
|
2597fe4bcd | ||
|
|
f85b4b36bd | ||
|
|
f777dafefc | ||
|
|
6ef65ccea3 | ||
|
|
974b27a313 | ||
|
|
5124d8abcd | ||
|
|
849bd356c0 | ||
|
|
6406486441 | ||
|
|
2993ada2e5 | ||
|
|
f083c18dd0 | ||
|
|
c7021c5689 | ||
|
|
815369805a | ||
|
|
d8feb5c3a8 | ||
|
|
cb101da8a8 | ||
|
|
71481f5971 | ||
|
|
817796ed3c | ||
|
|
ebb7618f2d | ||
|
|
9717f153cd | ||
|
|
afa0cfaeef | ||
|
|
298b89b227 | ||
|
|
3b94034767 | ||
|
|
444b4c21af | ||
|
|
3e9c828781 | ||
|
|
ca3a1fbe3f | ||
|
|
5f28b541e3 | ||
|
|
18edd349f2 | ||
|
|
f7c6a065c1 | ||
|
|
91d48eeadd | ||
|
|
21bafa1b7b | ||
|
|
38af2f2817 | ||
|
|
8faa1143e9 | ||
|
|
8f34ebbca4 | ||
|
|
1c03701e17 |
No files matched your search
@@ -2,6 +2,7 @@ name: CD Build Assets
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 2 * * *'
|
- cron: '0 2 * * *'
|
||||||
@@ -52,7 +53,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
||||||
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_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 }}"
|
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
|
||||||
yarn setup:patches --depot
|
yarn setup:patches --depot
|
||||||
|
|
||||||
@@ -60,5 +61,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
|
||||||
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_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' || '' }}
|
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
|
||||||
@@ -23,12 +23,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
||||||
- name: Install Yarn
|
|
||||||
run: npm install --location=global yarn
|
|
||||||
|
|
||||||
- name: Add fixed sharp
|
|
||||||
run: yarn add [email protected] --ignore-engines
|
|
||||||
|
|
||||||
- name: Download release assets
|
- name: Download release assets
|
||||||
run: |
|
run: |
|
||||||
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \
|
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \
|
||||||
@@ -39,4 +33,4 @@ jobs:
|
|||||||
unzip bot.zip
|
unzip bot.zip
|
||||||
|
|
||||||
- name: Run bot
|
- 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
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
name: CD Ratings Asia
|
name: CD Rating Asia
|
||||||
|
|
||||||
# latest: '0 0 * * *'
|
|
||||||
# asia midnight: '0 17 * * *'
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * *'
|
|
||||||
- cron: '0 17 * * *'
|
- cron: '0 17 * * *'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -35,4 +31,4 @@ jobs:
|
|||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
- name: Generate leaderboard
|
- name: Generate leaderboard
|
||||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
name: CD Ratings Europe
|
name: CD Rating Europe
|
||||||
|
|
||||||
# latest: '0 0 * * *'
|
|
||||||
# eu midnight: '0 23 * * *'
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * *'
|
|
||||||
- cron: '0 23 * * *'
|
- cron: '0 23 * * *'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -35,4 +31,4 @@ jobs:
|
|||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
- name: Generate leaderboard
|
- name: Generate leaderboard
|
||||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: CD Rating Latest
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
host:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Yarn
|
||||||
|
run: npm install --location=global yarn
|
||||||
|
|
||||||
|
- name: Use node_modules cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: '**/node_modules'
|
||||||
|
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn
|
||||||
|
|
||||||
|
- name: Generate all leaderboards
|
||||||
|
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
name: CD Ratings North America
|
name: CD Rating North America
|
||||||
|
|
||||||
# latest: '0 0 * * *'
|
|
||||||
# na midnight: '0 5 * * *'
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * *'
|
|
||||||
- cron: '0 5 * * *'
|
- cron: '0 5 * * *'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -35,4 +31,4 @@ jobs:
|
|||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
- name: Generate leaderboard
|
- name: Generate leaderboard
|
||||||
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }}
|
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,11 +33,11 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
- name: Add fixed sharp
|
- name: Build Prisma
|
||||||
run: yarn add [email protected] --ignore-engines
|
run: yarn prisma generate
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build:bot
|
run: yarn build:bot --production
|
||||||
|
|
||||||
- name: Archive build
|
- name: Archive build
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,6 @@ dist/
|
|||||||
dist.*
|
dist.*
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/test.*
|
/test.*
|
||||||
test.*
|
|
||||||
/docs/.vitepress/dist
|
/docs/.vitepress/dist
|
||||||
/docs/.vitepress/cache
|
/docs/.vitepress/cache
|
||||||
servers.csv
|
servers.csv
|
||||||
@@ -19,3 +18,4 @@ temp/
|
|||||||
/.VSCodeCounter
|
/.VSCodeCounter
|
||||||
/.yarn
|
/.yarn
|
||||||
depots
|
depots
|
||||||
|
public/sitemap*
|
||||||
Vendored
+8
-2
@@ -3,6 +3,7 @@
|
|||||||
"AABBOX",
|
"AABBOX",
|
||||||
"Abhi",
|
"Abhi",
|
||||||
"accountinfo",
|
"accountinfo",
|
||||||
|
"adsense",
|
||||||
"aeson",
|
"aeson",
|
||||||
"alphatest",
|
"alphatest",
|
||||||
"APAC",
|
"APAC",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"betakrieg",
|
"betakrieg",
|
||||||
"BINORMAL",
|
"BINORMAL",
|
||||||
"Binormals",
|
"Binormals",
|
||||||
|
"BKRL",
|
||||||
"bkwd",
|
"bkwd",
|
||||||
"blitzstars",
|
"blitzstars",
|
||||||
"Camo",
|
"Camo",
|
||||||
@@ -71,6 +73,7 @@
|
|||||||
"leaderboards",
|
"leaderboards",
|
||||||
"lerp",
|
"lerp",
|
||||||
"LINEARSIZE",
|
"LINEARSIZE",
|
||||||
|
"loadouts",
|
||||||
"loddist",
|
"loddist",
|
||||||
"lods",
|
"lods",
|
||||||
"metallicness",
|
"metallicness",
|
||||||
@@ -99,13 +102,13 @@
|
|||||||
"POSITIVEZ",
|
"POSITIVEZ",
|
||||||
"Promisable",
|
"Promisable",
|
||||||
"Pyogenics",
|
"Pyogenics",
|
||||||
"ratingsleaderboard",
|
"ratingleaderboard",
|
||||||
"ratingsplot",
|
|
||||||
"raycaster",
|
"raycaster",
|
||||||
"researchable",
|
"researchable",
|
||||||
"Resvg",
|
"Resvg",
|
||||||
"roboto",
|
"roboto",
|
||||||
"SCPG",
|
"SCPG",
|
||||||
|
"scroller",
|
||||||
"searchclans",
|
"searchclans",
|
||||||
"searchplayers",
|
"searchplayers",
|
||||||
"searchtanks",
|
"searchtanks",
|
||||||
@@ -125,6 +128,7 @@
|
|||||||
"Synx",
|
"Synx",
|
||||||
"tankaverages",
|
"tankaverages",
|
||||||
"Tankerch",
|
"Tankerch",
|
||||||
|
"tankmen",
|
||||||
"tankopedia",
|
"tankopedia",
|
||||||
"tankopedias",
|
"tankopedias",
|
||||||
"tankstats",
|
"tankstats",
|
||||||
@@ -143,8 +147,10 @@
|
|||||||
"Vovko",
|
"Vovko",
|
||||||
"wargaming",
|
"wargaming",
|
||||||
"webp",
|
"webp",
|
||||||
|
"whitespaces",
|
||||||
"winrate",
|
"winrate",
|
||||||
"wireframe",
|
"wireframe",
|
||||||
|
"Woah",
|
||||||
"WOTB",
|
"WOTB",
|
||||||
"wotblitz",
|
"wotblitz",
|
||||||
"xffn",
|
"xffn",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Blitzkrieg 1.18.0
|
||||||
|
|
||||||
|
Introducing the new `/rating` command!
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `/rating` command
|
||||||
|
- Features statistics on today's performance in ratings
|
||||||
|
- The included stats are battles, winrate, damage, survival, and kills
|
||||||
|
- This command also features the score, rank, and league you are at plus the reward you'll get if you are high enough on the leaderboard
|
||||||
|
- `/ratings`, the old leaderboard command, has been renamed `/rating-leaderboard`
|
||||||
|
- Reward icons have been removed from the leaderboard
|
||||||
|
|
||||||
|
## Technical Changes
|
||||||
|
|
||||||
|
- Rating leaderboards are now built in a more action-efficient manner.
|
||||||
|
- This version also introduces the BKRL (Blitzkrieg rating leaderboard) file format which is an efficient way to store entire leaderboards of regions to disk (1 MB per 40,000 users).
|
||||||
|
- Latest archival actions now build all regions at once in a single action.
|
||||||
|
- The sharp module is now included in the build.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Blitzkrieg 1.18.1
|
||||||
|
|
||||||
|
HE gets more accurate and say goodbye to NaNaNaNaNaN.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Armor highlighting now works on Apple MacBooks
|
||||||
|
- 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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Blitzkrieg Rating Leaderboard
|
||||||
|
|
||||||
|
A straightforward way of storing the entire World of Tanks Blitz rating leaderboard to disk.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
primary BlitzkriegRatingLeaderboard {
|
||||||
|
utf8"BKRL" magic;
|
||||||
|
Header header;
|
||||||
|
primary Body<header> body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
uint16 version; // always 1
|
||||||
|
Format format;
|
||||||
|
uint32 count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Format enum<uint8> {
|
||||||
|
Base,
|
||||||
|
Superset1,
|
||||||
|
}
|
||||||
|
|
||||||
|
Body<Header header> match<header.format> {
|
||||||
|
Base {
|
||||||
|
primary {
|
||||||
|
uint32 id;
|
||||||
|
uint16 score;
|
||||||
|
}[header.count] entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
Superset1 {
|
||||||
|
primary {
|
||||||
|
uint32 id;
|
||||||
|
uint16 score;
|
||||||
|
|
||||||
|
uint32 battles;
|
||||||
|
uint32 wins;
|
||||||
|
uint32 survived;
|
||||||
|
|
||||||
|
uint32 damage;
|
||||||
|
uint32 kills;
|
||||||
|
}[header.count] entries;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
# Cedilla Object Notation
|
# Cedilla Object Notation
|
||||||
|
|
||||||
This documentation uses an experimental binary notation syntax called "Buffer Up".
|
|
||||||
|
|
||||||
1. JSON compressed as CDON, written directly to disk (a direct 50% reduction).
|
1. JSON compressed as CDON, written directly to disk (a direct 50% reduction).
|
||||||
2. CDON further compressed with LZ4 (a clean 90% reduction).
|
2. CDON further compressed with LZ4 (a clean 90% reduction).
|
||||||
3. The raw JSON file as fetched from a random data source.
|
3. The raw JSON file as fetched from a random data source.
|
||||||
@@ -19,7 +17,7 @@ primary CedillaObjectNotation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Header {
|
Header {
|
||||||
uint16 version;
|
uint16 version; // always 1
|
||||||
FSFormat fsFormat;
|
FSFormat fsFormat;
|
||||||
StringTable<fsFormat> stringTable;
|
StringTable<fsFormat> stringTable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ Blitzkrieg is available as a Discord bot and can be [installed very easily](/gui
|
|||||||
Provides a bunch of very helpful tools; https://blitz-krieg.vercel.app/
|
Provides a bunch of very helpful tools; https://blitz-krieg.vercel.app/
|
||||||
|
|
||||||
- Session tracker
|
- Session tracker
|
||||||
- Ratings leaderboard
|
- Rating leaderboard
|
||||||
- Way more to come
|
- Way more to come
|
||||||
+10
-10
@@ -1,24 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "blitzkrieg",
|
"name": "blitzkrieg",
|
||||||
"version": "1.17.1",
|
"version": "1.19.0",
|
||||||
"description": "🎉 Tools for everything World of Tanks Blitz",
|
"description": "🎉 Tools for everything World of Tanks Blitz",
|
||||||
"main": "src/server.ts",
|
"main": "src/server.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:bot": "vite-node --script scripts/build",
|
"build:db": "prisma generate",
|
||||||
"build:website": "next build",
|
"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:docs": "vitepress build docs",
|
||||||
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard",
|
"build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
|
||||||
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
|
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
|
||||||
"build:test": "vite-node --script scripts/buildTest",
|
"build:test": "vite-node --script scripts/buildTest",
|
||||||
"build:ratingsplot": "vite-node --script scripts/buildRatingsPlot",
|
"dev:bot": "vite-node --script scripts/build && yarn start:bot",
|
||||||
"dev:bot": "yarn build:bot --dev && yarn start:bot",
|
|
||||||
"dev:docs": "vitepress dev docs",
|
"dev:docs": "vitepress dev docs",
|
||||||
"dev:website": "next dev",
|
"dev:website": "next dev",
|
||||||
"start:bot": "node --enable-source-maps dist/bot/index.cjs",
|
"start:bot": "node --enable-source-maps dist/bot/index.cjs",
|
||||||
"start:website": "next start",
|
"start:website": "next start",
|
||||||
"host:docs": "vitepress preview docs",
|
"host:docs": "vitepress preview docs",
|
||||||
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish",
|
"publish:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard --publish",
|
||||||
"setup:patches": "vite-node --script scripts/applyPatches",
|
"setup:patches": "vite-node --script scripts/applyPatches",
|
||||||
"lint": "npx tsc",
|
"lint": "npx tsc",
|
||||||
"lint:website": "next lint",
|
"lint:website": "next lint",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
"@prisma/client": "^5.4.1",
|
"@prisma/client": "^5.4.1",
|
||||||
"@radix-ui/colors": "^3.0.0",
|
"@radix-ui/colors": "^3.0.0",
|
||||||
"@radix-ui/react-icons": "^1.3.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/drei": "^9.93.0",
|
||||||
"@react-three/fiber": "^8.15.14",
|
"@react-three/fiber": "^8.15.14",
|
||||||
"@resvg/resvg-js": "^2.4.1",
|
"@resvg/resvg-js": "^2.4.1",
|
||||||
@@ -64,8 +65,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-fuzzysort": "^1.0.1",
|
"react-fuzzysort": "^1.0.1",
|
||||||
"react-promise-suspense": "^0.3.4",
|
"react-promise-suspense": "^0.3.4",
|
||||||
"react-spinners": "^0.13.8",
|
"satori": "^0.10.13",
|
||||||
"satori": "^0.10.8",
|
|
||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"three": "0.159.0",
|
"three": "0.159.0",
|
||||||
"three-stdlib": "^2.29.4",
|
"three-stdlib": "^2.29.4",
|
||||||
|
|||||||
@@ -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 {
|
datasource db {
|
||||||
provider = "cockroachdb"
|
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
|
discord BigInt @id
|
||||||
region String
|
|
||||||
blitz Int
|
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 |
File renamed without changes.
File renamed without changes.
@@ -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
|
||||||
+17
-4
@@ -1,10 +1,13 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
import { build } from 'esbuild';
|
import { build } from 'esbuild';
|
||||||
import { cp, readdir } from 'fs/promises';
|
import { cp, readdir } from 'fs/promises';
|
||||||
import { argv } from 'process';
|
import { argv } from 'process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
const isDev = argv.includes('--dev');
|
const execPromise = promisify(exec);
|
||||||
|
const isProduction = argv.includes('--production');
|
||||||
|
|
||||||
readdir('src/workers').then((workers) => {
|
await readdir('src/workers').then((workers) => {
|
||||||
const files = [
|
const files = [
|
||||||
'bot',
|
'bot',
|
||||||
'index',
|
'index',
|
||||||
@@ -40,9 +43,19 @@ readdir('src/workers').then((workers) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// copy schema.prisma
|
// copy schema.prisma
|
||||||
cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
|
await cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
|
||||||
readdir('node_modules/prisma').then((files) =>
|
await readdir('node_modules/prisma').then((files) =>
|
||||||
files
|
files
|
||||||
.filter((file) => file.endsWith('.node'))
|
.filter((file) => file.endsWith('.node'))
|
||||||
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
|
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isProduction) {
|
||||||
|
// install fixed sharp
|
||||||
|
console.log('Installing fixed sharp...');
|
||||||
|
await execPromise('yarn init -y && yarn add [email protected] --ignore-engines', {
|
||||||
|
cwd: 'dist/bot',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generated:', await readdir('dist/bot'));
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { readdir, writeFile } from 'fs/promises';
|
import { readdir } from 'fs/promises';
|
||||||
import { parse as parsePath } from 'path';
|
import { parse as parsePath } from 'path';
|
||||||
import { Vector3Tuple } from 'three';
|
import { Vector3Tuple } from 'three';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { TankClass } from '../../src/components/Tanks';
|
import { TankClass } from '../../src/components/Tanks';
|
||||||
|
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
|
||||||
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||||
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
|
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
|
||||||
@@ -16,11 +17,13 @@ import {
|
|||||||
EquipmentPreset,
|
EquipmentPreset,
|
||||||
EquipmentRow,
|
EquipmentRow,
|
||||||
} from '../../src/core/blitzkrieg/equipmentDefinitions';
|
} from '../../src/core/blitzkrieg/equipmentDefinitions';
|
||||||
|
import { GameDefinitions } from '../../src/core/blitzkrieg/gameDefinitions';
|
||||||
import {
|
import {
|
||||||
ModelArmor,
|
ModelArmor,
|
||||||
ModelDefinitions,
|
ModelDefinitions,
|
||||||
} from '../../src/core/blitzkrieg/modelDefinitions';
|
} from '../../src/core/blitzkrieg/modelDefinitions';
|
||||||
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
|
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
|
||||||
|
import { SkillDefinitions } from '../../src/core/blitzkrieg/skillDefinitions';
|
||||||
import { superCompress } from '../../src/core/blitzkrieg/superCompress';
|
import { superCompress } from '../../src/core/blitzkrieg/superCompress';
|
||||||
import {
|
import {
|
||||||
Crew,
|
Crew,
|
||||||
@@ -32,6 +35,7 @@ import {
|
|||||||
Tier,
|
Tier,
|
||||||
} from '../../src/core/blitzkrieg/tankDefinitions';
|
} from '../../src/core/blitzkrieg/tankDefinitions';
|
||||||
import { DATA, POI } from './constants';
|
import { DATA, POI } from './constants';
|
||||||
|
import { Avatar } from './skillIcons';
|
||||||
import { TankParameters } from './tankIcons';
|
import { TankParameters } from './tankIcons';
|
||||||
|
|
||||||
interface Strings {
|
interface Strings {
|
||||||
@@ -116,6 +120,7 @@ interface VehicleDefinitions {
|
|||||||
weight: number;
|
weight: number;
|
||||||
terrainResistance: string;
|
terrainResistance: string;
|
||||||
rotationSpeed: number;
|
rotationSpeed: number;
|
||||||
|
userString: string;
|
||||||
shotDispersionFactors: {
|
shotDispersionFactors: {
|
||||||
vehicleMovement: number;
|
vehicleMovement: number;
|
||||||
vehicleRotation: number;
|
vehicleRotation: number;
|
||||||
@@ -329,10 +334,18 @@ const blitzShellKindToBLitzkrieg: Record<ShellKind, ShellType> = {
|
|||||||
const missingStrings: Record<string, string> = {
|
const missingStrings: Record<string, string> = {
|
||||||
'#artefacts:tungstentip/name': 'Tungsten Shells',
|
'#artefacts:tungstentip/name': 'Tungsten Shells',
|
||||||
};
|
};
|
||||||
|
const botPattern = /^.+((tutorial_bot(\d+)?)|(TU(R?)))$/;
|
||||||
|
|
||||||
export async function definitions(production: boolean) {
|
export async function definitions(production: boolean) {
|
||||||
console.log('Building definitions...');
|
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 tankDefinitions: TankDefinitions = {};
|
||||||
const modelDefinitions: ModelDefinitions = {};
|
const modelDefinitions: ModelDefinitions = {};
|
||||||
const equipmentDefinitions: EquipmentDefinitions = {
|
const equipmentDefinitions: EquipmentDefinitions = {
|
||||||
@@ -341,6 +354,9 @@ export async function definitions(production: boolean) {
|
|||||||
};
|
};
|
||||||
const consumableDefinitions: ConsumableDefinitions = {};
|
const consumableDefinitions: ConsumableDefinitions = {};
|
||||||
const provisionDefinitions: ProvisionDefinitions = {};
|
const provisionDefinitions: ProvisionDefinitions = {};
|
||||||
|
const skillDefinitions: SkillDefinitions = {
|
||||||
|
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
|
||||||
|
};
|
||||||
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
|
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
|
||||||
(nations) => nations.filter((nation) => nation !== 'common'),
|
(nations) => nations.filter((nation) => nation !== 'common'),
|
||||||
);
|
);
|
||||||
@@ -367,6 +383,9 @@ export async function definitions(production: boolean) {
|
|||||||
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
|
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
|
||||||
`${DATA}/${POI.provisionsCommon}.dvpl`,
|
`${DATA}/${POI.provisionsCommon}.dvpl`,
|
||||||
);
|
);
|
||||||
|
const avatar = await readXMLDVPL<{ root: Avatar }>(
|
||||||
|
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
|
||||||
|
);
|
||||||
|
|
||||||
const tankXps = new Map<number, number>();
|
const tankXps = new Map<number, number>();
|
||||||
|
|
||||||
@@ -402,6 +421,8 @@ export async function definitions(production: boolean) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const tankKey in tankList.root) {
|
for (const tankKey in tankList.root) {
|
||||||
|
if (botPattern.test(tankKey)) continue;
|
||||||
|
|
||||||
const gunXps = new Map<number, number>();
|
const gunXps = new Map<number, number>();
|
||||||
const turretXps = new Map<number, number>();
|
const turretXps = new Map<number, number>();
|
||||||
const engineXps = new Map<number, number>();
|
const engineXps = new Map<number, number>();
|
||||||
@@ -558,6 +579,7 @@ export async function definitions(production: boolean) {
|
|||||||
tankDefinitions[tankId].tracks.push({
|
tankDefinitions[tankId].tracks.push({
|
||||||
id: trackId,
|
id: trackId,
|
||||||
weight: track.weight,
|
weight: track.weight,
|
||||||
|
name: strings[track.userString],
|
||||||
traverseSpeed: track.rotationSpeed,
|
traverseSpeed: track.rotationSpeed,
|
||||||
dispersion: {
|
dispersion: {
|
||||||
move: track.shotDispersionFactors.vehicleMovement,
|
move: track.shotDispersionFactors.vehicleMovement,
|
||||||
@@ -1084,13 +1106,18 @@ export async function definitions(production: boolean) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
writeFile(
|
Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
|
||||||
'test.tanks.cdon.lz4',
|
skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
|
||||||
Buffer.from(superCompress(tankDefinitions), 'base64'),
|
});
|
||||||
);
|
|
||||||
await commitAssets(
|
await commitAssets(
|
||||||
'definitions',
|
'definitions',
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
content: superCompress(gameDefinitions),
|
||||||
|
encoding: 'base64',
|
||||||
|
path: 'definitions/game.cdon.lz4',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: superCompress(tankDefinitions),
|
content: superCompress(tankDefinitions),
|
||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
@@ -1116,6 +1143,11 @@ export async function definitions(production: boolean) {
|
|||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
path: 'definitions/provisions.cdon.lz4',
|
path: 'definitions/provisions.cdon.lz4',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: superCompress(skillDefinitions),
|
||||||
|
encoding: 'base64',
|
||||||
|
path: 'definitions/skills.cdon.lz4',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
production,
|
production,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { equipmentIcons } from './equipmentIcons';
|
|||||||
import { moduleIcons } from './moduleIcons';
|
import { moduleIcons } from './moduleIcons';
|
||||||
import { scratchedFlags } from './scratchedFlags';
|
import { scratchedFlags } from './scratchedFlags';
|
||||||
import { shellIcons } from './shellIcons';
|
import { shellIcons } from './shellIcons';
|
||||||
|
import { skillIcons } from './skillIcons';
|
||||||
import { tankArmors } from './tankArmors';
|
import { tankArmors } from './tankArmors';
|
||||||
import { tankIcons } from './tankIcons';
|
import { tankIcons } from './tankIcons';
|
||||||
import { tankModels } from './tankModels';
|
import { tankModels } from './tankModels';
|
||||||
@@ -28,8 +29,9 @@ if (!targets && !allTargets) throw new Error('No target(s) specified');
|
|||||||
|
|
||||||
const methods = [
|
const methods = [
|
||||||
definitions,
|
definitions,
|
||||||
tankIcons,
|
|
||||||
scratchedFlags,
|
scratchedFlags,
|
||||||
|
currencies,
|
||||||
|
skillIcons,
|
||||||
circleFlags,
|
circleFlags,
|
||||||
shellIcons,
|
shellIcons,
|
||||||
moduleIcons,
|
moduleIcons,
|
||||||
@@ -37,9 +39,9 @@ const methods = [
|
|||||||
consumableProvisionIcons,
|
consumableProvisionIcons,
|
||||||
camouflageIcons,
|
camouflageIcons,
|
||||||
boosterIcons,
|
boosterIcons,
|
||||||
|
tankIcons,
|
||||||
tankArmors,
|
tankArmors,
|
||||||
tankModels,
|
tankModels,
|
||||||
currencies,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const method of methods) {
|
for (const method of methods) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readdir } from 'fs/promises';
|
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 { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||||
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
|
||||||
import { DATA, POI } from './constants';
|
import { DATA, POI } from './constants';
|
||||||
@@ -16,9 +17,11 @@ export async function moduleIcons(production: boolean) {
|
|||||||
.map(async (file) => {
|
.map(async (file) => {
|
||||||
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
|
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
|
||||||
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
|
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
|
||||||
const content = await readBase64DVPL(
|
const content = (
|
||||||
`${DATA}/${POI.moduleIcons}/${file}`,
|
await sharp(await readDVPLFile(`${DATA}/${POI.moduleIcons}/${file}`))
|
||||||
);
|
.trim()
|
||||||
|
.toBuffer()
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content,
|
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 { readdir } from 'fs/promises';
|
||||||
|
import sharp from 'sharp';
|
||||||
import { Vector3Tuple } from 'three';
|
import { Vector3Tuple } from 'three';
|
||||||
import { NATION_IDS } from '../../src/constants/nations';
|
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 { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
|
||||||
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
|
||||||
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
|
||||||
@@ -50,23 +51,29 @@ export async function tankIcons(production: boolean) {
|
|||||||
const parameters = await readYAMLDVPL<TankParameters>(
|
const parameters = await readYAMLDVPL<TankParameters>(
|
||||||
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
|
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
|
||||||
);
|
);
|
||||||
const small = `${DATA}/${parameters.resourcesPath.smallIconPath
|
const smallPath = `${DATA}/${parameters.resourcesPath.smallIconPath
|
||||||
.replace(/~res:\//, '')
|
.replace(/~res:\//, '')
|
||||||
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
||||||
const big = `${DATA}/${parameters.resourcesPath.bigIconPath
|
const bigPath = `${DATA}/${parameters.resourcesPath.bigIconPath
|
||||||
.replace(/~res:\//, '')
|
.replace(/~res:\//, '')
|
||||||
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
.replace(/\..+/, '')}.packed.webp.dvpl`;
|
||||||
|
const big = await sharp(await readDVPLFile(bigPath))
|
||||||
|
.trim()
|
||||||
|
.toBuffer();
|
||||||
|
const small = await sharp(await readDVPLFile(smallPath))
|
||||||
|
.trim()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
if (big) {
|
if (big) {
|
||||||
changes.push({
|
changes.push({
|
||||||
content: await readBase64DVPL(big),
|
content: big.toString('base64'),
|
||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
path: `icons/tanks/big/${id}.webp`,
|
path: `icons/tanks/big/${id}.webp`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (small) {
|
if (small) {
|
||||||
changes.push({
|
changes.push({
|
||||||
content: await readBase64DVPL(small),
|
content: small.toString('base64'),
|
||||||
encoding: 'base64',
|
encoding: 'base64',
|
||||||
path: `icons/tanks/small/${id}.webp`,
|
path: `icons/tanks/small/${id}.webp`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { chunk } from 'lodash';
|
||||||
|
import { argv } from 'process';
|
||||||
|
import {
|
||||||
|
RatingInfo,
|
||||||
|
RatingNeighbors,
|
||||||
|
RatingPlayer,
|
||||||
|
} from '../src/commands/ratingLeaderboard';
|
||||||
|
import { RegionSubdomain } from '../src/constants/regions';
|
||||||
|
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
|
||||||
|
import regionSubdomainToRegion from '../src/core/blitz/regionSubdomainToRegion';
|
||||||
|
import { commitAssets } from '../src/core/blitzkrieg/commitAssets';
|
||||||
|
import { FileChange } from '../src/core/blitzkrieg/commitMultipleFiles.js';
|
||||||
|
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
|
||||||
|
import { superCompress } from '../src/core/blitzkrieg/superCompress';
|
||||||
|
import {
|
||||||
|
BkrlBaseEntry,
|
||||||
|
BkrlFormat,
|
||||||
|
BkrlSuperset1Entry,
|
||||||
|
BkrlWriteStream,
|
||||||
|
} from '../src/core/streams/bkrl';
|
||||||
|
import { CdonValue, CdonWriteStream } from '../src/core/streams/cdon';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Central North American Time (UTC-5): use 0 5 * * *.
|
||||||
|
* Central European Time (UTC+1): use 0 23 * * *.
|
||||||
|
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const production = argv.includes('--production');
|
||||||
|
const target = argv
|
||||||
|
.find((arg) => arg.startsWith('--target='))
|
||||||
|
?.split('=')[1] as 'midnight' | 'latest' | undefined;
|
||||||
|
const regionSubdomains = argv
|
||||||
|
.find((arg) => arg.startsWith('--region='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (!target) throw new Error('Target parameter not specified');
|
||||||
|
if (!regionSubdomains) throw new Error('Region parameter not specified');
|
||||||
|
|
||||||
|
regionSubdomains.split(',').forEach(async (regionSubdomain) => {
|
||||||
|
const region = regionSubdomainToRegion(regionSubdomain as RegionSubdomain);
|
||||||
|
|
||||||
|
console.log(`Building ${target} rating leaderboard for ${region}`);
|
||||||
|
|
||||||
|
const NEIGHBORS = 2 ** 8;
|
||||||
|
const PLAYERS = await patientFetchJSON<RatingInfo>(
|
||||||
|
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
|
||||||
|
).then((data) => (data.detail === undefined ? data.count : undefined));
|
||||||
|
|
||||||
|
if (PLAYERS === undefined) {
|
||||||
|
throw new Error('No current rating season running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const players: Record<number, RatingPlayer> = {};
|
||||||
|
const leaderboard: BkrlBaseEntry[] = [];
|
||||||
|
let registered = 0;
|
||||||
|
|
||||||
|
async function branchFromPlayer(id: number) {
|
||||||
|
const { neighbors } = await patientFetchJSON<RatingNeighbors>(
|
||||||
|
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPlayer = neighbors[0];
|
||||||
|
const lastPlayer = neighbors[neighbors.length - 1];
|
||||||
|
|
||||||
|
const branches: Promise<void>[] = [];
|
||||||
|
if (!players[firstPlayer.number])
|
||||||
|
branches.push(branchFromPlayer(firstPlayer.spa_id));
|
||||||
|
if (!players[lastPlayer.number])
|
||||||
|
branches.push(branchFromPlayer(lastPlayer.spa_id));
|
||||||
|
|
||||||
|
neighbors.forEach((neighbor) => {
|
||||||
|
if (players[neighbor.number]) return;
|
||||||
|
|
||||||
|
players[neighbor.number] = neighbor;
|
||||||
|
registered++;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Registered ${registered} / ${PLAYERS} (${(
|
||||||
|
(registered / PLAYERS!) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}%)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(branches);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function branchFromLeague(id: number) {
|
||||||
|
const { result } = await patientFetchJSON<{ result: RatingPlayer[] }>(
|
||||||
|
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPlayer = result[0];
|
||||||
|
const lastPlayer = result.at(-1)!;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
branchFromPlayer(firstPlayer.spa_id),
|
||||||
|
branchFromPlayer(lastPlayer.spa_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Branching from leagues...');
|
||||||
|
await Promise.all([
|
||||||
|
branchFromLeague(0),
|
||||||
|
branchFromLeague(1),
|
||||||
|
branchFromLeague(2),
|
||||||
|
branchFromLeague(3),
|
||||||
|
branchFromLeague(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('Converting to an array...');
|
||||||
|
for (let index = 0; index < PLAYERS; index++) {
|
||||||
|
const listing = players[index + 1];
|
||||||
|
if (listing) {
|
||||||
|
leaderboard[index] = { id: listing.spa_id, score: listing.score };
|
||||||
|
} else {
|
||||||
|
console.warn(`Position ${index + 1} had no player`);
|
||||||
|
leaderboard[index] = { id: 0, score: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Requesting further stats...');
|
||||||
|
const comprehensiveLeaderboard: BkrlSuperset1Entry[] = [];
|
||||||
|
let chunkIndex = 0;
|
||||||
|
for (const leaderboardChunk of chunk(leaderboard, 100)) {
|
||||||
|
console.log(
|
||||||
|
`Chunk ${chunkIndex + 1} of ${Math.ceil(leaderboard.length / 100)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = await getAccountInfo(
|
||||||
|
region,
|
||||||
|
leaderboardChunk.map(({ id }) => id),
|
||||||
|
['statistics.rating'],
|
||||||
|
);
|
||||||
|
|
||||||
|
stats.forEach((stat, index) => {
|
||||||
|
const trueIndex = chunkIndex * 100 + index;
|
||||||
|
comprehensiveLeaderboard[trueIndex] =
|
||||||
|
leaderboardChunk[index].id === 0 ||
|
||||||
|
stat?.statistics.rating === undefined
|
||||||
|
? {
|
||||||
|
battles: 0,
|
||||||
|
damage: 0,
|
||||||
|
id: 0,
|
||||||
|
kills: 0,
|
||||||
|
score: 0,
|
||||||
|
survived: 0,
|
||||||
|
wins: 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: leaderboardChunk[index].id,
|
||||||
|
score: leaderboardChunk[index].score,
|
||||||
|
battles: stat.statistics.rating.battles,
|
||||||
|
damage: stat.statistics.rating.damage_dealt,
|
||||||
|
kills: stat.statistics.rating.frags,
|
||||||
|
survived: stat.statistics.rating.survived_battles,
|
||||||
|
wins: stat.statistics.rating.wins,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await patientFetchJSON<RatingInfo & { detail: undefined }>(
|
||||||
|
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
|
||||||
|
);
|
||||||
|
const normalizedServer = regionSubdomain === 'na' ? 'com' : regionSubdomain;
|
||||||
|
const infoContent = new CdonWriteStream().cdon(
|
||||||
|
info as unknown as CdonValue,
|
||||||
|
).uint8Array;
|
||||||
|
const leaderboardWriteStream = new BkrlWriteStream();
|
||||||
|
|
||||||
|
leaderboardWriteStream.bkrl({
|
||||||
|
format: BkrlFormat.Superset1,
|
||||||
|
entries: comprehensiveLeaderboard,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changes: FileChange[] = [
|
||||||
|
{
|
||||||
|
path: `regions/${normalizedServer}/rating/${info.current_season}/info.cdon.lz4`,
|
||||||
|
content: superCompress(infoContent),
|
||||||
|
encoding: 'base64',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `regions/${normalizedServer}/rating/${info.current_season}/${target}.bkrl`,
|
||||||
|
content: Buffer.from(leaderboardWriteStream.uint8Array).toString(
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
encoding: 'base64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
commitAssets(
|
||||||
|
`rating leaderboard ${regionSubdomain} ${target}`,
|
||||||
|
changes,
|
||||||
|
production,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { rm, writeFile } from 'fs/promises';
|
|
||||||
import { argv } from 'process';
|
|
||||||
import {
|
|
||||||
RatingsInfo,
|
|
||||||
RatingsNeighbors,
|
|
||||||
RatingsPlayer,
|
|
||||||
} from '../src/commands/ratings';
|
|
||||||
import commitMultipleFiles from '../src/core/blitzkrieg/commitMultipleFiles.js';
|
|
||||||
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Central North American Time (UTC-5): use 0 5 * * *.
|
|
||||||
* Central European Time (UTC+1): use 0 23 * * *.
|
|
||||||
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
|
|
||||||
*
|
|
||||||
* [region]/ratings/[season]/info.json: season info like rewards, start & end time, etc.
|
|
||||||
* [region]/ratings/[season]/latest.json: the absolute latest leaderboard recorded at UTC+0 midnight
|
|
||||||
* [region]/ratings/[season]/midnight.json: last midnight's leaderboard relative to the region
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type BlitzkriegRatingsLeaderboardEntry = { id: number; score: number };
|
|
||||||
export type BlitzkriegRatingsLeaderboard = BlitzkriegRatingsLeaderboardEntry[];
|
|
||||||
|
|
||||||
const publish = argv.includes('--publish');
|
|
||||||
const target = argv
|
|
||||||
.find((arg) => arg.startsWith('--target='))
|
|
||||||
?.split('=')[1] as 'midnight' | 'latest' | undefined;
|
|
||||||
const server = argv.find((arg) => arg.startsWith('--region='))?.split('=')[1];
|
|
||||||
|
|
||||||
if (!target) throw new Error('Target parameter not specified');
|
|
||||||
if (!server) throw new Error('Region parameter not specified');
|
|
||||||
|
|
||||||
const NEIGHBORS = 2 ** 8;
|
|
||||||
const PLAYERS = await patientFetchJSON<RatingsInfo>(
|
|
||||||
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
|
|
||||||
).then((data) => (data.detail === undefined ? data.count : undefined));
|
|
||||||
|
|
||||||
if (PLAYERS === undefined) {
|
|
||||||
throw new Error('No current ratings season running');
|
|
||||||
}
|
|
||||||
|
|
||||||
const players: Record<number, RatingsPlayer> = {};
|
|
||||||
const leaderboard: BlitzkriegRatingsLeaderboard = [];
|
|
||||||
let registered = 0;
|
|
||||||
|
|
||||||
async function branchFromPlayer(id: number) {
|
|
||||||
const { neighbors } = await patientFetchJSON<RatingsNeighbors>(
|
|
||||||
`https://${server}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstPlayer = neighbors[0];
|
|
||||||
const lastPlayer = neighbors[neighbors.length - 1];
|
|
||||||
|
|
||||||
const branches: Promise<void>[] = [];
|
|
||||||
if (!players[firstPlayer.number])
|
|
||||||
branches.push(branchFromPlayer(firstPlayer.spa_id));
|
|
||||||
if (!players[lastPlayer.number])
|
|
||||||
branches.push(branchFromPlayer(lastPlayer.spa_id));
|
|
||||||
|
|
||||||
neighbors.forEach((neighbor) => {
|
|
||||||
if (players[neighbor.number]) return;
|
|
||||||
|
|
||||||
players[neighbor.number] = neighbor;
|
|
||||||
registered++;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Registered ${registered} / ${PLAYERS} (${(
|
|
||||||
(registered / PLAYERS!) *
|
|
||||||
100
|
|
||||||
).toFixed(2)}%)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(branches);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function branchFromLeague(id: number) {
|
|
||||||
const { result } = await patientFetchJSON<{ result: RatingsPlayer[] }>(
|
|
||||||
`https://${server}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstPlayer = result[0];
|
|
||||||
const lastPlayer = result[result.length - 1];
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
branchFromPlayer(firstPlayer.spa_id),
|
|
||||||
branchFromPlayer(lastPlayer.spa_id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Branching from leagues...');
|
|
||||||
await Promise.all([
|
|
||||||
branchFromLeague(0),
|
|
||||||
branchFromLeague(1),
|
|
||||||
branchFromLeague(2),
|
|
||||||
branchFromLeague(3),
|
|
||||||
branchFromLeague(4),
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log('Converting to an array...');
|
|
||||||
for (let index = 0; index < PLAYERS; index++) {
|
|
||||||
const listing = players[index + 1];
|
|
||||||
if (!listing) {
|
|
||||||
console.warn(`Position ${index + 1} had no player`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
leaderboard[index] = {
|
|
||||||
id: listing.spa_id,
|
|
||||||
score: listing.score,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const leaderboardJSON = JSON.stringify(leaderboard);
|
|
||||||
|
|
||||||
if (publish) {
|
|
||||||
const info = await patientFetchJSON<RatingsInfo & { detail: undefined }>(
|
|
||||||
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
|
|
||||||
);
|
|
||||||
const normalizedServer = server === 'na' ? 'com' : server;
|
|
||||||
const infoPath = `regions/${normalizedServer}/ratings/${info.current_season}/info.json`;
|
|
||||||
const leaderboardPath = `regions/${normalizedServer}/ratings/${info.current_season}/${target}.json`;
|
|
||||||
const infoJSON = JSON.stringify(info);
|
|
||||||
|
|
||||||
console.log(`Publishing to season ${info.current_season}...`);
|
|
||||||
commitMultipleFiles(
|
|
||||||
'tresabhi',
|
|
||||||
'blitzkrieg-assets',
|
|
||||||
'main',
|
|
||||||
new Date().toString(),
|
|
||||||
[
|
|
||||||
{ path: infoPath, content: infoJSON, encoding: 'utf-8' },
|
|
||||||
{ path: leaderboardPath, content: leaderboardJSON, encoding: 'utf-8' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log('Writing to disk...');
|
|
||||||
await rm('dist.json', { force: true });
|
|
||||||
await writeFile('dist.json', leaderboardJSON);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { writeFile } from 'fs/promises';
|
|
||||||
import { Region } from '../src/constants/regions';
|
|
||||||
import { getRatingsNeighbors } from '../src/core/blitz/getRatingsNeighbors';
|
|
||||||
|
|
||||||
const REGION: Region = 'com';
|
|
||||||
const PLAYER = 1041988373;
|
|
||||||
const ITERATIONS = 2 ** 4;
|
|
||||||
const MAX_NEIGHBORS = 2 ** 7;
|
|
||||||
const data: [number, number][] = [];
|
|
||||||
let finished = 0;
|
|
||||||
|
|
||||||
for (let index = 0; index < ITERATIONS; index++) {
|
|
||||||
const neighbors = Math.round((Math.random() * MAX_NEIGHBORS) / 2) * 2;
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
console.log(`🟡 Iteration ${index} with ${neighbors} neighbors`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getRatingsNeighbors(REGION, PLAYER, neighbors);
|
|
||||||
|
|
||||||
finished++;
|
|
||||||
console.log(
|
|
||||||
`🟢 (${Math.round(
|
|
||||||
100 * (finished / ITERATIONS),
|
|
||||||
)}%) Iteration ${index} with ${neighbors} neighbors`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const end = Date.now();
|
|
||||||
const time = end - start;
|
|
||||||
|
|
||||||
data.push([neighbors, time]);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
`🔴 Iteration ${index} with ${neighbors} neighbors\n\n${error}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🟢 Done, sorting');
|
|
||||||
data.sort(([neighborsA], [neighborsB]) => neighborsA - neighborsB);
|
|
||||||
|
|
||||||
console.log('🟢 Writing');
|
|
||||||
writeFile(
|
|
||||||
'dist/ratingsPlot.csv',
|
|
||||||
data.map(([neighbors, time]) => `${neighbors},${time}`).join('\n'),
|
|
||||||
);
|
|
||||||
@@ -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`);
|
||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { Region } from '../../../../../../constants/regions';
|
import { Region } from '../../../../../../constants/regions';
|
||||||
import getRatingsInfo from '../../../../../../core/blitz/getRatingsInfo';
|
import getRatingInfo from '../../../../../../core/blitz/getRatingInfo';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: { region: Region } },
|
{ params }: { params: { region: Region } },
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(await getRatingsInfo(params.region));
|
return NextResponse.json(await getRatingInfo(params.region));
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { Region } from '../../../../../../../constants/regions';
|
import { Region } from '../../../../../../../constants/regions';
|
||||||
import { getRatingsLeague } from '../../../../../../../core/blitz/getRatingsLeague';
|
import { getRatingLeague } from '../../../../../../../core/blitz/getRatingLeague';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: { region: Region; league: number } },
|
{ params }: { params: { region: Region; league: number } },
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
await getRatingsLeague(params.region, params.league, true),
|
await getRatingLeague(params.region, params.league, true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { Region } from '../../../../../../../constants/regions';
|
import { Region } from '../../../../../../../constants/regions';
|
||||||
import { searchCurrentRatingsPlayers } from '../../../../../../../core/blitz/searchCurrentRatingsPlayers';
|
import { searchCurrentRatingPlayers } from '../../../../../../../core/blitz/searchCurrentRatingPlayers';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: { region: Region; username: string } },
|
{ params }: { params: { region: Region; username: string } },
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
await searchCurrentRatingsPlayers(params.region, params.username),
|
await searchCurrentRatingPlayers(params.region, params.username),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+42
-92
@@ -1,20 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
import { AlertDialog, Button, Flex, Theme } from '@radix-ui/themes';
|
||||||
import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes';
|
|
||||||
import '@radix-ui/themes/styles.css';
|
import '@radix-ui/themes/styles.css';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import { Roboto_Flex } from 'next/font/google';
|
import { Roboto_Flex } from 'next/font/google';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { SkyLine } from '../assets/art/SkyLine';
|
import { Google } from '../components/Google';
|
||||||
import { Analytics } from '../components/Analytics';
|
import { Footer } from '../components/Footer';
|
||||||
import Navbar from '../components/Navbar';
|
import Navbar from '../components/Navbar';
|
||||||
import PageWrapper from '../components/PageWrapper';
|
|
||||||
import isDev from '../core/blitzkrieg/isDev';
|
import isDev from '../core/blitzkrieg/isDev';
|
||||||
import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
|
import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
|
||||||
|
import { useFullScreen } from '../hooks/useFullScreen';
|
||||||
import { useApp } from '../stores/app';
|
import { useApp } from '../stores/app';
|
||||||
import { mintTheme } from '../themes/mint';
|
|
||||||
|
|
||||||
config();
|
config();
|
||||||
|
|
||||||
@@ -33,6 +31,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isEmbed = pathname.split('/')[1] === 'embeds';
|
const isEmbed = pathname.split('/')[1] === 'embeds';
|
||||||
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
|
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
|
||||||
|
const isFullScreen = useFullScreen();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowDevBuildAlert(
|
setShowDevBuildAlert(
|
||||||
@@ -51,110 +50,61 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||||||
name="description"
|
name="description"
|
||||||
content="🎉 Tools for everything World of Tanks Blitz"
|
content="🎉 Tools for everything World of Tanks Blitz"
|
||||||
/>
|
/>
|
||||||
<Analytics />
|
|
||||||
|
<Google />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body
|
<body
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
paddingTop: isEmbed ? 0 : '3.25rem',
|
|
||||||
backgroundColor: isEmbed ? 'transparent' : undefined,
|
backgroundColor: isEmbed ? 'transparent' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Theme
|
<Theme
|
||||||
appearance="dark"
|
appearance="dark"
|
||||||
panelBackground="translucent"
|
panelBackground="solid"
|
||||||
radius="full"
|
radius="full"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
>
|
>
|
||||||
{!isEmbed && <Navbar />}
|
<Flex
|
||||||
<AlertDialog.Root open={showDevBuildAlert}>
|
direction="column"
|
||||||
<AlertDialog.Content>
|
style={{ minHeight: '100vh', paddingTop: isEmbed ? undefined : 52 }}
|
||||||
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
|
>
|
||||||
<AlertDialog.Description>
|
{!isEmbed && <Navbar />}
|
||||||
This version may have a lot of issues. Report issues to{' '}
|
<AlertDialog.Root open={showDevBuildAlert}>
|
||||||
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
|
<AlertDialog.Content>
|
||||||
the official Discord server
|
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
|
||||||
</a>
|
<AlertDialog.Description>
|
||||||
. Also consider using{' '}
|
This version may have a lot of issues. Report issues to{' '}
|
||||||
<a href="https://blitz-krieg.vercel.app/">
|
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
|
||||||
the more stable release version
|
the official Discord server
|
||||||
</a>
|
</a>
|
||||||
. You will be asked again in 8 days.
|
. Also consider using{' '}
|
||||||
</AlertDialog.Description>
|
<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">
|
<Flex justify="end">
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDevBuildAlert(false);
|
setShowDevBuildAlert(false);
|
||||||
useApp.setState({ devBuildAgreementTime: Date.now() });
|
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,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="4" color="mint">
|
Continue
|
||||||
Blitzkrieg. Free forever. For everyone.
|
</Button>
|
||||||
</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%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</a>
|
</AlertDialog.Content>
|
||||||
</PageWrapper>
|
</AlertDialog.Root>
|
||||||
)}
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{!isEmbed && !isFullScreen && <Footer />}
|
||||||
|
</Flex>
|
||||||
</Theme>
|
</Theme>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { theme } from '../stitches.config';
|
|||||||
|
|
||||||
export const tool = styleVariants({
|
export const tool = styleVariants({
|
||||||
enabled: {
|
enabled: {
|
||||||
boxShadow: `inset 0 -256px 512px -128px ${theme.colors.appBackground1}`,
|
boxShadow: `inset 0 -200px 128px -128px ${theme.colors.appBackground1}`,
|
||||||
|
|
||||||
':hover': {
|
':hover': {
|
||||||
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
|
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
|
||||||
|
|||||||
+63
-79
@@ -1,95 +1,79 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CaretRightIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
|
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||||
import { Flex, Text, TextField } from '@radix-ui/themes';
|
import { Flex } from '@radix-ui/themes';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRef } from 'react';
|
|
||||||
import { Search, SearchItem } from 'react-fuzzysort';
|
|
||||||
import PageWrapper from '../components/PageWrapper';
|
import PageWrapper from '../components/PageWrapper';
|
||||||
import { TOOLS } from '../constants/tools';
|
import { TOOLS } from '../constants/tools';
|
||||||
import { theme } from '../stitches.config';
|
import { theme } from '../stitches.config';
|
||||||
import * as styles from './page.css';
|
import * as styles from './page.css';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const input = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper>
|
<PageWrapper>
|
||||||
<TextField.Root>
|
<Flex direction="column" gap="4" justify="center" style={{ flex: 1 }}>
|
||||||
<TextField.Slot>
|
{/* <TextField.Root placeholder="Search anything...">
|
||||||
<MagnifyingGlassIcon height="16" width="16" />
|
<TextField.Slot>
|
||||||
</TextField.Slot>
|
<MagnifyingGlassIcon height="16" width="16" />
|
||||||
<TextField.Input ref={input} placeholder="Search tools..." />
|
</TextField.Slot>
|
||||||
</TextField.Root>
|
</TextField.Root> */}
|
||||||
|
|
||||||
<Flex gap="3" wrap="wrap" align="center" justify="center">
|
<Flex gap="3" wrap="wrap" align="center" justify="center">
|
||||||
<Search
|
{TOOLS.map((tool) => (
|
||||||
input={input}
|
<Link
|
||||||
list={TOOLS.map(
|
href={tool.href ?? `/tools/${tool.id}`}
|
||||||
(tool) =>
|
target={tool.href ? '_blank' : undefined}
|
||||||
({
|
className={
|
||||||
node: (
|
tool.disabled ? styles.tool.disabled : styles.tool.enabled
|
||||||
<Link
|
}
|
||||||
href={tool.href ?? `/tools/${tool.id}`}
|
style={{
|
||||||
target={tool.href ? '_blank' : undefined}
|
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
|
||||||
className={
|
cursor: tool.disabled ? 'default' : 'pointer',
|
||||||
tool.disabled ? styles.tool.disabled : styles.tool.enabled
|
opacity: tool.disabled ? 0.25 : 1,
|
||||||
}
|
height: 128,
|
||||||
style={{
|
minWidth: 256,
|
||||||
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
|
flex: 1,
|
||||||
cursor: tool.disabled ? 'default' : 'pointer',
|
borderRadius: 8,
|
||||||
opacity: tool.disabled ? 0.25 : 1,
|
textDecoration: 'none',
|
||||||
height: 128,
|
backgroundSize: 'cover',
|
||||||
minWidth: 256,
|
backgroundPosition: 'center',
|
||||||
flex: 1,
|
overflow: 'hidden',
|
||||||
borderRadius: 8,
|
display: 'flex',
|
||||||
textDecoration: 'none',
|
flexDirection: 'column',
|
||||||
backgroundSize: 'cover',
|
alignItems: 'flex-start',
|
||||||
backgroundPosition: 'center',
|
justifyContent: 'flex-end',
|
||||||
overflow: 'hidden',
|
padding: 16,
|
||||||
display: 'flex',
|
gap: 4,
|
||||||
flexDirection: 'column',
|
transition: `box-shadow ${theme.durations.regular}`,
|
||||||
alignItems: 'flex-start',
|
}}
|
||||||
justifyContent: 'flex-end',
|
onClick={(event) => {
|
||||||
padding: 16,
|
if (tool.disabled) event.preventDefault();
|
||||||
gap: 4,
|
}}
|
||||||
transition: `box-shadow ${theme.durations.regular}`,
|
>
|
||||||
}}
|
<span
|
||||||
onClick={(event) => {
|
style={{
|
||||||
if (tool.disabled) event.preventDefault();
|
fontSize: 32,
|
||||||
}}
|
color: theme.colors.textHighContrast,
|
||||||
>
|
display: 'flex',
|
||||||
<span
|
alignItems: 'center',
|
||||||
style={{
|
justifyContent: 'center',
|
||||||
fontSize: 32,
|
}}
|
||||||
color: theme.colors.textHighContrast,
|
>
|
||||||
display: 'flex',
|
{tool.title}
|
||||||
alignItems: 'center',
|
<CaretRightIcon style={{ width: '1em', height: '1em' }} />
|
||||||
justifyContent: 'center',
|
</span>
|
||||||
}}
|
<span
|
||||||
>
|
style={{
|
||||||
{tool.title}
|
color: theme.colors.textLowContrast,
|
||||||
<CaretRightIcon style={{ width: '1em', height: '1em' }} />
|
fontSize: 16,
|
||||||
</span>
|
}}
|
||||||
<span
|
>
|
||||||
style={{
|
{tool.disabled ? 'Coming soon!' : tool.description}
|
||||||
color: theme.colors.textLowContrast,
|
</span>
|
||||||
fontSize: 16,
|
</Link>
|
||||||
}}
|
))}
|
||||||
>
|
</Flex>
|
||||||
{tool.disabled ? 'Coming soon!' : tool.description}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
query: `${tool.title} ${tool.description}`,
|
|
||||||
}) satisfies SearchItem,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Text size="1" color="gray" align="center">
|
|
||||||
Images courtesy of Wargaming Art Department
|
|
||||||
</Text>
|
|
||||||
</PageWrapper>
|
</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 { blackA } from '@radix-ui/colors';
|
||||||
import { Flex, Text } from '@radix-ui/themes';
|
import { Flex, Spinner, Text } from '@radix-ui/themes';
|
||||||
import { PuffLoader } from 'react-spinners';
|
|
||||||
import { theme } from '../../../stitches.config';
|
import { theme } from '../../../stitches.config';
|
||||||
|
|
||||||
const NAV_HEIGHT = 52.65;
|
const NAV_HEIGHT = 52.65;
|
||||||
|
|
||||||
interface LoaderProps {
|
interface LoaderProps {
|
||||||
naked?: boolean;
|
naked?: boolean;
|
||||||
color?: string;
|
|
||||||
progress?: number;
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Loader({ naked, color, progress }: LoaderProps) {
|
export function Loader({ naked, progress }: LoaderProps) {
|
||||||
const animation = (
|
const animation = (
|
||||||
<div style={{ position: 'relative' }}>
|
<Flex align="center" gap="2">
|
||||||
|
<Spinner />
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
<Text
|
<Text color="gray">{Math.round(progress * 100)}%</Text>
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translate(calc(-50% + 5px), calc(-50% + 5px))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Math.round(progress * 100)}%
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
<PuffLoader color={color ?? theme.colors.solidBackground} />
|
</Flex>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (naked) return animation;
|
if (naked) return animation;
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
|
|||||||
|
|
||||||
return (
|
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 && (
|
{pathSplit.length === 3 && (
|
||||||
<div
|
<div
|
||||||
@@ -73,7 +85,15 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Suspense fallback={<Loader />}>{children}</Suspense>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File renamed without changes.
+19
-22
@@ -53,29 +53,26 @@ export function PageTurner({
|
|||||||
<CaretLeftIcon />
|
<CaretLeftIcon />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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.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.Slot>out of {pages}</TextField.Slot>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
<Button
|
<Button
|
||||||
@@ -15,11 +15,11 @@ import { produce } from 'immer';
|
|||||||
import { debounce, range } from 'lodash';
|
import { debounce, range } from 'lodash';
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { RatingsInfo, RatingsPlayer } from '../../../commands/ratings';
|
import { RatingInfo, RatingPlayer } from '../../../commands/ratingLeaderboard';
|
||||||
import PageWrapper from '../../../components/PageWrapper';
|
import PageWrapper from '../../../components/PageWrapper';
|
||||||
import { Skeleton } from '../../../components/Skeleton';
|
import { Skeleton } from '../../../components/Skeleton';
|
||||||
import { LEAGUES } from '../../../constants/leagues';
|
import { LEAGUES } from '../../../constants/leagues';
|
||||||
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings';
|
import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../../../constants/rating';
|
||||||
import {
|
import {
|
||||||
REGIONS,
|
REGIONS,
|
||||||
Region,
|
Region,
|
||||||
@@ -28,17 +28,17 @@ import {
|
|||||||
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
|
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
|
||||||
import fetchBlitz from '../../../core/blitz/fetchBlitz';
|
import fetchBlitz from '../../../core/blitz/fetchBlitz';
|
||||||
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
|
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
|
||||||
import getRatingsInfo from '../../../core/blitz/getRatingsInfo';
|
import getRatingInfo from '../../../core/blitz/getRatingInfo';
|
||||||
import { getRatingsLeague } from '../../../core/blitz/getRatingsLeague';
|
import { getRatingLeague } from '../../../core/blitz/getRatingLeague';
|
||||||
import { getRatingsNeighbors } from '../../../core/blitz/getRatingsNeighbors';
|
import { getRatingNeighbors } from '../../../core/blitz/getRatingNeighbors';
|
||||||
import { searchCurrentRatingsPlayers } from '../../../core/blitz/searchCurrentRatingsPlayers';
|
import { searchCurrentRatingPlayers } from '../../../core/blitz/searchCurrentRatingPlayers';
|
||||||
import {
|
import {
|
||||||
AccountList,
|
AccountList,
|
||||||
AccountListItem,
|
AccountListItem,
|
||||||
} from '../../../core/blitz/searchPlayersAcrossRegions';
|
} from '../../../core/blitz/searchPlayersAcrossRegions';
|
||||||
import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber';
|
import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber';
|
||||||
import getArchivedRatingsInfo from '../../../core/blitzkrieg/getArchivedRatingsInfo';
|
import getArchivedRatingInfo from '../../../core/blitzkrieg/getArchivedRatingInfo';
|
||||||
import { getArchivedRatingsLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingsLeaderboard';
|
import { getArchivedRatingLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingLeaderboard';
|
||||||
import { theme } from '../../../stitches.config';
|
import { theme } from '../../../stitches.config';
|
||||||
import { PageTurner } from './components/PageTurner';
|
import { PageTurner } from './components/PageTurner';
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ interface NameCacheEntry {
|
|||||||
}
|
}
|
||||||
type NameCache = Record<Region, Record<number, NameCacheEntry | null>>;
|
type NameCache = Record<Region, Record<number, NameCacheEntry | null>>;
|
||||||
type LeaderboardCache = Record<Region, Record<number, Record<number, number>>>;
|
type LeaderboardCache = Record<Region, Record<number, Record<number, number>>>;
|
||||||
type InfoCache = Record<Region, Record<number, RatingsInfo | undefined>>;
|
type InfoCache = Record<Region, Record<number, RatingInfo | undefined>>;
|
||||||
type ScoreCache = Record<Region, Record<number, Record<number, number>>>;
|
type ScoreCache = Record<Region, Record<number, Record<number, number>>>;
|
||||||
|
|
||||||
const useNameCache = create<NameCache>(() => ({
|
const useNameCache = create<NameCache>(() => ({
|
||||||
@@ -93,7 +93,7 @@ export default function Page() {
|
|||||||
{ type: 'id'; id: number } | { type: 'position'; position: number } | null
|
{ type: 'id'; id: number } | { type: 'position'; position: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const ratingsInfo = useInfoCache()[region][season];
|
const ratingInfo = useInfoCache()[region][season];
|
||||||
const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState<
|
const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -111,7 +111,7 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pages = Math.ceil(
|
const pages = Math.ceil(
|
||||||
(ratingsInfo && ratingsInfo.detail === undefined ? ratingsInfo?.count : 1) /
|
(ratingInfo && ratingInfo.detail === undefined ? ratingInfo?.count : 1) /
|
||||||
ROWS_PER_PAGE,
|
ROWS_PER_PAGE,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,8 +122,8 @@ export default function Page() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
const newInfo =
|
const newInfo =
|
||||||
season === 0
|
season === 0
|
||||||
? await getRatingsInfo(region)
|
? await getRatingInfo(region)
|
||||||
: await getArchivedRatingsInfo(region, season);
|
: await getArchivedRatingInfo(region, season);
|
||||||
useInfoCache.setState(
|
useInfoCache.setState(
|
||||||
produce((draft: InfoCache) => {
|
produce((draft: InfoCache) => {
|
||||||
draft[region][season] = newInfo;
|
draft[region][season] = newInfo;
|
||||||
@@ -140,7 +140,7 @@ export default function Page() {
|
|||||||
const results = (
|
const results = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
range(0, 5).map(async (league: number) => {
|
range(0, 5).map(async (league: number) => {
|
||||||
const data = await getRatingsLeague(region, league);
|
const data = await getRatingLeague(region, league);
|
||||||
leaguePositionCache.current[region][league] = data[0].number - 1;
|
leaguePositionCache.current[region][league] = data[0].number - 1;
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -178,12 +178,12 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
getArchivedRatingsLeaderboard(region, season).then((results) => {
|
getArchivedRatingLeaderboard(region, season).then((results) => {
|
||||||
useLeaderboardCache.setState(
|
useLeaderboardCache.setState(
|
||||||
produce((draft: LeaderboardCache) => {
|
produce((draft: LeaderboardCache) => {
|
||||||
if (!(season in draft[region])) draft[region][season] = {};
|
if (!(season in draft[region])) draft[region][season] = {};
|
||||||
|
|
||||||
results.forEach((result, index) => {
|
results.entries.forEach((result, index) => {
|
||||||
draft[region][season][index] = result.id;
|
draft[region][season][index] = result.id;
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -192,7 +192,7 @@ export default function Page() {
|
|||||||
produce((draft: ScoreCache) => {
|
produce((draft: ScoreCache) => {
|
||||||
if (!(season in draft[region])) draft[region][season] = {};
|
if (!(season in draft[region])) draft[region][season] = {};
|
||||||
|
|
||||||
results.forEach((result) => {
|
results.entries.forEach((result) => {
|
||||||
draft[region][season][result.id] = result.score;
|
draft[region][season][result.id] = result.score;
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -213,7 +213,7 @@ export default function Page() {
|
|||||||
}, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]);
|
}, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]);
|
||||||
// neighboring page caching for all seasons
|
// neighboring page caching for all seasons
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ratingsInfo || ratingsInfo?.detail) return;
|
if (!ratingInfo || ratingInfo?.detail) return;
|
||||||
|
|
||||||
if (season === 0) {
|
if (season === 0) {
|
||||||
const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2;
|
const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2;
|
||||||
@@ -236,7 +236,7 @@ export default function Page() {
|
|||||||
const ids = range(
|
const ids = range(
|
||||||
Math.max(0, (page - 1) * ROWS_PER_PAGE),
|
Math.max(0, (page - 1) * ROWS_PER_PAGE),
|
||||||
Math.min(
|
Math.min(
|
||||||
ratingsInfo.count - 1,
|
ratingInfo.count - 1,
|
||||||
(page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE,
|
(page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -265,7 +265,7 @@ export default function Page() {
|
|||||||
page,
|
page,
|
||||||
region,
|
region,
|
||||||
season,
|
season,
|
||||||
ratingsInfo?.detail,
|
ratingInfo?.detail,
|
||||||
leaderboard[region][season] !== undefined &&
|
leaderboard[region][season] !== undefined &&
|
||||||
0 in leaderboard[region][season],
|
0 in leaderboard[region][season],
|
||||||
]);
|
]);
|
||||||
@@ -279,7 +279,7 @@ export default function Page() {
|
|||||||
async function cacheNeighbors(
|
async function cacheNeighbors(
|
||||||
id: number,
|
id: number,
|
||||||
size: number,
|
size: number,
|
||||||
): Promise<RatingsPlayer[] | null> {
|
): Promise<RatingPlayer[] | null> {
|
||||||
const radius = Math.round(size / 2);
|
const radius = Math.round(size / 2);
|
||||||
const targetPosition = Object.entries(leaderboard[region][season]).find(
|
const targetPosition = Object.entries(leaderboard[region][season]).find(
|
||||||
([, player]) => id === player,
|
([, player]) => id === player,
|
||||||
@@ -303,7 +303,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { neighbors } = await getRatingsNeighbors(region, id, size, true);
|
const { neighbors } = await getRatingNeighbors(region, id, size, true);
|
||||||
|
|
||||||
useLeaderboardCache.setState(
|
useLeaderboardCache.setState(
|
||||||
produce((draft: LeaderboardCache) => {
|
produce((draft: LeaderboardCache) => {
|
||||||
@@ -341,11 +341,11 @@ export default function Page() {
|
|||||||
return neighbors;
|
return neighbors;
|
||||||
}
|
}
|
||||||
async function jumpToPosition(position: number) {
|
async function jumpToPosition(position: number) {
|
||||||
if (!ratingsInfo || ratingsInfo?.detail) return;
|
if (!ratingInfo || ratingInfo?.detail) return;
|
||||||
|
|
||||||
const targetPosition = Math.min(
|
const targetPosition = Math.min(
|
||||||
ratingsInfo.count - 1,
|
ratingInfo.count - 1,
|
||||||
Math.max(0, Math.min(position, ratingsInfo.count - 1)),
|
Math.max(0, Math.min(position, ratingInfo.count - 1)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (season === 0) {
|
if (season === 0) {
|
||||||
@@ -378,7 +378,7 @@ export default function Page() {
|
|||||||
const loadingDirection = Math.sign(targetPosition - closestPosition);
|
const loadingDirection = Math.sign(targetPosition - closestPosition);
|
||||||
let seedingPlayer = closestPlayerId as number;
|
let seedingPlayer = closestPlayerId as number;
|
||||||
let targetPositionAcquired = false;
|
let targetPositionAcquired = false;
|
||||||
let neighbors: RatingsPlayer[] | null;
|
let neighbors: RatingPlayer[] | null;
|
||||||
|
|
||||||
setLoadingProgress([0, closestPositionDistance]);
|
setLoadingProgress([0, closestPositionDistance]);
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function jumpToScore() {
|
async function jumpToScore() {
|
||||||
if (!ratingsInfo || ratingsInfo?.detail) return;
|
if (!ratingInfo || ratingInfo?.detail) return;
|
||||||
|
|
||||||
const targetScore = scoreInput.current!.valueAsNumber;
|
const targetScore = scoreInput.current!.valueAsNumber;
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@ export default function Page() {
|
|||||||
} else {
|
} else {
|
||||||
let playerIndex = -1;
|
let playerIndex = -1;
|
||||||
|
|
||||||
for (let index = ratingsInfo.count; index >= 0; index--) {
|
for (let index = ratingInfo.count; index >= 0; index--) {
|
||||||
if (!(index in leaderboard[region][season])) continue;
|
if (!(index in leaderboard[region][season])) continue;
|
||||||
|
|
||||||
if (scores[leaderboard[region][season][index]] >= targetScore) {
|
if (scores[leaderboard[region][season][index]] >= targetScore) {
|
||||||
@@ -545,7 +545,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalPlayers =
|
const totalPlayers =
|
||||||
!ratingsInfo?.detail && ratingsInfo?.count ? ratingsInfo.count : undefined;
|
!ratingInfo?.detail && ratingInfo?.count ? ratingInfo.count : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper color="orange">
|
<PageWrapper color="orange">
|
||||||
@@ -573,7 +573,7 @@ export default function Page() {
|
|||||||
<Select.Trigger style={{ flex: 1 }} />
|
<Select.Trigger style={{ flex: 1 }} />
|
||||||
|
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{ratingsInfo?.detail === undefined && (
|
{ratingInfo?.detail === undefined && (
|
||||||
<Select.Item value="0">Current season</Select.Item>
|
<Select.Item value="0">Current season</Select.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -582,7 +582,7 @@ export default function Page() {
|
|||||||
|
|
||||||
{latestArchivedSeasonNumber &&
|
{latestArchivedSeasonNumber &&
|
||||||
range(
|
range(
|
||||||
FIRST_ARCHIVED_RATINGS_SEASON,
|
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
|
||||||
latestArchivedSeasonNumber + 1,
|
latestArchivedSeasonNumber + 1,
|
||||||
)
|
)
|
||||||
.map((season) => (
|
.map((season) => (
|
||||||
@@ -605,7 +605,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Flex gap="4" justify="center">
|
<Flex gap="4" justify="center">
|
||||||
<TextField.Input
|
<TextField.Root
|
||||||
ref={scoreInput}
|
ref={scoreInput}
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Type a score..."
|
placeholder="Type a score..."
|
||||||
@@ -638,7 +638,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Flex gap="4" justify="center">
|
<Flex gap="4" justify="center">
|
||||||
<TextField.Input
|
<TextField.Root
|
||||||
ref={positionInput}
|
ref={positionInput}
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Type a position..."
|
placeholder="Type a position..."
|
||||||
@@ -681,8 +681,8 @@ export default function Page() {
|
|||||||
<Select.Trigger />
|
<Select.Trigger />
|
||||||
|
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{ratingsInfo?.detail === undefined &&
|
{ratingInfo?.detail === undefined &&
|
||||||
ratingsInfo?.leagues.map(({ index, title }) => (
|
ratingInfo?.leagues.map(({ index, title }) => (
|
||||||
<Select.Item key={index} value={`${index}`}>
|
<Select.Item key={index} value={`${index}`}>
|
||||||
{title}
|
{title}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
@@ -714,11 +714,7 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (!ratingInfo || ratingInfo.detail || !leaderboard)
|
||||||
!ratingsInfo ||
|
|
||||||
ratingsInfo.detail ||
|
|
||||||
!leaderboard
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const minScore =
|
const minScore =
|
||||||
@@ -727,7 +723,7 @@ export default function Page() {
|
|||||||
|
|
||||||
for (
|
for (
|
||||||
let index = 0;
|
let index = 0;
|
||||||
index < ratingsInfo.count;
|
index < ratingInfo.count;
|
||||||
index++
|
index++
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
@@ -763,105 +759,100 @@ export default function Page() {
|
|||||||
|
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<Flex gap="4" direction="column">
|
<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>
|
<TextField.Slot>
|
||||||
<MagnifyingGlassIcon height="16" width="16" />
|
<MagnifyingGlassIcon height="16" width="16" />
|
||||||
</TextField.Slot>
|
</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 searchCurrentRatingsPlayers(
|
|
||||||
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>
|
</TextField.Root>
|
||||||
|
|
||||||
<Flex direction="column" gap="2">
|
<Flex direction="column" gap="2">
|
||||||
@@ -885,12 +876,12 @@ export default function Page() {
|
|||||||
id: player.account_id,
|
id: player.account_id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!ratingsInfo || ratingsInfo.detail) return;
|
if (!ratingInfo || ratingInfo.detail) return;
|
||||||
let playerIndex = -1;
|
let playerIndex = -1;
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let index = 0;
|
let index = 0;
|
||||||
index < ratingsInfo.count;
|
index < ratingInfo.count;
|
||||||
index++
|
index++
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
@@ -951,9 +942,7 @@ export default function Page() {
|
|||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<PageTurner
|
<PageTurner
|
||||||
totalPlayers={
|
totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
|
||||||
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
|
|
||||||
}
|
|
||||||
leaderboard={leaderboard[region][season] ?? {}}
|
leaderboard={leaderboard[region][season] ?? {}}
|
||||||
rowsPerPage={ROWS_PER_PAGE}
|
rowsPerPage={ROWS_PER_PAGE}
|
||||||
page={page}
|
page={page}
|
||||||
@@ -982,7 +971,7 @@ export default function Page() {
|
|||||||
page * ROWS_PER_PAGE,
|
page * ROWS_PER_PAGE,
|
||||||
Math.min(
|
Math.min(
|
||||||
page * ROWS_PER_PAGE + ROWS_PER_PAGE,
|
page * ROWS_PER_PAGE + ROWS_PER_PAGE,
|
||||||
ratingsInfo?.detail ? 0 : (ratingsInfo?.count ?? 1) - 1,
|
ratingInfo?.detail ? 0 : (ratingInfo?.count ?? 1) - 1,
|
||||||
),
|
),
|
||||||
).map((index) => {
|
).map((index) => {
|
||||||
const id = leaderboard[region][season]?.[index];
|
const id = leaderboard[region][season]?.[index];
|
||||||
@@ -994,8 +983,8 @@ export default function Page() {
|
|||||||
: highlightedPlayer.position === index
|
: highlightedPlayer.position === index
|
||||||
: false;
|
: false;
|
||||||
const reward =
|
const reward =
|
||||||
!ratingsInfo?.detail && ratingsInfo?.rewards
|
!ratingInfo?.detail && ratingInfo?.rewards
|
||||||
? ratingsInfo.rewards.find(
|
? ratingInfo.rewards.find(
|
||||||
(reward) =>
|
(reward) =>
|
||||||
reward.from_position <= position &&
|
reward.from_position <= position &&
|
||||||
reward.to_position >= position,
|
reward.to_position >= position,
|
||||||
@@ -1090,9 +1079,7 @@ export default function Page() {
|
|||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
|
||||||
<PageTurner
|
<PageTurner
|
||||||
totalPlayers={
|
totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
|
||||||
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
|
|
||||||
}
|
|
||||||
rowsPerPage={ROWS_PER_PAGE}
|
rowsPerPage={ROWS_PER_PAGE}
|
||||||
leaderboard={leaderboard[region][season] ?? {}}
|
leaderboard={leaderboard[region][season] ?? {}}
|
||||||
page={page}
|
page={page}
|
||||||
@@ -37,19 +37,18 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
|
|||||||
<Heading size="4">Edit title</Heading>
|
<Heading size="4">Edit title</Heading>
|
||||||
|
|
||||||
<Flex gap="2" style={{ width: '100%' }}>
|
<Flex gap="2" style={{ width: '100%' }}>
|
||||||
<TextField.Root style={{ flex: 1 }}>
|
<TextField.Root
|
||||||
<TextField.Input
|
style={{ flex: 1 }}
|
||||||
ref={titleInput}
|
ref={titleInput}
|
||||||
defaultValue={session.title}
|
defaultValue={session.title}
|
||||||
placeholder="Enter title..."
|
placeholder="Enter title..."
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' && titleInput.current) {
|
if (event.key === 'Enter' && titleInput.current) {
|
||||||
setShowTitleEditor(false);
|
setShowTitleEditor(false);
|
||||||
useSession.setState({ title: titleInput.current!.value });
|
useSession.setState({ title: titleInput.current!.value });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
|
||||||
|
|
||||||
<Dialog.Close>
|
<Dialog.Close>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
Flex,
|
Flex,
|
||||||
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
TextField,
|
TextField,
|
||||||
} from '@radix-ui/themes';
|
} from '@radix-ui/themes';
|
||||||
@@ -56,38 +57,36 @@ export default function Page() {
|
|||||||
<PageWrapper color="blue">
|
<PageWrapper color="blue">
|
||||||
<div className={styles.toolBar}>
|
<div className={styles.toolBar}>
|
||||||
<div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}>
|
<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>
|
<TextField.Slot>
|
||||||
<PersonIcon height="16" width="16" />
|
<PersonIcon height="16" width="16" />
|
||||||
</TextField.Slot>
|
</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>
|
</TextField.Root>
|
||||||
|
|
||||||
<DropdownMenu.Root open={showSearchResults} modal={false}>
|
<DropdownMenu.Root open={showSearchResults} modal={false}>
|
||||||
@@ -251,6 +250,20 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionPage naked={false} />
|
<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>
|
</PageWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,5 @@
|
|||||||
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
|
import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
|
||||||
import {
|
import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Dialog,
|
|
||||||
Flex,
|
|
||||||
Heading,
|
|
||||||
Tabs,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
} from '@radix-ui/themes';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
|
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
|
||||||
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
|
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
|
||||||
@@ -37,58 +27,70 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
|||||||
const [tab, setTab] = useState('search');
|
const [tab, setTab] = useState('search');
|
||||||
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
|
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (mode !== 'armor') return null;
|
if (mode !== 'armor') return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Flex justify="center">
|
||||||
style={
|
<Card
|
||||||
floating
|
style={
|
||||||
? {
|
floating
|
||||||
position: 'absolute',
|
? {
|
||||||
bottom: 8,
|
position: 'absolute',
|
||||||
left: '50%',
|
bottom: 8,
|
||||||
transform: 'translateX(-50%)',
|
left: '50%',
|
||||||
}
|
transform: 'translateX(-50%)',
|
||||||
: undefined
|
}
|
||||||
}
|
: undefined
|
||||||
>
|
}
|
||||||
<Flex align="center" justify="between" gap="6">
|
>
|
||||||
<Flex>
|
<Flex
|
||||||
{antagonist.gun.shells.map((shell, index) => {
|
align="center"
|
||||||
return (
|
justify="center"
|
||||||
<ShellButton
|
wrap="wrap"
|
||||||
selected={antagonist.shell.id === shell.id}
|
style={{
|
||||||
shell={shell.icon}
|
gap: 'min(5vw, 32px)',
|
||||||
rowChild
|
}}
|
||||||
first={index === 0}
|
>
|
||||||
key={shell.id}
|
<Flex>
|
||||||
last={index === antagonist.gun.shells.length - 1}
|
{antagonist.gun.shells.map((shell, index) => {
|
||||||
onClick={() => {
|
return (
|
||||||
mutateDuel((draft) => {
|
<ShellButton
|
||||||
draft.antagonist!.shell = shell;
|
selected={antagonist.shell.id === shell.id}
|
||||||
mutateTankopediaTemporary((draft) => {
|
shell={shell.icon}
|
||||||
draft.shot = undefined;
|
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">
|
<Button
|
||||||
{!floating && <Text>Versus</Text>}
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
mutateDuel((draft) => {
|
||||||
|
[draft.protagonist, draft.antagonist] = [
|
||||||
|
draft.antagonist,
|
||||||
|
draft.protagonist,
|
||||||
|
];
|
||||||
|
pushTankopediaPath(draft.antagonist!.tank.id);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShuffleIcon /> Swap
|
||||||
|
</Button>
|
||||||
|
|
||||||
{floating && (
|
<Flex align="center" gap="4">
|
||||||
<Flex gap="2" align="center">
|
|
||||||
Versus {antagonist.tank.name}
|
|
||||||
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!floating && (
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
open={antagonistSelectorOpen}
|
open={antagonistSelectorOpen}
|
||||||
onOpenChange={setAntagonistSelectorVisible}
|
onOpenChange={setAntagonistSelectorVisible}
|
||||||
@@ -96,9 +98,8 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
|||||||
<Dialog.Trigger>
|
<Dialog.Trigger>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<Flex gap="2" align="center">
|
<Flex gap="2" align="center">
|
||||||
{antagonist.tank.name}
|
Versus {antagonist.tank.name}
|
||||||
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
<SmallTankIcon id={antagonist.tank.id} size={16} />
|
||||||
<CaretRightIcon />
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
@@ -160,61 +161,51 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
|||||||
<Flex gap="2" wrap="wrap">
|
<Flex gap="2" wrap="wrap">
|
||||||
<Flex>
|
<Flex>
|
||||||
{antagonist.tank.turrets.map((turret, index) => (
|
{antagonist.tank.turrets.map((turret, index) => (
|
||||||
<Tooltip content={turret.name} key={turret.id}>
|
<ModuleButton
|
||||||
<ModuleButton
|
rowChild
|
||||||
rowChild
|
first={index === 0}
|
||||||
first={index === 0}
|
last={
|
||||||
last={
|
index === antagonist.tank.turrets.length - 1
|
||||||
index ===
|
}
|
||||||
antagonist.tank.turrets.length - 1
|
key={turret.id}
|
||||||
}
|
onClick={() => {
|
||||||
key={turret.id}
|
mutateDuel((draft) => {
|
||||||
onClick={() => {
|
draft.antagonist!.turret = turret;
|
||||||
mutateDuel((draft) => {
|
draft.antagonist!.gun =
|
||||||
draft.antagonist!.turret = turret;
|
turret.guns.at(-1)!;
|
||||||
draft.antagonist!.gun =
|
draft.antagonist!.shell =
|
||||||
turret.guns.at(-1)!;
|
draft.antagonist!.gun.shells[0];
|
||||||
draft.antagonist!.shell =
|
});
|
||||||
draft.antagonist!.gun.shells[0];
|
mutateTankopediaTemporary((draft) => {
|
||||||
});
|
draft.shot = undefined;
|
||||||
mutateTankopediaTemporary((draft) => {
|
});
|
||||||
draft.shot = undefined;
|
}}
|
||||||
});
|
selected={antagonist.turret.id === turret.id}
|
||||||
}}
|
discriminator={
|
||||||
selected={
|
TIER_ROMAN_NUMERALS[turret.tier]
|
||||||
antagonist.turret.id === turret.id
|
}
|
||||||
}
|
module="turret"
|
||||||
discriminator={
|
/>
|
||||||
TIER_ROMAN_NUMERALS[turret.tier]
|
|
||||||
}
|
|
||||||
module="turret"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex>
|
<Flex>
|
||||||
{antagonist.turret.guns.map((gun, index) => (
|
{antagonist.turret.guns.map((gun, index) => (
|
||||||
<Tooltip content={gun.name} key={gun.id}>
|
<ModuleButton
|
||||||
<ModuleButton
|
rowChild
|
||||||
rowChild
|
first={index === 0}
|
||||||
first={index === 0}
|
last={
|
||||||
last={
|
index === antagonist.turret.guns.length - 1
|
||||||
index ===
|
}
|
||||||
antagonist.turret.guns.length - 1
|
onClick={() => {
|
||||||
}
|
mutateDuel((draft) => {
|
||||||
onClick={() => {
|
draft.antagonist!.gun = gun;
|
||||||
mutateDuel((draft) => {
|
draft.antagonist!.shell = gun.shells[0];
|
||||||
draft.antagonist!.gun = gun;
|
});
|
||||||
draft.antagonist!.shell = gun.shells[0];
|
}}
|
||||||
});
|
selected={antagonist.gun.id === gun.id}
|
||||||
}}
|
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
||||||
selected={antagonist.gun.id === gun.id}
|
module="gun"
|
||||||
discriminator={
|
/>
|
||||||
TIER_ROMAN_NUMERALS[gun.tier]
|
|
||||||
}
|
|
||||||
module="gun"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -251,24 +242,9 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
|
|||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
)}
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Card>
|
||||||
<Button
|
</Flex>
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
mutateDuel((draft) => {
|
|
||||||
[draft.protagonist, draft.antagonist] = [
|
|
||||||
draft.antagonist,
|
|
||||||
draft.protagonist,
|
|
||||||
];
|
|
||||||
pushTankopediaPath(draft.antagonist!.tank.id);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShuffleIcon /> Swap
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+6
-2
@@ -1,5 +1,5 @@
|
|||||||
|
import { Flex } from '@radix-ui/themes';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import * as styles from '../../../../page.css';
|
|
||||||
|
|
||||||
interface ConfigurationChildWrapperProps {
|
interface ConfigurationChildWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -8,5 +8,9 @@ interface ConfigurationChildWrapperProps {
|
|||||||
export function ConfigurationChildWrapper({
|
export function ConfigurationChildWrapper({
|
||||||
children,
|
children,
|
||||||
}: ConfigurationChildWrapperProps) {
|
}: 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 { use } from 'react';
|
||||||
import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton';
|
import { ConsumablesManager } from '../../../../../../../components/ConsumablesManager';
|
||||||
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
|
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
|
||||||
import { consumableDefinitions } from '../../../../../../../core/blitzkrieg/consumableDefinitions';
|
import { consumableDefinitions } from '../../../../../../../core/blitzkrieg/consumableDefinitions';
|
||||||
import { useEquipment } from '../../../../../../../hooks/useEquipment';
|
import { useEquipment } from '../../../../../../../hooks/useEquipment';
|
||||||
@@ -27,49 +35,53 @@ export function Consumables() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigurationChildWrapper>
|
<ConfigurationChildWrapper>
|
||||||
<Heading size="4">Consumables</Heading>
|
<Flex gap="4" align="center">
|
||||||
|
<Flex gap="2" align="center">
|
||||||
|
<Heading size="4">Consumables</Heading>
|
||||||
|
|
||||||
<Flex wrap="wrap">
|
<Popover.Root>
|
||||||
{consumablesList.map((consumable, index) => {
|
<Popover.Trigger>
|
||||||
const selected = consumables.includes(consumable.id);
|
<IconButton variant="ghost">
|
||||||
|
<InfoCircledIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
return (
|
<Popover.Content>
|
||||||
<ConsumableButton
|
<Text>
|
||||||
duration={
|
<Text color="amber">Yellow consumables</Text> do not affect the
|
||||||
typeof consumable.duration === 'number'
|
statistics.
|
||||||
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
|
</Text>
|
||||||
: undefined
|
</Popover.Content>
|
||||||
}
|
</Popover.Root>
|
||||||
cooldown={
|
</Flex>
|
||||||
consumable.cooldown *
|
|
||||||
(hasConsumableDeliverySystem ? 0.85 : 1) *
|
<Button
|
||||||
(1 - cooldownBooster * 0.1)
|
variant="ghost"
|
||||||
}
|
color="red"
|
||||||
key={consumable.id}
|
onClick={() => {
|
||||||
first={index === 0}
|
mutateDuel((draft) => {
|
||||||
last={index === consumablesList.length - 1}
|
draft.protagonist!.consumables = [];
|
||||||
rowChild
|
});
|
||||||
disabled={
|
}}
|
||||||
protagonist.tank.consumables === consumables.length && !selected
|
>
|
||||||
}
|
Clear
|
||||||
consumable={consumable.id}
|
</Button>
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
</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>
|
</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"
|
direction="column"
|
||||||
gap="1"
|
gap="1"
|
||||||
justify="end"
|
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.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.Slot>%</TextField.Slot>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +1,79 @@
|
|||||||
import { Flex, Heading } from '@radix-ui/themes';
|
import { Button, Flex, Heading } from '@radix-ui/themes';
|
||||||
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
|
import { ModuleManager } from '../../../../../../../components/ModuleManager';
|
||||||
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
|
|
||||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||||
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
|
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
|
||||||
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
|
||||||
|
|
||||||
export function Modules() {
|
export function Modules() {
|
||||||
const protagonist = useDuel((state) => state.protagonist!);
|
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 (
|
return (
|
||||||
<ConfigurationChildWrapper>
|
<ConfigurationChildWrapper>
|
||||||
<Heading size="4">Modules</Heading>
|
<Flex gap="4" align="center">
|
||||||
|
<Heading size="4">Modules</Heading>
|
||||||
<Flex gap="2" wrap="wrap">
|
{hasUpgrades && (
|
||||||
<Flex>
|
<>
|
||||||
{protagonist.tank.turrets.map((turret, index) => {
|
<Button
|
||||||
return (
|
variant="ghost"
|
||||||
<ModuleButton
|
color="red"
|
||||||
key={turret.id}
|
onClick={() => {
|
||||||
selected={protagonist.turret.id === turret.id}
|
mutateDuel((draft) => {
|
||||||
module="turret"
|
draft.protagonist!.turret =
|
||||||
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
|
draft.protagonist!.tank.turrets[0];
|
||||||
first={index === 0}
|
draft.protagonist!.gun = draft.protagonist!.turret.guns[0];
|
||||||
last={index === protagonist.tank.turrets.length - 1}
|
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
|
||||||
rowChild
|
draft.protagonist!.engine =
|
||||||
onClick={() => {
|
draft.protagonist!.tank.engines[0];
|
||||||
mutateDuel((draft) => {
|
draft.protagonist!.track = draft.protagonist!.tank.tracks[0];
|
||||||
draft.protagonist!.turret = turret;
|
});
|
||||||
draft.protagonist!.gun = turret.guns.at(-1)!;
|
}}
|
||||||
draft.protagonist!.shell = turret.guns.at(-1)!.shells[0];
|
>
|
||||||
});
|
Stock
|
||||||
mutateTankopediaTemporary((draft) => {
|
</Button>
|
||||||
draft.shot = undefined;
|
<Button
|
||||||
});
|
variant="ghost"
|
||||||
}}
|
onClick={() => {
|
||||||
/>
|
mutateDuel((draft) => {
|
||||||
);
|
draft.protagonist!.turret =
|
||||||
})}
|
draft.protagonist!.tank.turrets.at(-1)!;
|
||||||
</Flex>
|
draft.protagonist!.gun =
|
||||||
|
draft.protagonist!.turret.guns.at(-1)!;
|
||||||
<Flex>
|
draft.protagonist!.shell =
|
||||||
{protagonist.turret.guns.map((gun, index) => {
|
draft.protagonist!.gun.shells.at(-1)!;
|
||||||
return (
|
draft.protagonist!.engine =
|
||||||
<ModuleButton
|
draft.protagonist!.tank.engines.at(-1)!;
|
||||||
key={gun.id}
|
draft.protagonist!.track =
|
||||||
module="gun"
|
draft.protagonist!.tank.tracks.at(-1)!;
|
||||||
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
|
});
|
||||||
selected={protagonist.gun.id === gun.id}
|
}}
|
||||||
first={index === 0}
|
>
|
||||||
last={index === protagonist.turret.guns.length - 1}
|
Upgrade
|
||||||
rowChild
|
</Button>
|
||||||
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>
|
</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>
|
</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 { use } from 'react';
|
||||||
import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton';
|
import { ProvisionsManager } from '../../../../../../../components/ProvisionsManager';
|
||||||
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
|
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
|
||||||
import { provisionDefinitions } from '../../../../../../../core/blitzkrieg/provisionDefinitions';
|
import { provisionDefinitions } from '../../../../../../../core/blitzkrieg/provisionDefinitions';
|
||||||
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
|
||||||
@@ -18,37 +18,31 @@ export function Provisions() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigurationChildWrapper>
|
<ConfigurationChildWrapper>
|
||||||
<Heading size="4">Provisions</Heading>
|
<Flex gap="4" align="center">
|
||||||
|
<Heading size="4">Provisions</Heading>
|
||||||
<Flex wrap="wrap">
|
<Button
|
||||||
{provisionsList.map((provision, index) => {
|
variant="ghost"
|
||||||
const selected = provisions.includes(provision.id);
|
color="red"
|
||||||
|
onClick={() => {
|
||||||
return (
|
mutateDuel((draft) => {
|
||||||
<ProvisionButton
|
draft.protagonist!.provisions = [];
|
||||||
key={provision.id}
|
});
|
||||||
first={index === 0}
|
}}
|
||||||
last={index === provisionsList.length - 1}
|
>
|
||||||
rowChild
|
Clear
|
||||||
disabled={tank.provisions === provisions.length && !selected}
|
</Button>
|
||||||
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>
|
</Flex>
|
||||||
|
|
||||||
|
<ProvisionsManager
|
||||||
|
provisions={provisionsList.map((provision) => provision.id)}
|
||||||
|
selected={provisions}
|
||||||
|
disabled={tank.provisions === provisions.length}
|
||||||
|
onChange={(provisions) => {
|
||||||
|
mutateDuel((draft) => {
|
||||||
|
draft.protagonist!.provisions = provisions;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ConfigurationChildWrapper>
|
</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 { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
|
||||||
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes';
|
import {
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
IconButton,
|
||||||
|
Popover,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
TextField,
|
||||||
|
} from '@radix-ui/themes';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { use, useEffect, useRef, useState } from 'react';
|
import { use, useEffect, useRef, useState } from 'react';
|
||||||
import { lerp } from 'three/src/math/MathUtils';
|
import { lerp } from 'three/src/math/MathUtils';
|
||||||
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
|
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
|
||||||
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
|
||||||
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
|
||||||
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
|
|
||||||
import { coefficient } from '../../../../../../core/blitzkrieg/coefficient';
|
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 { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
|
||||||
import { normalizeBoundingBox } from '../../../../../../core/blitzkrieg/normalizeBoundingBox';
|
import { provisionDefinitions } from '../../../../../../core/blitzkrieg/provisionDefinitions';
|
||||||
import { progressiveStat } from '../../../../../../core/blitzkrieg/progressiveStat';
|
import { tankCharacteristics } from '../../../../../../core/blitzkrieg/tankCharacteristics';
|
||||||
import { resolveDpm } from '../../../../../../core/blitzkrieg/resolveDpm';
|
|
||||||
import { sum } from '../../../../../../core/blitzkrieg/sum';
|
|
||||||
import {
|
import {
|
||||||
CREW_MEMBER_NAMES,
|
CREW_MEMBER_NAMES,
|
||||||
GUN_TYPE_NAMES,
|
GUN_TYPE_NAMES,
|
||||||
SHELL_NAMES,
|
SHELL_NAMES,
|
||||||
} from '../../../../../../core/blitzkrieg/tankDefinitions/constants';
|
} from '../../../../../../core/blitzkrieg/tankDefinitions/constants';
|
||||||
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
|
|
||||||
import { useConsumable } from '../../../../../../hooks/useConsumable';
|
|
||||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||||
import { useProvision } from '../../../../../../hooks/useProvision';
|
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
|
||||||
import { useProvisions } from '../../../../../../hooks/useProvisions';
|
|
||||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||||
|
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
|
||||||
import { Info } from './components/Info';
|
import { Info } from './components/Info';
|
||||||
import { InfoWithDelta } from './components/InfoWithDelta';
|
import { InfoWithDelta } from './components/InfoWithDelta';
|
||||||
|
|
||||||
export function Characteristics() {
|
export function Characteristics() {
|
||||||
|
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||||
const awaitedModelDefinitions = use(modelDefinitions);
|
const awaitedModelDefinitions = use(modelDefinitions);
|
||||||
const { tank, turret, gun, engine, track, shell } = useDuel(
|
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||||
(state) => state.protagonist!,
|
const crewSkills = useTankopediaTemporary((state) => state.skills);
|
||||||
);
|
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
|
||||||
|
const isFullScreen = useFullScreen();
|
||||||
const hasImprovedVentilation = useEquipment(102);
|
const hasImprovedVentilation = useEquipment(102);
|
||||||
const provisions = useProvisions();
|
|
||||||
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
|
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
|
||||||
|
const [penetrationDistance, setPenetrationDistance] = useState(250);
|
||||||
|
|
||||||
|
const provisions = useDuel((state) => state.protagonist!.provisions);
|
||||||
const provisionCrewBonus =
|
const provisionCrewBonus =
|
||||||
provisions.reduce(
|
provisions.reduce(
|
||||||
(total, provision) =>
|
(total, provision) =>
|
||||||
provision.crew ? total + provision.crew / 100 : total,
|
awaitedProvisionDefinitions[provision].crew
|
||||||
|
? total + awaitedProvisionDefinitions[provision].crew! / 100
|
||||||
|
: total,
|
||||||
0,
|
0,
|
||||||
) + (hasImprovedVentilation ? 0.05 : 0);
|
) + (hasImprovedVentilation ? 0.05 : 0);
|
||||||
const commanderMastery = crewMastery + provisionCrewBonus;
|
const commanderMastery = crewMastery + provisionCrewBonus;
|
||||||
const crewMemberMastery = commanderMastery * 1.1;
|
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 stockEngine = tank.engines[0];
|
||||||
const stockTrack = tank.tracks[0];
|
const stockTrack = tank.tracks[0];
|
||||||
const stockTurret = tank.turrets[0];
|
const stockTurret = tank.turrets[0];
|
||||||
@@ -51,169 +68,46 @@ export function Characteristics() {
|
|||||||
const tankModelDefinition = awaitedModelDefinitions[tank.id];
|
const tankModelDefinition = awaitedModelDefinitions[tank.id];
|
||||||
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
|
||||||
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
const gunModelDefinition = turretModelDefinition.guns[gun.id];
|
||||||
const size = normalizeBoundingBox(
|
const stats = tankCharacteristics(
|
||||||
unionBoundingBox(
|
{
|
||||||
tankModelDefinition.boundingBox,
|
tank,
|
||||||
turretModelDefinition.boundingBox,
|
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(
|
const hasSupercharger = useEquipment(107);
|
||||||
[hasSandbagArmor, 0.03],
|
const hasCalibratedShells = useEquipment(103);
|
||||||
[hasEnhancedSandbagArmor, 0.06],
|
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||||
[hasImprovedAssembly, 0.06],
|
|
||||||
);
|
|
||||||
const reloadCoefficient =
|
|
||||||
(coefficient([hasRammer, -0.07]) *
|
|
||||||
coefficient([true, degressiveStat(crewMemberMastery)])) /
|
|
||||||
coefficient([hasAdrenaline && gun.type === 'regular', 0.2]);
|
|
||||||
const penetrationCoefficient = coefficient([
|
const penetrationCoefficient = coefficient([
|
||||||
hasCalibratedShells,
|
hasCalibratedShells,
|
||||||
isExplosive(shell.type) ? 0.1 : 0.05,
|
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([
|
const penetrationLossOverDistanceCoefficient = coefficient([
|
||||||
hasSupercharger,
|
hasSupercharger,
|
||||||
-0.5,
|
-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(() => {
|
useEffect(() => {
|
||||||
if (penetrationDistanceInput.current) {
|
if (penetrationDistanceInput.current) {
|
||||||
@@ -232,6 +126,8 @@ export function Characteristics() {
|
|||||||
});
|
});
|
||||||
}, [hasImprovedVerticalStabilizer]);
|
}, [hasImprovedVerticalStabilizer]);
|
||||||
|
|
||||||
|
if (isFullScreen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" gap="8" style={{ width: '100%' }}>
|
<Flex direction="column" gap="8" style={{ width: '100%' }}>
|
||||||
<Flex direction="column" gap="2">
|
<Flex direction="column" gap="2">
|
||||||
@@ -262,19 +158,12 @@ export function Characteristics() {
|
|||||||
|
|
||||||
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
|
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
|
||||||
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
|
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
|
||||||
{dpm}
|
{stats.dpm}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
{gun.type === 'autoReloader' && (
|
{gun.type === 'autoReloader' && (
|
||||||
<>
|
<>
|
||||||
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
|
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
|
||||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
{stats.dpmMaximum!}
|
||||||
? ((damageCoefficient * shell.damage.armor) /
|
|
||||||
(gun.reload.at(-1)! * reloadCoefficient +
|
|
||||||
gun.intraClip * intraClipCoefficient)) *
|
|
||||||
60
|
|
||||||
: ((damageCoefficient * shell.damage.armor) /
|
|
||||||
(gun.reload[0] * reloadCoefficient)) *
|
|
||||||
60}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={0}
|
decimals={0}
|
||||||
@@ -282,27 +171,7 @@ export function Characteristics() {
|
|||||||
name="Effective at 60s"
|
name="Effective at 60s"
|
||||||
unit="hp / min"
|
unit="hp / min"
|
||||||
>
|
>
|
||||||
{gun.reload.at(-1)! < gun.reload.at(-2)!
|
{stats.dpmEffective!}
|
||||||
? ((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}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<Info
|
<Info
|
||||||
indent
|
indent
|
||||||
@@ -317,14 +186,17 @@ export function Characteristics() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{gun.type !== 'regular' && (
|
||||||
|
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
|
||||||
|
)}
|
||||||
|
|
||||||
{gun.type === 'autoReloader' ? (
|
{gun.type === 'autoReloader' ? (
|
||||||
<>
|
<>
|
||||||
<InfoWithDelta name="Shells">{gun.reload.length}</InfoWithDelta>
|
|
||||||
<Info indent name="Most optimal">
|
<Info indent name="Most optimal">
|
||||||
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
|
{stats.mostOptimalShellIndex}
|
||||||
</Info>
|
</Info>
|
||||||
<Info name="Shell reloads" unit="s" />
|
<Info name="Shell reloads" unit="s" />
|
||||||
{gun.reload.map((reload, index) => (
|
{stats.shellReloads!.map((reload, index) => (
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
key={index}
|
key={index}
|
||||||
indent
|
indent
|
||||||
@@ -332,7 +204,7 @@ export function Characteristics() {
|
|||||||
decimals={2}
|
decimals={2}
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{reload * reloadCoefficient}
|
{reload}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -343,7 +215,7 @@ export function Characteristics() {
|
|||||||
unit="s"
|
unit="s"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.reload * reloadCoefficient}
|
{stats.shellReload!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
)}
|
)}
|
||||||
{gun.type === 'autoLoader' ||
|
{gun.type === 'autoLoader' ||
|
||||||
@@ -355,14 +227,14 @@ export function Characteristics() {
|
|||||||
unit="s"
|
unit="s"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.intraClip * intraClipCoefficient}
|
{stats.intraClip!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
))}
|
))}
|
||||||
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
|
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
|
||||||
{shell.caliber}
|
{stats.caliber}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
|
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
|
||||||
{resolveNearPenetration(shell.penetration) * penetrationCoefficient}
|
{stats.penetration}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
{typeof shell.penetration !== 'number' && (
|
{typeof shell.penetration !== 'number' && (
|
||||||
<>
|
<>
|
||||||
@@ -390,37 +262,42 @@ export function Characteristics() {
|
|||||||
setPenetrationDistance(value);
|
setPenetrationDistance(value);
|
||||||
}, 500)}
|
}, 500)}
|
||||||
/>
|
/>
|
||||||
<TextField.Root style={{ width: 64 }}>
|
<TextField.Root
|
||||||
<TextField.Input
|
style={{ width: 64 }}
|
||||||
ref={penetrationDistanceInput}
|
ref={penetrationDistanceInput}
|
||||||
defaultValue={penetrationDistance}
|
defaultValue={penetrationDistance}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setPenetrationDistance(
|
setPenetrationDistance(
|
||||||
Math.min(
|
Math.min(
|
||||||
parseInt(penetrationDistanceInput.current!.value),
|
parseInt(penetrationDistanceInput.current!.value),
|
||||||
500,
|
500,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.currentTarget.blur();
|
event.currentTarget.blur();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<TextField.Slot>m</TextField.Slot>
|
<TextField.Slot side="right">m</TextField.Slot>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</Flex>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
|
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
|
||||||
{shell.damage.armor * damageCoefficient}
|
{stats.damage}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
|
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
|
||||||
{shell.damage.module * damageCoefficient}
|
{stats.moduleDamage}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
|
{isExplosive(shell.type) && (
|
||||||
|
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
|
||||||
|
{stats.explosionRadius!}
|
||||||
|
</InfoWithDelta>
|
||||||
|
)}
|
||||||
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
|
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
|
||||||
{shell.speed * shellVelocityCoefficient}
|
{stats.shellVelocity}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={2}
|
decimals={2}
|
||||||
@@ -428,7 +305,7 @@ export function Characteristics() {
|
|||||||
unit="s"
|
unit="s"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.aimTime * aimTimeCoefficient}
|
{stats.aimTime}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<Info name="Dispersion at 100m" />
|
<Info name="Dispersion at 100m" />
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
@@ -438,7 +315,7 @@ export function Characteristics() {
|
|||||||
unit="m"
|
unit="m"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.dispersion.base * dispersionStillCoefficient}
|
{stats.dispersion}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
prefix="+ "
|
prefix="+ "
|
||||||
@@ -447,7 +324,7 @@ export function Characteristics() {
|
|||||||
name="Moving"
|
name="Moving"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{track.dispersion.move * dispersionMovingCoefficient}
|
{stats.dispersionMoving}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={3}
|
decimals={3}
|
||||||
@@ -456,7 +333,7 @@ export function Characteristics() {
|
|||||||
name="Hull traversing"
|
name="Hull traversing"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{track.dispersion.traverse * dispersionMovingCoefficient}
|
{stats.dispersionHullTraversing}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={3}
|
decimals={3}
|
||||||
@@ -465,7 +342,7 @@ export function Characteristics() {
|
|||||||
name="Turret traversing"
|
name="Turret traversing"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.dispersion.traverse * dispersionMovingCoefficient}
|
{stats.dispersionTurretTraversing}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={3}
|
decimals={3}
|
||||||
@@ -474,7 +351,7 @@ export function Characteristics() {
|
|||||||
name="After shooting"
|
name="After shooting"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.dispersion.shot * dispersionMovingCoefficient}
|
{stats.dispersionShooting}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={1}
|
decimals={1}
|
||||||
@@ -484,46 +361,42 @@ export function Characteristics() {
|
|||||||
unit="scalar"
|
unit="scalar"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{gun.dispersion.damaged * dispersionMovingCoefficient}
|
{stats.dispersionGunDamaged}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<Info name="Gun flexibility" unit="°" />
|
<Info name="Gun flexibility" unit="°" />
|
||||||
<InfoWithDelta decimals={1} indent name="Depression">
|
<InfoWithDelta decimals={1} indent name="Depression">
|
||||||
{gunModelDefinition.pitch.max +
|
{stats.gunDepression}
|
||||||
gunDefaultPitch +
|
|
||||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={1} indent name="Elevation">
|
<InfoWithDelta decimals={1} indent name="Elevation">
|
||||||
{-gunModelDefinition.pitch.min -
|
{stats.gunElevation}
|
||||||
gunDefaultPitch +
|
|
||||||
(hasImprovedVerticalStabilizer ? 3 : 0)}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
{gunModelDefinition.pitch.front && (
|
{gunModelDefinition.pitch.front && (
|
||||||
<>
|
<>
|
||||||
<InfoWithDelta decimals={1} indent name="Frontal depression">
|
<InfoWithDelta decimals={1} indent name="Frontal depression">
|
||||||
{gunModelDefinition.pitch.front.max}
|
{stats.gunFrontalDepression!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={1} indent name="Frontal elevation">
|
<InfoWithDelta decimals={1} indent name="Frontal elevation">
|
||||||
{-gunModelDefinition.pitch.front.min}
|
{stats.gunFrontalElevation!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{gunModelDefinition.pitch.back && (
|
{gunModelDefinition.pitch.back && (
|
||||||
<>
|
<>
|
||||||
<InfoWithDelta decimals={1} indent name="Rear depression">
|
<InfoWithDelta decimals={1} indent name="Rear depression">
|
||||||
{gunModelDefinition.pitch.back.max}
|
{stats.gunRearDepression!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={1} indent name="Rear elevation">
|
<InfoWithDelta decimals={1} indent name="Rear elevation">
|
||||||
{-gunModelDefinition.pitch.back.min}
|
{stats.gunRearElevation!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{turretModelDefinition.yaw && (
|
{turretModelDefinition.yaw && (
|
||||||
<>
|
<>
|
||||||
<InfoWithDelta decimals={1} indent name="Azimuth left">
|
<InfoWithDelta decimals={1} indent name="Azimuth left">
|
||||||
{-turretModelDefinition.yaw.min}
|
{stats.azimuthLeft!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={1} indent name="Azimuth right">
|
<InfoWithDelta decimals={1} indent name="Azimuth right">
|
||||||
{turretModelDefinition.yaw.max}
|
{stats.azimuthRight!}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -533,29 +406,13 @@ export function Characteristics() {
|
|||||||
<Heading size="5">Maneuverability</Heading>
|
<Heading size="5">Maneuverability</Heading>
|
||||||
<Info name="Speed" unit="km/hr" />
|
<Info name="Speed" unit="km/hr" />
|
||||||
<InfoWithDelta decimals={0} indent name="Forwards">
|
<InfoWithDelta decimals={0} indent name="Forwards">
|
||||||
{tank.speed.forwards + speedForwardsSum}
|
{stats.speedForwards}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={0} indent name="Backwards">
|
<InfoWithDelta decimals={0} indent name="Backwards">
|
||||||
{tank.speed.backwards + speedBackwardsSum}
|
{stats.speedBackwards}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={0} name="Power" unit="hp">
|
<InfoWithDelta decimals={0} name="Engine power" unit="hp">
|
||||||
{resolvedEnginePower}
|
{stats.enginePower}
|
||||||
</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>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta
|
<InfoWithDelta
|
||||||
decimals={1}
|
decimals={1}
|
||||||
@@ -563,76 +420,98 @@ export function Characteristics() {
|
|||||||
unit="tn"
|
unit="tn"
|
||||||
deltaType="lowerIsBetter"
|
deltaType="lowerIsBetter"
|
||||||
>
|
>
|
||||||
{weightTons}
|
{stats.weight}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={0}>
|
<Info name="Power to weight ratio" unit="hp/tn" />
|
||||||
{turret.traverseSpeed * turretTraverseCoefficient}
|
|
||||||
</InfoWithDelta>
|
|
||||||
<Info name="Effective traverse speed" unit="°/s" />
|
|
||||||
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
<InfoWithDelta decimals={1} indent name="On hard terrain">
|
||||||
{(resolvedEnginePower / stockEngine.power) *
|
{stats.powerToWeightRatioHardTerrain}
|
||||||
track.traverseSpeed *
|
|
||||||
hullTraverseCoefficient *
|
|
||||||
(track.resistance.hard /
|
|
||||||
(track.resistance.hard * resistanceCoefficient)) *
|
|
||||||
(stockWeight / weight)}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
<InfoWithDelta decimals={1} indent name="On medium terrain">
|
||||||
{(resolvedEnginePower / stockEngine.power) *
|
{stats.powerToWeightRatioMediumTerrain}
|
||||||
track.traverseSpeed *
|
|
||||||
hullTraverseCoefficient *
|
|
||||||
(track.resistance.hard /
|
|
||||||
(track.resistance.medium * resistanceCoefficient)) *
|
|
||||||
(stockWeight / weight)}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
<InfoWithDelta decimals={1} indent name="On soft terrain">
|
||||||
{(resolvedEnginePower / stockEngine.power) *
|
{stats.powerToWeightRatioSoftTerrain}
|
||||||
track.traverseSpeed *
|
</InfoWithDelta>
|
||||||
hullTraverseCoefficient *
|
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
|
||||||
(track.resistance.hard /
|
{stats.turretTraverseSpeed}
|
||||||
(track.resistance.soft * resistanceCoefficient)) *
|
</InfoWithDelta>
|
||||||
(stockWeight / weight)}
|
<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>
|
</InfoWithDelta>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex direction="column" gap="2">
|
<Flex direction="column" gap="2">
|
||||||
<Heading size="5">Survivability</Heading>
|
<Heading size="5">Survivability</Heading>
|
||||||
<InfoWithDelta name="Health" unit="hp" decimals={0}>
|
<InfoWithDelta name="Health" unit="hp" decimals={0}>
|
||||||
{(tank.health + turret.health) * healthCoefficient}
|
{stats.health}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta name="Fire chance" unit="%" deltaType="lowerIsBetter">
|
<InfoWithDelta
|
||||||
{Math.round(engine.fireChance * fireChanceCoefficient * 100)}
|
name="Fire chance"
|
||||||
|
unit="%"
|
||||||
|
deltaType="lowerIsBetter"
|
||||||
|
decimals={0}
|
||||||
|
>
|
||||||
|
{stats.fireChance * 100}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta name="View range" unit="m" decimals={0}>
|
<InfoWithDelta name="View range" unit="m" decimals={0}>
|
||||||
{turret.viewRange * viewRangeCoefficient}
|
{stats.viewRange}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<Info name="Camouflage" unit="%" />
|
<Info name="Camouflage" unit="%" />
|
||||||
<InfoWithDelta indent name="Still" decimals={2}>
|
<InfoWithDelta indent name="Still" decimals={0}>
|
||||||
{(tank.camouflage.still + camouflageSumStill) * 100}
|
{stats.camouflageStill * 100}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta indent name="Moving" decimals={2}>
|
<InfoWithDelta indent name="Moving" decimals={0}>
|
||||||
{(tank.camouflage.moving + camouflageSumMoving) * 100}
|
{stats.camouflageMoving * 100}
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta indent name="Shooting still" decimals={2}>
|
<InfoWithDelta indent name="Shooting still" decimals={0}>
|
||||||
{(tank.camouflage.still * gun.camouflageLoss + camouflageSumStill) *
|
{stats.camouflageShootingStill * 100}
|
||||||
100}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta indent name="Shooting on move" decimals={2}>
|
<InfoWithDelta indent name="Shooting moving" decimals={0}>
|
||||||
{(tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving) *
|
{stats.camouflageShootingMoving * 100}
|
||||||
100}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<InfoWithDelta indent name="Caught on fire" decimals={2}>
|
<InfoWithDelta indent name="Caught on fire" decimals={0}>
|
||||||
{(tank.camouflage.onFire * tank.camouflage.still +
|
{stats.camouflageCaughtOnFire * 100}
|
||||||
camouflageSumStill) *
|
|
||||||
100}
|
|
||||||
</InfoWithDelta>
|
</InfoWithDelta>
|
||||||
<Info name="Size" unit="m">
|
<Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
|
||||||
{size[0].toFixed(2)} x {size[2].toFixed(2)} x {size[1].toFixed(2)}
|
{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>
|
</Info>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex direction="column" gap="2">
|
<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) => {
|
{tank.crew.map((member) => {
|
||||||
const count = member.count ?? 1;
|
const count = member.count ?? 1;
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ export function Controls() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handlePoseEvent(event: Pose) {
|
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) {
|
switch (event) {
|
||||||
case Pose.HullDown: {
|
case Pose.HullDown: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LightningBoltIcon } from '@radix-ui/react-icons';
|
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 { PerspectiveCamera } from '@react-three/drei';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { Suspense, use, useEffect, useRef, useState } from 'react';
|
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 { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
|
||||||
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
|
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
|
||||||
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
import { useEquipment } from '../../../../../../hooks/useEquipment';
|
||||||
|
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
|
||||||
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
|
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
|
||||||
|
import { BlitzkriegWormWide } from '../../../../../../icons/BlitzkriegWormWide';
|
||||||
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
|
||||||
import mutateTankopediaPersistent, {
|
import mutateTankopediaPersistent, {
|
||||||
TankopediaMode,
|
TankopediaMode,
|
||||||
@@ -33,7 +35,6 @@ export function TankSandbox() {
|
|||||||
const hasImprovedVerticalStabilizer = useEquipment(122);
|
const hasImprovedVerticalStabilizer = useEquipment(122);
|
||||||
const awaitedModelDefinitions = use(modelDefinitions);
|
const awaitedModelDefinitions = use(modelDefinitions);
|
||||||
const canvasWrapper = useRef<HTMLDivElement>(null);
|
const canvasWrapper = useRef<HTMLDivElement>(null);
|
||||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
|
||||||
const mode = useTankopediaPersistent((state) => state.mode);
|
const mode = useTankopediaPersistent((state) => state.mode);
|
||||||
const protagonist = useDuel((state) => state.protagonist!);
|
const protagonist = useDuel((state) => state.protagonist!);
|
||||||
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
|
||||||
@@ -43,6 +44,7 @@ export function TankSandbox() {
|
|||||||
const wideFormat = useWideFormat();
|
const wideFormat = useWideFormat();
|
||||||
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
|
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
|
||||||
const duel = useDuel();
|
const duel = useDuel();
|
||||||
|
const isFullScreen = useFullScreen();
|
||||||
|
|
||||||
function handlePointerDown() {
|
function handlePointerDown() {
|
||||||
window.addEventListener('pointermove', handlePointerMove);
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
@@ -59,10 +61,6 @@ export function TankSandbox() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleFullScreenChange() {
|
|
||||||
setIsFullScreen(document.fullscreenElement !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePoseEvent(pose: Pose) {
|
function handlePoseEvent(pose: Pose) {
|
||||||
switch (pose) {
|
switch (pose) {
|
||||||
case Pose.HullDown: {
|
case Pose.HullDown: {
|
||||||
@@ -121,10 +119,8 @@ export function TankSandbox() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
poseEvent.on(handlePoseEvent);
|
poseEvent.on(handlePoseEvent);
|
||||||
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
|
||||||
poseEvent.off(handlePoseEvent);
|
poseEvent.off(handlePoseEvent);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -145,117 +141,127 @@ export function TankSandbox() {
|
|||||||
<Theme radius={isFullScreen ? 'none' : undefined}>
|
<Theme radius={isFullScreen ? 'none' : undefined}>
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: isFullScreen ? 'fixed' : 'relative',
|
||||||
border: isFullScreen ? 'none' : 'unset',
|
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}
|
ref={canvasWrapper}
|
||||||
>
|
>
|
||||||
<Theme radius="full" style={{ height: '100%' }}>
|
<Flex
|
||||||
<Flex
|
style={{
|
||||||
style={{
|
height: isFullScreen ? '100%' : '75vh',
|
||||||
height: isFullScreen ? '100%' : '75vh',
|
maxHeight: isFullScreen ? 'unset' : 576,
|
||||||
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
|
<Tabs.List>
|
||||||
value={mode}
|
<Tabs.Trigger value="model">
|
||||||
onValueChange={(mode) => {
|
<Flex gap="2" align="center">
|
||||||
mutateTankopediaPersistent((draft) => {
|
Model
|
||||||
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>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
</Tabs.Trigger>
|
||||||
</div>
|
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.Root>
|
||||||
|
|
||||||
<Options
|
<Text
|
||||||
canvasWrapper={canvasWrapper}
|
color="gray"
|
||||||
isFullScreen={isFullScreen}
|
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} />}
|
<Suspense fallback={<ModelLoader />}>
|
||||||
</Flex>
|
<Lighting />
|
||||||
</Theme>
|
<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>
|
</Card>
|
||||||
</Theme>
|
</Theme>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Html, useProgress } from '@react-three/drei';
|
import { Html, useProgress } from '@react-three/drei';
|
||||||
import { theme } from '../../../../../../../stitches.config';
|
|
||||||
import { Loader } from '../../../../../components/Loader';
|
import { Loader } from '../../../../../components/Loader';
|
||||||
|
|
||||||
export function ModelLoader() {
|
export function ModelLoader() {
|
||||||
@@ -7,11 +6,7 @@ export function ModelLoader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Html center position={[0, 1.5, 0]}>
|
<Html center position={[0, 1.5, 0]}>
|
||||||
<Loader
|
<Loader naked progress={progress / 100} />
|
||||||
naked
|
|
||||||
color={theme.colors.textHighContrast_purple}
|
|
||||||
progress={progress / 100}
|
|
||||||
/>
|
|
||||||
</Html>
|
</Html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ interface OptionsProps {
|
|||||||
canvasWrapper: RefObject<HTMLElement>;
|
canvasWrapper: RefObject<HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
export function Options({ isFullScreen }: OptionsProps) {
|
||||||
const showGrid = useTankopediaPersistent(
|
const showGrid = useTankopediaPersistent(
|
||||||
(state) => state.model.visual.showGrid,
|
(state) => state.model.visual.showGrid,
|
||||||
);
|
);
|
||||||
@@ -50,136 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
|||||||
right: 18,
|
right: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isFullScreen && (
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Trigger>
|
<Button variant="ghost">
|
||||||
<Button variant="ghost">
|
<EyeOpenIcon />
|
||||||
<EyeOpenIcon />
|
</Button>
|
||||||
</Button>
|
</DropdownMenu.Trigger>
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
|
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content>
|
||||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
|
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
|
||||||
Hull down
|
Hull down
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
|
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
|
||||||
Face hug
|
Face hug
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
|
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
|
||||||
Default
|
Default
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
)}
|
|
||||||
|
|
||||||
{!isFullScreen && (
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Trigger>
|
<Button variant="ghost">
|
||||||
<Button variant="ghost">
|
<GearIcon />
|
||||||
<GearIcon />
|
</Button>
|
||||||
</Button>
|
</DropdownMenu.Trigger>
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
|
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content>
|
||||||
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
<DropdownMenu.Label>Armor</DropdownMenu.Label>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={showSpacedArmor}
|
checked={showSpacedArmor}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.showSpacedArmor = checked;
|
draft.model.visual.showSpacedArmor = checked;
|
||||||
});
|
});
|
||||||
mutateTankopediaTemporary((draft) => {
|
mutateTankopediaTemporary((draft) => {
|
||||||
draft.shot = undefined;
|
draft.shot = undefined;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Spaced armor
|
Spaced armor
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={greenPenetration}
|
checked={greenPenetration}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.greenPenetration = checked;
|
draft.model.visual.greenPenetration = checked;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Green penetration
|
Green penetration
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={wireframe}
|
checked={wireframe}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.wireframe = checked;
|
draft.model.visual.wireframe = checked;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Wireframe
|
Wireframe
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={opaque}
|
checked={opaque}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.opaque = checked;
|
draft.model.visual.opaque = checked;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Opaque
|
Opaque
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
|
|
||||||
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
<DropdownMenu.Label>Environment</DropdownMenu.Label>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={showGrid}
|
checked={showGrid}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.showGrid = checked;
|
draft.model.visual.showGrid = checked;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Show grid
|
Show grid
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={showEnvironment}
|
checked={showEnvironment}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.showEnvironment = checked;
|
draft.model.visual.showEnvironment = checked;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
View environment
|
View environment
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
|
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
|
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
|
||||||
|
|
||||||
<DropdownMenu.SubContent>
|
<DropdownMenu.SubContent>
|
||||||
<DropdownMenu.RadioGroup value={environment}>
|
<DropdownMenu.RadioGroup value={environment}>
|
||||||
{ENVIRONMENTS.map((environment) => (
|
{ENVIRONMENTS.map((environment) => (
|
||||||
<DropdownMenu.RadioItem
|
<DropdownMenu.RadioItem
|
||||||
key={environment}
|
key={environment}
|
||||||
value={environment}
|
value={environment}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.model.visual.environment = environment;
|
draft.model.visual.environment = environment;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{environment[0].toUpperCase() + environment.slice(1)}
|
{environment[0].toUpperCase() + environment.slice(1)}
|
||||||
</DropdownMenu.RadioItem>
|
</DropdownMenu.RadioItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.RadioGroup>
|
</DropdownMenu.RadioGroup>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
)}
|
|
||||||
|
|
||||||
{fullScreenAvailable && (
|
{fullScreenAvailable && (
|
||||||
<Button
|
<Button
|
||||||
@@ -187,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isFullScreen) {
|
if (isFullScreen) {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen();
|
||||||
} else canvasWrapper.current?.requestFullscreen();
|
} else document.body.requestFullscreen();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
|
||||||
|
|||||||
@@ -162,7 +162,12 @@ export const TankModel = memo(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function handlePointerMove(event: PointerEvent) {
|
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] = applyPitchYawLimits(
|
||||||
pitch,
|
pitch,
|
||||||
yaw + event.movementX * (Math.PI / canvas.width),
|
yaw + event.movementX * (Math.PI / canvas.width),
|
||||||
@@ -223,7 +228,12 @@ export const TankModel = memo(() => {
|
|||||||
window.addEventListener('pointerup', handlePointerUp);
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
}
|
}
|
||||||
async function handlePointerMove(event: PointerEvent) {
|
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] = applyPitchYawLimits(
|
||||||
pitch - event.movementY * (Math.PI / canvas.height),
|
pitch - event.movementY * (Math.PI / canvas.height),
|
||||||
yaw + event.movementX * (Math.PI / canvas.width),
|
yaw + event.movementX * (Math.PI / canvas.width),
|
||||||
|
|||||||
@@ -46,19 +46,48 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 8,
|
left: 8,
|
||||||
bottom: isFullScreen ? 80 : 8,
|
bottom: isFullScreen ? 80 : 8,
|
||||||
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
gap="4"
|
gap="4"
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
|
||||||
gap="2"
|
|
||||||
style={{
|
|
||||||
width: 256,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
|
radius="full"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
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
|
<TextField.Slot
|
||||||
style={{
|
style={{
|
||||||
@@ -67,46 +96,46 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
|||||||
>
|
>
|
||||||
Yaw
|
Yaw
|
||||||
</TextField.Slot>
|
</TextField.Slot>
|
||||||
<TextField.Input
|
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
|
||||||
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>
|
||||||
|
|
||||||
<TextField.Root
|
<TextField.Root
|
||||||
|
radius="full"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
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
|
<TextField.Slot
|
||||||
style={{
|
style={{
|
||||||
@@ -115,35 +144,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
|||||||
>
|
>
|
||||||
Pitch
|
Pitch
|
||||||
</TextField.Slot>
|
</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
|
<TextField.Slot
|
||||||
style={{
|
style={{
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -155,13 +155,15 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{mode === 'armor' && (
|
{mode === 'armor' && (
|
||||||
<Flex gap="4">
|
<Flex gap="4" style={{ pointerEvents: 'auto' }}>
|
||||||
<QuickEquipmentButton
|
<QuickEquipmentButton
|
||||||
equipment={103}
|
equipment={103}
|
||||||
active={hasCalibratedShells}
|
active={hasCalibratedShells}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateDuel((draft) => {
|
mutateDuel((draft) => {
|
||||||
draft.antagonist!.equipment[0][0] = hasCalibratedShells ? 0 : 1;
|
draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +172,9 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
|
|||||||
active={hasEnhancedArmor}
|
active={hasEnhancedArmor}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateDuel((draft) => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<title>{protagonist.tank.name}</title>
|
|
||||||
|
|
||||||
<Flex gap="6" justify="center" align="center" style={{ padding: 16 }}>
|
<Flex gap="6" justify="center" align="center" style={{ padding: 16 }}>
|
||||||
<Flex gap="2" align="center">
|
<Flex gap="2" align="center">
|
||||||
<Heading
|
<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 { AntagonistBar } from './components/AntagonistBar';
|
||||||
import { Characteristics } from './components/Characteristics';
|
import { Characteristics } from './components/Characteristics';
|
||||||
import { Consumables } from './components/Characteristics/components/Consumables';
|
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 { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
|
||||||
import { Modules } from './components/Characteristics/components/Modules';
|
import { Modules } from './components/Characteristics/components/Modules';
|
||||||
import { Provisions } from './components/Characteristics/components/Provisions';
|
import { Provisions } from './components/Characteristics/components/Provisions';
|
||||||
|
import { Skills } from './components/Characteristics/components/Skills';
|
||||||
import { TankSandbox } from './components/Model/TankSandbox';
|
import { TankSandbox } from './components/Model/TankSandbox';
|
||||||
import { Title } from './components/Title';
|
import { Title } from './components/Title';
|
||||||
|
|
||||||
@@ -63,10 +64,8 @@ export default function Page({ params }: { params: { id: string } }) {
|
|||||||
};
|
};
|
||||||
}, [initialId, assigned]);
|
}, [initialId, assigned]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper color="purple" size="double">
|
<PageWrapper color="purple" size={1600}>
|
||||||
{assigned && (
|
{assigned && (
|
||||||
<>
|
<>
|
||||||
<Title />
|
<Title />
|
||||||
@@ -79,7 +78,7 @@ export default function Page({ params }: { params: { id: string } }) {
|
|||||||
justify="center"
|
justify="center"
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
gap="4"
|
gap="8"
|
||||||
direction="column"
|
direction="column"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -88,15 +87,22 @@ export default function Page({ params }: { params: { id: string } }) {
|
|||||||
position: wideFormat ? 'sticky' : undefined,
|
position: wideFormat ? 'sticky' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TankSandbox />
|
<Flex gap="4" direction="column">
|
||||||
<AntagonistBar />
|
<TankSandbox />
|
||||||
|
<AntagonistBar />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<Flex direction="column" gap="4">
|
<Flex gap="5" wrap="wrap">
|
||||||
<Modules />
|
<Modules />
|
||||||
<Provisions />
|
<Provisions />
|
||||||
<Equipments />
|
|
||||||
<Miscellaneous />
|
|
||||||
<Consumables />
|
<Consumables />
|
||||||
|
|
||||||
|
<Flex gap="5" wrap="wrap">
|
||||||
|
<Skills />
|
||||||
|
<Equipment />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Miscellaneous />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
|||||||
@@ -32,32 +32,31 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
|
|||||||
>
|
>
|
||||||
<CaretLeftIcon />
|
<CaretLeftIcon />
|
||||||
</Button>
|
</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.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>
|
<TextField.Slot>
|
||||||
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
|
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
|
||||||
</TextField.Slot>
|
</TextField.Slot>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { slateDark } from '@radix-ui/colors';
|
import { slateDark } from '@radix-ui/colors';
|
||||||
import {
|
import {
|
||||||
CaretDownIcon,
|
CaretDownIcon,
|
||||||
|
CaretRightIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@radix-ui/react-icons';
|
} from '@radix-ui/react-icons';
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from '@radix-ui/themes';
|
} from '@radix-ui/themes';
|
||||||
import { go } from 'fuzzysort';
|
import { go } from 'fuzzysort';
|
||||||
|
import Link from 'next/link';
|
||||||
import { use, useEffect, useMemo, useRef, useState } from 'react';
|
import { use, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { TreeType } from '../../../../../components/Tanks';
|
import { TreeType } from '../../../../../components/Tanks';
|
||||||
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
|
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
|
||||||
@@ -50,11 +53,16 @@ import { Options } from './components/Options';
|
|||||||
interface TankSearchProps {
|
interface TankSearchProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
onSelect?: (tank: TankDefinition) => void;
|
onSelect?: (tank: TankDefinition) => void;
|
||||||
|
onSelectAll?: (tanks: TankDefinition[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector'];
|
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 tanksPerPage = compact ? 16 : 96;
|
||||||
const nations = use(NATIONS);
|
const nations = use(NATIONS);
|
||||||
const filters = useTankopediaPersistent((state) => state.filters);
|
const filters = useTankopediaPersistent((state) => state.filters);
|
||||||
@@ -151,7 +159,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'survivability.size':
|
case 'survivability.volume':
|
||||||
sorted = filtered.sort((a, b) => {
|
sorted = filtered.sort((a, b) => {
|
||||||
const aTankModelDefinition = awaitedModelDefinitions[a.id];
|
const aTankModelDefinition = awaitedModelDefinitions[a.id];
|
||||||
const bTankModelDefinition = awaitedModelDefinitions[b.id];
|
const bTankModelDefinition = awaitedModelDefinitions[b.id];
|
||||||
@@ -178,6 +186,33 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
});
|
});
|
||||||
break;
|
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':
|
case 'fire.dpm':
|
||||||
sorted = filtered.sort(
|
sorted = filtered.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
@@ -365,20 +400,28 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
.filter(([name]) => name !== 'page')
|
.filter(([name]) => name !== 'page')
|
||||||
.map(([, value]) => value),
|
.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 page = useTankopediaPersistent((state) => state.filters.page);
|
||||||
const wideFormat = typeof window !== 'undefined' && window.innerWidth > 512;
|
|
||||||
const chunkSize = wideFormat ? tanksPerPage / 2 : Infinity;
|
|
||||||
const searchResultsPageSlice = searchResults.slice(
|
const searchResultsPageSlice = searchResults.slice(
|
||||||
page * tanksPerPage,
|
page * tanksPerPage,
|
||||||
(page + 1) * tanksPerPage,
|
(page + 1) * tanksPerPage,
|
||||||
);
|
);
|
||||||
const firstChunk = searchResultsPageSlice.slice(0, chunkSize);
|
const firstChunk = searchResultsPageSlice.slice(
|
||||||
const secondChunk = searchResultsPageSlice.slice(chunkSize);
|
0,
|
||||||
|
Math.ceil(searchResultsPageSlice.length / 2),
|
||||||
|
);
|
||||||
|
const secondChunk = searchResultsPageSlice.slice(
|
||||||
|
Math.ceil(searchResultsPageSlice.length / 2),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSearchedList(searchableTanks);
|
|
||||||
}, [searchableTanks]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
mutateTankopediaPersistent((draft) => {
|
||||||
draft.filters.page = Math.min(
|
draft.filters.page = Math.min(
|
||||||
@@ -392,27 +435,41 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
<>
|
<>
|
||||||
<Flex direction="column" gap="4">
|
<Flex direction="column" gap="4">
|
||||||
<Flex gap="2">
|
<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>
|
<TextField.Slot>
|
||||||
<MagnifyingGlassIcon height="16" width="16" />
|
<MagnifyingGlassIcon height="16" width="16" />
|
||||||
</TextField.Slot>
|
</TextField.Slot>
|
||||||
|
|
||||||
<TextField.Input
|
{searchResults.length > 0 &&
|
||||||
ref={input}
|
input.current &&
|
||||||
placeholder="Search tanks..."
|
input.current.value.length > 0 && (
|
||||||
onChange={(event) => {
|
<TextField.Slot>
|
||||||
const results = go(event.target.value, searchableTanks, {
|
<Button
|
||||||
keys: [
|
variant="ghost"
|
||||||
'name',
|
onClick={() => onSelect(searchResults[0])}
|
||||||
'nameFull',
|
>
|
||||||
'id',
|
{searchResults[0].name} <CaretRightIcon />
|
||||||
] satisfies (keyof TankDefinition)[],
|
</Button>
|
||||||
all: true,
|
</TextField.Slot>
|
||||||
}).map(({ obj }) => obj);
|
)}
|
||||||
|
|
||||||
setSearchedList(results);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
@@ -699,14 +756,24 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
Camouflage shooting
|
Camouflage shooting
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
checked={sort.by === 'survivability.size'}
|
checked={sort.by === 'survivability.volume'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateTankopediaPersistent((draft) => {
|
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.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
@@ -759,132 +826,185 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
<Options />
|
<Options />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex justify="center">
|
<Flex justify="center" gap="4" align="center">
|
||||||
<Text color="gray">
|
<Text color="gray">
|
||||||
Sorting: {SORT_NAMES[sort.by]}, {sort.direction}
|
Sorting: {SORT_NAMES[sort.by]}, {sort.direction}
|
||||||
</Text>
|
</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>
|
</Flex>
|
||||||
|
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
|
<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 &&
|
{!compact &&
|
||||||
searchResultsPageSlice.map((tank) => (
|
searchResultsPageSlice.map((tank) => (
|
||||||
<Card
|
<Link
|
||||||
key={tank.id}
|
href={`/tools/tankopedia/${tank.id}`}
|
||||||
onClick={() => onSelect(tank)}
|
|
||||||
className={styles.listing}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
cursor: 'pointer',
|
minWidth: 256,
|
||||||
height: 64,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Inset
|
<Card
|
||||||
key={tank.id}
|
key={tank.id}
|
||||||
|
className={styles.listing}
|
||||||
style={{
|
style={{
|
||||||
minWidth: 256,
|
cursor: 'pointer',
|
||||||
minHeight: 64,
|
height: 64,
|
||||||
position: 'relative',
|
width: '100%',
|
||||||
display: 'flex',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<Inset
|
||||||
className={styles.flag}
|
key={tank.id}
|
||||||
src={asset(`flags/scratched/${tank.nation}.webp`)}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
minHeight: 64,
|
||||||
height: '100%',
|
position: 'relative',
|
||||||
objectFit: 'contain',
|
display: 'flex',
|
||||||
position: 'absolute',
|
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={styles.listingShadow}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
transition: `box-shadow ${theme.durations.regular}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 256,
|
|
||||||
height: 128,
|
|
||||||
}}
|
|
||||||
className={styles.listingImage}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={tankIcon(tank.id)}
|
className={styles.flag}
|
||||||
|
src={asset(`flags/scratched/${tank.nation}.webp`)}
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
objectPosition: 'left center',
|
position: 'absolute',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Flex
|
<div
|
||||||
align="center"
|
className={styles.listingShadow}
|
||||||
justify="between"
|
style={{
|
||||||
className={styles.listingLabel}
|
width: '100%',
|
||||||
style={{
|
height: '100%',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 8,
|
transition: `box-shadow ${theme.durations.regular}`,
|
||||||
padding: '0 12px',
|
}}
|
||||||
width: '100%',
|
/>
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
<div
|
||||||
>
|
style={{
|
||||||
<Flex align="center" justify="center" gap="1">
|
width: 256,
|
||||||
|
height: 128,
|
||||||
|
}}
|
||||||
|
className={styles.listingImage}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={
|
src={tankIcon(tank.id)}
|
||||||
(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={{
|
style={{
|
||||||
color:
|
objectFit: 'contain',
|
||||||
tank.treeType === 'researchable'
|
objectPosition: 'left center',
|
||||||
? slateDark.slate12
|
|
||||||
: undefined,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: 160,
|
|
||||||
display: 'block',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{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>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Inset>
|
||||||
<Text size="4" color="gray" highContrast={false}>
|
</Card>
|
||||||
{TIER_ROMAN_NUMERALS[tank.tier]}
|
</Link>
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Inset>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{compact && (
|
{compact && firstChunk.length > 0 && (
|
||||||
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
|
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -892,6 +1012,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
|
|||||||
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
|
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
|
<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({
|
export const configuration = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function Page() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper size="double" color="purple">
|
<PageWrapper size={1200} color="purple">
|
||||||
<TankSearch
|
<TankSearch
|
||||||
onSelect={(tank) => {
|
onSelect={(tank) => {
|
||||||
router.push(`./tankopedia/${tank.id}`);
|
router.push(`./tankopedia/${tank.id}`);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import TitleBar from '../components/TitleBar';
|
|||||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||||
import resolveTankId from '../core/blitz/resolveTankId';
|
import resolveTankId from '../core/blitz/resolveTankId';
|
||||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
import { emblemURL } from '../core/blitzkrieg/emblemURL';
|
||||||
import getTankHistories from '../core/blitzkrieg/getTankHistories';
|
import getTankHistories from '../core/blitzkrieg/getTankHistories';
|
||||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||||
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
|
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
|
||||||
@@ -41,7 +41,7 @@ async function render(
|
|||||||
) {
|
) {
|
||||||
const accountInfo = await getAccountInfo(region, id);
|
const accountInfo = await getAccountInfo(region, id);
|
||||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||||
const logo = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
const logo = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||||
const histories = await (type === 'player'
|
const histories = await (type === 'player'
|
||||||
? getPlayerHistories(region, id, {
|
? getPlayerHistories(region, id, {
|
||||||
start,
|
start,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import TierWeights from '../components/TierWeights';
|
|||||||
import TitleBar from '../components/TitleBar';
|
import TitleBar from '../components/TitleBar';
|
||||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
import { emblemURL } from '../core/blitzkrieg/emblemURL';
|
||||||
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
|
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
|
||||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||||
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||||
@@ -40,7 +40,7 @@ async function render(
|
|||||||
) {
|
) {
|
||||||
const { nickname } = await getAccountInfo(region, id);
|
const { nickname } = await getAccountInfo(region, id);
|
||||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||||
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||||
const diffedTankStats = await getStatsInPeriod(
|
const diffedTankStats = await getStatsInPeriod(
|
||||||
region,
|
region,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { teal } from '@radix-ui/colors';
|
|||||||
import { GuildMemberRoleManager } from 'discord.js';
|
import { GuildMemberRoleManager } from 'discord.js';
|
||||||
import markdownEscape from 'markdown-escape';
|
import markdownEscape from 'markdown-escape';
|
||||||
import discord from '../../discord.json' assert { type: 'json' };
|
import discord from '../../discord.json' assert { type: 'json' };
|
||||||
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow';
|
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
|
||||||
import CommandWrapper from '../components/CommandWrapper';
|
import CommandWrapper from '../components/CommandWrapper';
|
||||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
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 addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||||
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
async handler(interaction) {
|
async handler(interaction) {
|
||||||
const { t, translate } = translator(interaction.locale);
|
const { t, translate } = translator(interaction.locale);
|
||||||
const { id, region } = await resolvePlayerFromCommand(interaction);
|
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 accountInfo = await getAccountInfo(region, id);
|
||||||
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
|
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.guildId === discord.sklld_guild_id) {
|
||||||
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
|
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import markdownEscape from 'markdown-escape';
|
||||||
|
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
|
||||||
|
import CommandWrapper from '../components/CommandWrapper';
|
||||||
|
import { DeltaCaret } from '../components/DeltaCaret';
|
||||||
|
import TitleBar from '../components/TitleBar';
|
||||||
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
|
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||||
|
import { getLeagueFromScore } from '../core/blitz/getLeagueFromScore';
|
||||||
|
import getRatingInfo from '../core/blitz/getRatingInfo';
|
||||||
|
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
|
||||||
|
import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon';
|
||||||
|
import { emblemURL } from '../core/blitzkrieg/emblemURL';
|
||||||
|
import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
|
||||||
|
import { webpToPng } from '../core/blitzkrieg/iconPng';
|
||||||
|
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||||
|
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||||
|
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||||
|
import { translator } from '../core/localization/translator';
|
||||||
|
import { deltaBkrlBlitzStats } from '../core/statistics/deltaBkrlBlitzStats';
|
||||||
|
import { BkrlFormat } from '../core/streams/bkrl';
|
||||||
|
import { CommandRegistry } from '../events/interactionCreate';
|
||||||
|
import { theme } from '../stitches.config';
|
||||||
|
|
||||||
|
const LEAGUE_ACCENT = [
|
||||||
|
'_purple',
|
||||||
|
'_sand',
|
||||||
|
'_amber',
|
||||||
|
'_mauve',
|
||||||
|
'_brown',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
|
||||||
|
resolve({
|
||||||
|
command:
|
||||||
|
createLocalizedCommand('rating').addStringOption(addUsernameChoices),
|
||||||
|
|
||||||
|
inProduction: true,
|
||||||
|
inPublic: true,
|
||||||
|
|
||||||
|
async handler(interaction) {
|
||||||
|
const { t, translate } = translator(interaction.locale);
|
||||||
|
const { id, region } = await resolvePlayerFromCommand(interaction);
|
||||||
|
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||||
|
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||||
|
const leaderboard = await getArchivedRatingMidnightLeaderboard(
|
||||||
|
region,
|
||||||
|
53,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (leaderboard?.format === BkrlFormat.Base) {
|
||||||
|
throw new Error(
|
||||||
|
'Encountered bkrl minimal format in latest season. Wait till next cron?',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratingInfo = await getRatingInfo(region);
|
||||||
|
const accountInfo = await getAccountInfo(region, id, [
|
||||||
|
'statistics.rating',
|
||||||
|
]);
|
||||||
|
const statsIndex = leaderboard?.entries.findIndex(
|
||||||
|
(entry) => entry.id === id,
|
||||||
|
);
|
||||||
|
const statsA = leaderboard ? leaderboard.entries[statsIndex!] : null;
|
||||||
|
const positionA = leaderboard ? statsIndex! + 1 : undefined;
|
||||||
|
const statsB1 = accountInfo.statistics.rating!;
|
||||||
|
const [statsB2] = (await getRatingNeighbors(region, id, 0)).neighbors;
|
||||||
|
const league = getLeagueFromScore(statsB2.score);
|
||||||
|
|
||||||
|
if (ratingInfo.detail) {
|
||||||
|
throw new Error(`Rating info not available for ${region}`);
|
||||||
|
}
|
||||||
|
if (!statsA) {
|
||||||
|
return t`bot.commands.rating.errors.unrecorded_stats`;
|
||||||
|
}
|
||||||
|
if (!statsB1) {
|
||||||
|
return translate('bot.commands.rating.errors.no_participation', [
|
||||||
|
markdownEscape(accountInfo.nickname),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reward = ratingInfo.rewards.find(
|
||||||
|
(reward) =>
|
||||||
|
statsB2.number >= reward.from_position &&
|
||||||
|
statsB2.number <= reward.to_position,
|
||||||
|
);
|
||||||
|
const positionDelta = leaderboard
|
||||||
|
? statsB2.number - positionA!
|
||||||
|
: undefined;
|
||||||
|
const delta = deltaBkrlBlitzStats(
|
||||||
|
statsA,
|
||||||
|
accountInfo.statistics.rating!,
|
||||||
|
statsB2,
|
||||||
|
);
|
||||||
|
const leagueTheme = LEAGUE_ACCENT[league.index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandWrapper>
|
||||||
|
<TitleBar
|
||||||
|
title={accountInfo.nickname}
|
||||||
|
image={clanImage}
|
||||||
|
description={`${t`bot.commands.rating.body.subtitle`} • ${new Date().toLocaleDateString(
|
||||||
|
interaction.locale,
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: theme.colors[`appBackground2${leagueTheme}`],
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${theme.colors[`componentInteractive${leagueTheme}`]}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reward === undefined && (
|
||||||
|
<Glow
|
||||||
|
endOpacity={3 / 4}
|
||||||
|
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||||
|
style={{
|
||||||
|
transform: 'translateX(-16px) rotate(90deg)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
src={normalizeLeagueIcon(
|
||||||
|
ratingInfo.leagues[league.index].big_icon,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: theme.colors[`textHighContrast${leagueTheme}`],
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 900,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statsB2.score.toLocaleString(interaction.locale)}
|
||||||
|
</span>
|
||||||
|
{delta.score !== 0 && (
|
||||||
|
<>
|
||||||
|
<DeltaCaret delta={delta.score} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
delta.score > 0
|
||||||
|
? theme.colors.textLowContrast_green
|
||||||
|
: theme.colors.textLowContrast_red,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.abs(delta.score).toLocaleString(
|
||||||
|
interaction.locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: theme.colors[`textLowContrast${leagueTheme}`],
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{statsB2.number.toLocaleString(interaction.locale)}
|
||||||
|
</span>
|
||||||
|
{positionDelta !== undefined && positionDelta !== 0 && (
|
||||||
|
<>
|
||||||
|
<DeltaCaret delta={positionDelta} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
positionDelta > 0
|
||||||
|
? theme.colors.textLowContrast_green
|
||||||
|
: theme.colors.textLowContrast_red,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.abs(positionDelta).toLocaleString(
|
||||||
|
interaction.locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reward && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: theme.colors[`textHighContrast${leagueTheme}`],
|
||||||
|
fontSize: 16,
|
||||||
|
maxWidth: 136,
|
||||||
|
textAlign: 'right',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reward.type === 'vehicle'
|
||||||
|
? reward.vehicle.user_string
|
||||||
|
: reward.stuff.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: theme.colors[`textLowContrast${leagueTheme}`],
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reward
|
||||||
|
{reward.type === 'stuff' ? ` x${reward.count}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
src={await webpToPng(
|
||||||
|
reward.type === 'vehicle'
|
||||||
|
? reward.vehicle.preview_image_url
|
||||||
|
: reward.stuff.image_url,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reward === undefined && (
|
||||||
|
<Glow
|
||||||
|
endOpacity={3 / 4}
|
||||||
|
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||||
|
style={{
|
||||||
|
transform: 'translateX(16px) rotate(-90deg)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* separated because fragment middle man invalidates relative parent: https://github.com/vercel/satori/issues/605 */}
|
||||||
|
{reward && (
|
||||||
|
<Glow
|
||||||
|
direction="reverse"
|
||||||
|
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -40,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{reward && (
|
||||||
|
<Glow
|
||||||
|
color={theme.colors[`solidBackground${leagueTheme}`]}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -40,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{delta.battles > 0 && (
|
||||||
|
<div style={{ padding: '8px 0', display: 'flex' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: theme.colors.textHighContrast,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{delta.battles.toLocaleString(interaction.locale)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textLowContrast,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Battles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: theme.colors.textHighContrast,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(100 * (delta.wins / delta.battles)).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textLowContrast,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Winrate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: theme.colors.textHighContrast,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(delta.damage / delta.battles).toLocaleString(
|
||||||
|
interaction.locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textLowContrast,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Damage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: theme.colors.textHighContrast,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(100 * (delta.survived / delta.battles)).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textLowContrast,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Survival
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: theme.colors.textHighContrast,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(delta.kills / delta.battles).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textLowContrast,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kills
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandWrapper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import {
|
||||||
|
APIApplicationCommandOptionChoice,
|
||||||
|
Locale,
|
||||||
|
SlashCommandSubcommandBuilder,
|
||||||
|
} from 'discord.js';
|
||||||
|
import { range } from 'lodash';
|
||||||
|
import markdownEscape from 'markdown-escape';
|
||||||
|
import CommandWrapper from '../components/CommandWrapper';
|
||||||
|
import * as Leaderboard from '../components/Leaderboard';
|
||||||
|
import TitleBar from '../components/TitleBar';
|
||||||
|
import { LEAGUES } from '../constants/leagues';
|
||||||
|
import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../constants/rating';
|
||||||
|
import { Region } from '../constants/regions';
|
||||||
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
|
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||||
|
import getRatingInfo from '../core/blitz/getRatingInfo';
|
||||||
|
import { getRatingLeague } from '../core/blitz/getRatingLeague';
|
||||||
|
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
|
||||||
|
import { isOnGoingRatingSeason } from '../core/blitz/isOnGoingRatingSeason';
|
||||||
|
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
|
||||||
|
import getArchivedRatingInfo from '../core/blitzkrieg/getArchivedRatingInfo';
|
||||||
|
import { getArchivedRatingLeaderboard } from '../core/blitzkrieg/getArchivedRatingLeaderboard';
|
||||||
|
import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
|
||||||
|
import addRegionChoices from '../core/discord/addRegionChoices';
|
||||||
|
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||||
|
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||||
|
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||||
|
import { localizationObject } from '../core/discord/localizationObject';
|
||||||
|
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
||||||
|
import { translator } from '../core/localization/translator';
|
||||||
|
import { BkrlDiscriminatedEntries } from '../core/streams/bkrl';
|
||||||
|
import { CommandRegistry } from '../events/interactionCreate';
|
||||||
|
import { UserError } from '../hooks/userError';
|
||||||
|
|
||||||
|
export interface RatingPlayer {
|
||||||
|
spa_id: number;
|
||||||
|
mmr: number;
|
||||||
|
season_number: number;
|
||||||
|
calibrationBattlesLeft: number;
|
||||||
|
number: number;
|
||||||
|
percentile: number;
|
||||||
|
skip: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
score: number;
|
||||||
|
nickname: string;
|
||||||
|
clan_tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeagueTop {
|
||||||
|
result: RatingPlayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RatingReward = {
|
||||||
|
from_position: number;
|
||||||
|
to_position: number;
|
||||||
|
count: number;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
type: 'vehicle';
|
||||||
|
|
||||||
|
vehicle: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nation: string;
|
||||||
|
subnation: string;
|
||||||
|
use_subnation_flag: boolean;
|
||||||
|
type_slug: string;
|
||||||
|
level: number;
|
||||||
|
roman_level: string;
|
||||||
|
user_string: string;
|
||||||
|
image_url: string;
|
||||||
|
preview_image_url: string;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_collectible: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'stuff';
|
||||||
|
|
||||||
|
stuff: {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
title: string;
|
||||||
|
image_url: string;
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
sizes: {};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface RatingLeague {
|
||||||
|
title: string;
|
||||||
|
small_icon: string;
|
||||||
|
big_icon: string;
|
||||||
|
background: string;
|
||||||
|
index: number;
|
||||||
|
percentile: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RatingInfo =
|
||||||
|
| {
|
||||||
|
detail: undefined;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
icon: string | null;
|
||||||
|
start_at: string;
|
||||||
|
finish_at: string;
|
||||||
|
current_season: number;
|
||||||
|
updated_at: string;
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
rewards: RatingReward[];
|
||||||
|
leagues: RatingLeague[];
|
||||||
|
}
|
||||||
|
| { detail: { error: string } };
|
||||||
|
|
||||||
|
export interface RatingNeighbors {
|
||||||
|
neighbors: RatingPlayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimplifiedPlayer {
|
||||||
|
id: number;
|
||||||
|
score: number;
|
||||||
|
position: number;
|
||||||
|
clan: string | undefined;
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
|
||||||
|
export const ratingLeaderboardCommand = new Promise<CommandRegistry>(
|
||||||
|
async (resolve) => {
|
||||||
|
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
|
||||||
|
const onGoingSeason = await isOnGoingRatingSeason();
|
||||||
|
const seasonNumbers = range(
|
||||||
|
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
|
||||||
|
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
function addOptions(option: SlashCommandSubcommandBuilder) {
|
||||||
|
const { t, translate } = translator(Locale.EnglishUS);
|
||||||
|
|
||||||
|
return option
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName(t`bot.commands.rating_leaderboard.options.limit`)
|
||||||
|
.setNameLocalizations(
|
||||||
|
localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.limit',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
translate(
|
||||||
|
'bot.commands.rating_leaderboard.options.limit.description',
|
||||||
|
[`${DEFAULT_LIMIT}`],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setDescriptionLocalizations(
|
||||||
|
localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.limit.description',
|
||||||
|
[`${DEFAULT_LIMIT}`],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setRequired(false)
|
||||||
|
.setMinValue(5)
|
||||||
|
.setMaxValue(30),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName(t`bot.commands.rating_leaderboard.options.season`)
|
||||||
|
.setNameLocalizations(
|
||||||
|
localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.season',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
translate(
|
||||||
|
'bot.commands.rating_leaderboard.options.season.description',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setDescriptionLocalizations(
|
||||||
|
localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.season.description',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
...seasonNumbers
|
||||||
|
.map((number, index) => {
|
||||||
|
const current =
|
||||||
|
index === seasonNumbers.length - 1 && onGoingSeason;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: current
|
||||||
|
? translate(
|
||||||
|
'bot.commands.rating_leaderboard.options.season.choices.current',
|
||||||
|
[`${number}`],
|
||||||
|
)
|
||||||
|
: `${number}`,
|
||||||
|
value: `${number}`,
|
||||||
|
name_localizations: current
|
||||||
|
? localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.season.choices.current',
|
||||||
|
[`${number}`],
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
} satisfies APIApplicationCommandOptionChoice<string>;
|
||||||
|
})
|
||||||
|
.reverse(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
inProduction: true,
|
||||||
|
inPublic: true,
|
||||||
|
|
||||||
|
command: createLocalizedCommand('rating-leaderboard', [
|
||||||
|
{
|
||||||
|
subcommand: 'neighbors',
|
||||||
|
modify(option: SlashCommandSubcommandBuilder) {
|
||||||
|
option.addStringOption(addUsernameChoices);
|
||||||
|
addOptions(option);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subcommand: 'league',
|
||||||
|
modify(option: SlashCommandSubcommandBuilder) {
|
||||||
|
option
|
||||||
|
.addStringOption((option) => {
|
||||||
|
const { t, translate } = translator(Locale.EnglishUS);
|
||||||
|
|
||||||
|
return option
|
||||||
|
.setName(t`bot.commands.rating_leaderboard.options.league`)
|
||||||
|
.setNameLocalizations(
|
||||||
|
localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.league',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setDescription(
|
||||||
|
translate(
|
||||||
|
'bot.commands.rating_leaderboard.options.league.description',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setDescriptionLocalizations(
|
||||||
|
localizationObject(
|
||||||
|
'bot.commands.rating_leaderboard.options.league.description',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addChoices(
|
||||||
|
...LEAGUES.map(
|
||||||
|
({ name }, value) =>
|
||||||
|
({
|
||||||
|
name: translate(`common.leagues.${name}`),
|
||||||
|
value: `${value}`,
|
||||||
|
name_localizations: localizationObject(
|
||||||
|
`common.leagues.${name}`,
|
||||||
|
),
|
||||||
|
}) satisfies APIApplicationCommandOptionChoice<string>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setRequired(true);
|
||||||
|
})
|
||||||
|
.addStringOption(addRegionChoices);
|
||||||
|
addOptions(option);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
|
// BIG TODO: title seems to be in english
|
||||||
|
|
||||||
|
async handler(interaction) {
|
||||||
|
const { t, translate } = translator(interaction.locale);
|
||||||
|
const subcommand = interaction.options.getSubcommand(true) as
|
||||||
|
| 'league'
|
||||||
|
| 'neighbors';
|
||||||
|
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
|
||||||
|
const seasonOption = interaction.options.getString('season');
|
||||||
|
const season = seasonOption
|
||||||
|
? parseInt(seasonOption)
|
||||||
|
: onGoingSeason
|
||||||
|
? latestArchivedSeasonNumber + 1
|
||||||
|
: latestArchivedSeasonNumber;
|
||||||
|
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
|
||||||
|
let titleName: string;
|
||||||
|
let titleImage: string | undefined;
|
||||||
|
let titleDescription: string;
|
||||||
|
let playersAfter: number;
|
||||||
|
let players: SimplifiedPlayer[] | undefined;
|
||||||
|
let midnightLeaderboard: BkrlDiscriminatedEntries | null;
|
||||||
|
let playerId: number | undefined;
|
||||||
|
let regionRatingInfo: RatingInfo;
|
||||||
|
|
||||||
|
if (subcommand === 'league') {
|
||||||
|
const leagueIndex = parseInt(
|
||||||
|
interaction.options.getString('league')!,
|
||||||
|
);
|
||||||
|
const region = interaction.options.getString('region') as Region;
|
||||||
|
regionRatingInfo = await (isCurrentSeason
|
||||||
|
? getRatingInfo(region)
|
||||||
|
: getArchivedRatingInfo(region, season!));
|
||||||
|
|
||||||
|
if (regionRatingInfo.detail !== undefined)
|
||||||
|
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
|
||||||
|
|
||||||
|
const result = isCurrentSeason
|
||||||
|
? await getRatingLeague(region, leagueIndex).then(async (result) =>
|
||||||
|
result.slice(0, limit).map(
|
||||||
|
(player) =>
|
||||||
|
({
|
||||||
|
id: player.spa_id,
|
||||||
|
score: player.score,
|
||||||
|
position: player.number,
|
||||||
|
clan: player.clan_tag,
|
||||||
|
nickname: player.nickname,
|
||||||
|
}) satisfies SimplifiedPlayer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: await getArchivedRatingLeaderboard(region, season!).then(
|
||||||
|
async (players) => {
|
||||||
|
const firstPlayerIndex =
|
||||||
|
leagueIndex === 0
|
||||||
|
? 0
|
||||||
|
: players.entries.findIndex(
|
||||||
|
(player) =>
|
||||||
|
player.score < LEAGUES[leagueIndex - 1].minScore,
|
||||||
|
);
|
||||||
|
const lastPlayerIndex = firstPlayerIndex + limit - 1;
|
||||||
|
const trimmed = players.entries.slice(
|
||||||
|
firstPlayerIndex,
|
||||||
|
lastPlayerIndex + 1,
|
||||||
|
);
|
||||||
|
const clanData = await getClanAccountInfo(
|
||||||
|
region,
|
||||||
|
trimmed.map(({ id }) => id),
|
||||||
|
['clan'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return trimmed.map(
|
||||||
|
(player, index) =>
|
||||||
|
({
|
||||||
|
id: player.id,
|
||||||
|
score: player.score,
|
||||||
|
position: firstPlayerIndex + index + 1,
|
||||||
|
clan: clanData[player.id]?.clan?.tag,
|
||||||
|
nickname:
|
||||||
|
clanData[index]?.account_name ??
|
||||||
|
translate(
|
||||||
|
'bot.commands.rating_leaderboard.body.deleted_player',
|
||||||
|
[`${player.id}`],
|
||||||
|
),
|
||||||
|
}) satisfies SimplifiedPlayer,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const leagueInfo = regionRatingInfo.leagues[leagueIndex];
|
||||||
|
midnightLeaderboard = isCurrentSeason
|
||||||
|
? await getArchivedRatingMidnightLeaderboard(
|
||||||
|
region,
|
||||||
|
regionRatingInfo.current_season,
|
||||||
|
)
|
||||||
|
: await getArchivedRatingMidnightLeaderboard(region, season!);
|
||||||
|
|
||||||
|
players = result;
|
||||||
|
playersAfter =
|
||||||
|
regionRatingInfo.count - result[result.length - 1].position;
|
||||||
|
titleName = `${
|
||||||
|
leagueInfo
|
||||||
|
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
|
||||||
|
: ''
|
||||||
|
} - ${translate(`common.regions.short.${region}`)}`;
|
||||||
|
titleImage = leagueInfo.big_icon.startsWith('http')
|
||||||
|
? leagueInfo.big_icon
|
||||||
|
: `https:${leagueInfo.big_icon}`;
|
||||||
|
titleDescription = translate(
|
||||||
|
'bot.commands.rating_leaderboard.body.top_players',
|
||||||
|
[`${limit}`],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const { region, id } = await resolvePlayerFromCommand(interaction);
|
||||||
|
regionRatingInfo = isCurrentSeason
|
||||||
|
? await getRatingInfo(region)
|
||||||
|
: await getArchivedRatingInfo(region, season!);
|
||||||
|
|
||||||
|
if (regionRatingInfo.detail !== undefined)
|
||||||
|
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
|
||||||
|
|
||||||
|
const accountInfo = await getAccountInfo(region, id);
|
||||||
|
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||||
|
const neighbors = isCurrentSeason
|
||||||
|
? await getRatingNeighbors(region, id, Math.round(limit / 2)).then(
|
||||||
|
(players) =>
|
||||||
|
players.neighbors.map(
|
||||||
|
(player) =>
|
||||||
|
({
|
||||||
|
id: player.spa_id,
|
||||||
|
position: player.number,
|
||||||
|
score: player.score,
|
||||||
|
clan: player.clan_tag,
|
||||||
|
nickname: player.nickname,
|
||||||
|
}) satisfies SimplifiedPlayer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: await getArchivedRatingLeaderboard(region, season!).then(
|
||||||
|
async (players) => {
|
||||||
|
const playerIndex = players.entries.findIndex(
|
||||||
|
(player) => player.id === id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playerIndex === -1) {
|
||||||
|
throw new UserError(
|
||||||
|
translate(
|
||||||
|
'bot.commands.rating_leaderboard.body.no_participation',
|
||||||
|
[markdownEscape(accountInfo.nickname), `${season}`],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfRange = Math.round(limit / 2);
|
||||||
|
const trimmed = players.entries.slice(
|
||||||
|
playerIndex - halfRange,
|
||||||
|
playerIndex + halfRange + 1,
|
||||||
|
);
|
||||||
|
const clanData = await getClanAccountInfo(
|
||||||
|
region,
|
||||||
|
trimmed.map((player) => player.id),
|
||||||
|
['clan'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return trimmed.map((player, index) => ({
|
||||||
|
id: player.id,
|
||||||
|
score: player.score,
|
||||||
|
position: playerIndex + index + 1,
|
||||||
|
clan: clanData[index]?.clan?.tag,
|
||||||
|
nickname: clanData[index]
|
||||||
|
? clanData[index]!.account_name
|
||||||
|
: `Deleted ${player.id}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
midnightLeaderboard = isCurrentSeason
|
||||||
|
? await getArchivedRatingMidnightLeaderboard(
|
||||||
|
region,
|
||||||
|
regionRatingInfo.current_season,
|
||||||
|
)
|
||||||
|
: await getArchivedRatingMidnightLeaderboard(region, season!);
|
||||||
|
|
||||||
|
players = neighbors;
|
||||||
|
titleImage = clan
|
||||||
|
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
|
||||||
|
: undefined;
|
||||||
|
titleName = neighbors ? accountInfo.nickname : '';
|
||||||
|
titleDescription = t`bot.commands.rating_leaderboard.body.neighbors`;
|
||||||
|
playersAfter =
|
||||||
|
regionRatingInfo.count - neighbors[neighbors.length - 1].position;
|
||||||
|
playerId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = players.map((player) => {
|
||||||
|
const midnightIndex = midnightLeaderboard?.entries.findIndex(
|
||||||
|
(item) => item.id === player.id,
|
||||||
|
);
|
||||||
|
const midnightScore =
|
||||||
|
midnightIndex === undefined
|
||||||
|
? player.score
|
||||||
|
: midnightLeaderboard!.entries[midnightIndex].score;
|
||||||
|
const midnightPosition =
|
||||||
|
midnightIndex === undefined ? player.position : midnightIndex + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Leaderboard.Item
|
||||||
|
nickname={player.nickname}
|
||||||
|
score={player.score}
|
||||||
|
position={player.position}
|
||||||
|
clan={player.clan}
|
||||||
|
deltaScore={player.score - midnightScore}
|
||||||
|
deltaPosition={midnightPosition - player.position}
|
||||||
|
key={player.id}
|
||||||
|
highlight={player.id === playerId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandWrapper>
|
||||||
|
<TitleBar
|
||||||
|
title={titleName}
|
||||||
|
image={titleImage}
|
||||||
|
description={titleDescription}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{items && (
|
||||||
|
<Leaderboard.Root>
|
||||||
|
{items}
|
||||||
|
{playersAfter > 0 && (
|
||||||
|
<Leaderboard.Gap
|
||||||
|
message={translate(
|
||||||
|
'bot.commands.rating_leaderboard.body.more',
|
||||||
|
[`${playersAfter.toLocaleString(interaction.locale)}`],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Leaderboard.Root>
|
||||||
|
)}
|
||||||
|
</CommandWrapper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
autocomplete: autocompleteUsername,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
import {
|
|
||||||
APIApplicationCommandOptionChoice,
|
|
||||||
Locale,
|
|
||||||
SlashCommandSubcommandBuilder,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { range } from 'lodash';
|
|
||||||
import markdownEscape from 'markdown-escape';
|
|
||||||
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
|
|
||||||
import CommandWrapper from '../components/CommandWrapper';
|
|
||||||
import * as Leaderboard from '../components/Leaderboard';
|
|
||||||
import TitleBar from '../components/TitleBar';
|
|
||||||
import { LEAGUES } from '../constants/leagues';
|
|
||||||
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../constants/ratings';
|
|
||||||
import { Region } from '../constants/regions';
|
|
||||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
|
||||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
|
||||||
import getRatingsInfo from '../core/blitz/getRatingsInfo';
|
|
||||||
import { getRatingsLeague } from '../core/blitz/getRatingsLeague';
|
|
||||||
import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors';
|
|
||||||
import { isOnGoingRatingsSeason } from '../core/blitz/isOnGoingRatingsSeason';
|
|
||||||
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
|
|
||||||
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
|
|
||||||
import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard';
|
|
||||||
import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard';
|
|
||||||
import addRegionChoices from '../core/discord/addRegionChoices';
|
|
||||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
|
||||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
|
||||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
|
||||||
import { localizationObject } from '../core/discord/localizationObject';
|
|
||||||
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
|
|
||||||
import { translator } from '../core/localization/translator';
|
|
||||||
import { CommandRegistry } from '../events/interactionCreate';
|
|
||||||
import { UserError } from '../hooks/userError';
|
|
||||||
|
|
||||||
export interface RatingsPlayer {
|
|
||||||
spa_id: number;
|
|
||||||
mmr: number;
|
|
||||||
season_number: number;
|
|
||||||
calibrationBattlesLeft: number;
|
|
||||||
number: number;
|
|
||||||
percentile: number;
|
|
||||||
skip: boolean;
|
|
||||||
updated_at: string;
|
|
||||||
score: number;
|
|
||||||
nickname: string;
|
|
||||||
clan_tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeagueTop {
|
|
||||||
result: RatingsPlayer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RatingsReward = {
|
|
||||||
from_position: number;
|
|
||||||
to_position: number;
|
|
||||||
count: number;
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
type: 'vehicle';
|
|
||||||
|
|
||||||
vehicle: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
nation: string;
|
|
||||||
subnation: string;
|
|
||||||
use_subnation_flag: boolean;
|
|
||||||
type_slug: string;
|
|
||||||
level: number;
|
|
||||||
roman_level: string;
|
|
||||||
user_string: string;
|
|
||||||
image_url: string;
|
|
||||||
preview_url: string;
|
|
||||||
is_premium: boolean;
|
|
||||||
is_collectible: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'stuff';
|
|
||||||
|
|
||||||
stuff: {
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
title: string;
|
|
||||||
image_url: string;
|
|
||||||
type: string;
|
|
||||||
|
|
||||||
sizes: {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface RatingsLeague {
|
|
||||||
title: string;
|
|
||||||
small_icon: string;
|
|
||||||
big_icon: string;
|
|
||||||
background: string;
|
|
||||||
index: number;
|
|
||||||
percentile: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RatingsInfo =
|
|
||||||
| {
|
|
||||||
detail: undefined;
|
|
||||||
|
|
||||||
title: string;
|
|
||||||
icon: string | null;
|
|
||||||
start_at: string;
|
|
||||||
finish_at: string;
|
|
||||||
current_season: number;
|
|
||||||
updated_at: string;
|
|
||||||
count: number;
|
|
||||||
|
|
||||||
rewards: RatingsReward[];
|
|
||||||
leagues: RatingsLeague[];
|
|
||||||
}
|
|
||||||
| { detail: { error: string } };
|
|
||||||
|
|
||||||
export interface RatingsNeighbors {
|
|
||||||
neighbors: RatingsPlayer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SimplifiedPlayer {
|
|
||||||
id: number;
|
|
||||||
score: number;
|
|
||||||
position: number;
|
|
||||||
clan: string | undefined;
|
|
||||||
nickname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 10;
|
|
||||||
|
|
||||||
export const ratingsCommand = new Promise<CommandRegistry>(async (resolve) => {
|
|
||||||
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
|
|
||||||
const onGoingSeason = await isOnGoingRatingsSeason();
|
|
||||||
const seasonNumbers = range(
|
|
||||||
FIRST_ARCHIVED_RATINGS_SEASON,
|
|
||||||
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
function addOptions(option: SlashCommandSubcommandBuilder) {
|
|
||||||
const { t, translate } = translator(Locale.EnglishUS);
|
|
||||||
|
|
||||||
return option
|
|
||||||
.addIntegerOption((option) =>
|
|
||||||
option
|
|
||||||
.setName(t`bot.commands.ratings.options.limit`)
|
|
||||||
.setNameLocalizations(
|
|
||||||
localizationObject('bot.commands.ratings.options.limit'),
|
|
||||||
)
|
|
||||||
.setDescription(
|
|
||||||
translate('bot.commands.ratings.options.limit.description', [
|
|
||||||
`${DEFAULT_LIMIT}`,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.setDescriptionLocalizations(
|
|
||||||
localizationObject(
|
|
||||||
'bot.commands.ratings.options.limit.description',
|
|
||||||
[`${DEFAULT_LIMIT}`],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setRequired(false)
|
|
||||||
.setMinValue(5)
|
|
||||||
.setMaxValue(30),
|
|
||||||
)
|
|
||||||
.addStringOption((option) =>
|
|
||||||
option
|
|
||||||
.setName(t`bot.commands.ratings.options.season`)
|
|
||||||
.setNameLocalizations(
|
|
||||||
localizationObject('bot.commands.ratings.options.season'),
|
|
||||||
)
|
|
||||||
.setDescription(
|
|
||||||
translate('bot.commands.ratings.options.season.description'),
|
|
||||||
)
|
|
||||||
.setDescriptionLocalizations(
|
|
||||||
localizationObject(
|
|
||||||
'bot.commands.ratings.options.season.description',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setRequired(false)
|
|
||||||
.addChoices(
|
|
||||||
...seasonNumbers
|
|
||||||
.map((number, index) => {
|
|
||||||
const current =
|
|
||||||
index === seasonNumbers.length - 1 && onGoingSeason;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: current
|
|
||||||
? translate(
|
|
||||||
'bot.commands.ratings.options.season.choices.current',
|
|
||||||
[`${number}`],
|
|
||||||
)
|
|
||||||
: `${number}`,
|
|
||||||
value: `${number}`,
|
|
||||||
name_localizations: current
|
|
||||||
? localizationObject(
|
|
||||||
'bot.commands.ratings.options.season.choices.current',
|
|
||||||
[`${number}`],
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
} satisfies APIApplicationCommandOptionChoice<string>;
|
|
||||||
})
|
|
||||||
.reverse(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
inProduction: true,
|
|
||||||
inPublic: true,
|
|
||||||
|
|
||||||
command: createLocalizedCommand('ratings', [
|
|
||||||
{
|
|
||||||
subcommand: 'neighbors',
|
|
||||||
modify(option: SlashCommandSubcommandBuilder) {
|
|
||||||
option.addStringOption(addUsernameChoices);
|
|
||||||
addOptions(option);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subcommand: 'league',
|
|
||||||
modify(option: SlashCommandSubcommandBuilder) {
|
|
||||||
option
|
|
||||||
.addStringOption((option) => {
|
|
||||||
const { t, translate } = translator(Locale.EnglishUS);
|
|
||||||
|
|
||||||
return option
|
|
||||||
.setName(t`bot.commands.ratings.options.league`)
|
|
||||||
.setNameLocalizations(
|
|
||||||
localizationObject('bot.commands.ratings.options.league'),
|
|
||||||
)
|
|
||||||
.setDescription(
|
|
||||||
translate('bot.commands.ratings.options.league.description'),
|
|
||||||
)
|
|
||||||
.setDescriptionLocalizations(
|
|
||||||
localizationObject(
|
|
||||||
'bot.commands.ratings.options.league.description',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.addChoices(
|
|
||||||
...LEAGUES.map(
|
|
||||||
({ name }, value) =>
|
|
||||||
({
|
|
||||||
name: translate(`common.leagues.${name}`),
|
|
||||||
value: `${value}`,
|
|
||||||
name_localizations: localizationObject(
|
|
||||||
`common.leagues.${name}`,
|
|
||||||
),
|
|
||||||
}) satisfies APIApplicationCommandOptionChoice<string>,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setRequired(true);
|
|
||||||
})
|
|
||||||
.addStringOption(addRegionChoices);
|
|
||||||
addOptions(option);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
|
|
||||||
// BIG TODO: title seems to be in english
|
|
||||||
|
|
||||||
async handler(interaction) {
|
|
||||||
const { t, translate } = translator(interaction.locale);
|
|
||||||
const subcommand = interaction.options.getSubcommand(true) as
|
|
||||||
| 'league'
|
|
||||||
| 'neighbors';
|
|
||||||
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
|
|
||||||
const seasonOption = interaction.options.getString('season');
|
|
||||||
const season = seasonOption
|
|
||||||
? parseInt(seasonOption)
|
|
||||||
: onGoingSeason
|
|
||||||
? latestArchivedSeasonNumber + 1
|
|
||||||
: latestArchivedSeasonNumber;
|
|
||||||
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
|
|
||||||
let titleName: string;
|
|
||||||
let titleImage: string | undefined;
|
|
||||||
let titleDescription: string;
|
|
||||||
let playersAfter: number;
|
|
||||||
let players: SimplifiedPlayer[] | undefined;
|
|
||||||
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
|
|
||||||
let playerId: number | undefined;
|
|
||||||
let regionRatingsInfo: RatingsInfo;
|
|
||||||
|
|
||||||
if (subcommand === 'league') {
|
|
||||||
const leagueIndex = parseInt(interaction.options.getString('league')!);
|
|
||||||
const region = interaction.options.getString('region') as Region;
|
|
||||||
regionRatingsInfo = await (isCurrentSeason
|
|
||||||
? getRatingsInfo(region)
|
|
||||||
: getArchivedRatingsInfo(region, season!));
|
|
||||||
|
|
||||||
if (regionRatingsInfo.detail !== undefined)
|
|
||||||
return t`bot.commands.ratings.body.no_ongoing_season`;
|
|
||||||
|
|
||||||
const result = isCurrentSeason
|
|
||||||
? await getRatingsLeague(region, leagueIndex).then(async (result) =>
|
|
||||||
result.slice(0, limit).map(
|
|
||||||
(player) =>
|
|
||||||
({
|
|
||||||
id: player.spa_id,
|
|
||||||
score: player.score,
|
|
||||||
position: player.number,
|
|
||||||
clan: player.clan_tag,
|
|
||||||
nickname: player.nickname,
|
|
||||||
}) satisfies SimplifiedPlayer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: await getArchivedRatingsLeaderboard(region, season!).then(
|
|
||||||
async (players) => {
|
|
||||||
const firstPlayerIndex =
|
|
||||||
leagueIndex === 0
|
|
||||||
? 0
|
|
||||||
: players.findIndex(
|
|
||||||
(player) =>
|
|
||||||
player.score < LEAGUES[leagueIndex - 1].minScore,
|
|
||||||
);
|
|
||||||
const lastPlayerIndex = firstPlayerIndex + limit - 1;
|
|
||||||
const trimmed = players.slice(
|
|
||||||
firstPlayerIndex,
|
|
||||||
lastPlayerIndex + 1,
|
|
||||||
);
|
|
||||||
const clanData = await getClanAccountInfo(
|
|
||||||
region,
|
|
||||||
trimmed.map(({ id }) => id),
|
|
||||||
['clan'],
|
|
||||||
);
|
|
||||||
|
|
||||||
return trimmed.map(
|
|
||||||
(player, index) =>
|
|
||||||
({
|
|
||||||
id: player.id,
|
|
||||||
score: player.score,
|
|
||||||
position: firstPlayerIndex + index + 1,
|
|
||||||
clan: clanData[player.id]?.clan?.tag,
|
|
||||||
nickname:
|
|
||||||
clanData[index]?.account_name ??
|
|
||||||
translate('bot.commands.ratings.body.deleted_player', [
|
|
||||||
`${player.id}`,
|
|
||||||
]),
|
|
||||||
}) satisfies SimplifiedPlayer,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
|
|
||||||
midnightLeaderboard = isCurrentSeason
|
|
||||||
? await getArchivedRatingsMidnightLeaderboard(
|
|
||||||
region,
|
|
||||||
regionRatingsInfo.current_season,
|
|
||||||
)
|
|
||||||
: await getArchivedRatingsMidnightLeaderboard(region, season!);
|
|
||||||
|
|
||||||
players = result;
|
|
||||||
playersAfter =
|
|
||||||
regionRatingsInfo.count - result[result.length - 1].position;
|
|
||||||
titleName = `${
|
|
||||||
leagueInfo
|
|
||||||
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
|
|
||||||
: ''
|
|
||||||
} - ${translate(`common.regions.short.${region}`)}`;
|
|
||||||
titleImage = leagueInfo.big_icon.startsWith('http')
|
|
||||||
? leagueInfo.big_icon
|
|
||||||
: `https:${leagueInfo.big_icon}`;
|
|
||||||
titleDescription = translate('bot.commands.ratings.body.top_players', [
|
|
||||||
`${limit}`,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const { region, id } = await resolvePlayerFromCommand(interaction);
|
|
||||||
regionRatingsInfo = isCurrentSeason
|
|
||||||
? await getRatingsInfo(region)
|
|
||||||
: await getArchivedRatingsInfo(region, season!);
|
|
||||||
|
|
||||||
if (regionRatingsInfo.detail !== undefined)
|
|
||||||
return t`bot.commands.ratings.body.no_ongoing_season`;
|
|
||||||
|
|
||||||
const accountInfo = await getAccountInfo(region, id);
|
|
||||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
|
||||||
const neighbors = isCurrentSeason
|
|
||||||
? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
|
|
||||||
(players) =>
|
|
||||||
players.neighbors.map(
|
|
||||||
(player) =>
|
|
||||||
({
|
|
||||||
id: player.spa_id,
|
|
||||||
position: player.number,
|
|
||||||
score: player.score,
|
|
||||||
clan: player.clan_tag,
|
|
||||||
nickname: player.nickname,
|
|
||||||
}) satisfies SimplifiedPlayer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: await getArchivedRatingsLeaderboard(region, season!).then(
|
|
||||||
async (players) => {
|
|
||||||
const playerIndex = players.findIndex(
|
|
||||||
(player) => player.id === id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (playerIndex === -1) {
|
|
||||||
throw new UserError(
|
|
||||||
translate('bot.commands.ratings.body.no_participation', [
|
|
||||||
markdownEscape(accountInfo.nickname),
|
|
||||||
`${season}`,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const halfRange = Math.round(limit / 2);
|
|
||||||
const trimmed = players.slice(
|
|
||||||
playerIndex - halfRange,
|
|
||||||
playerIndex + halfRange + 1,
|
|
||||||
);
|
|
||||||
const clanData = await getClanAccountInfo(
|
|
||||||
region,
|
|
||||||
trimmed.map((player) => player.id),
|
|
||||||
['clan'],
|
|
||||||
);
|
|
||||||
|
|
||||||
return trimmed.map((player, index) => ({
|
|
||||||
id: player.id,
|
|
||||||
score: player.score,
|
|
||||||
position: playerIndex + index + 1,
|
|
||||||
clan: clanData[index]?.clan?.tag,
|
|
||||||
nickname: clanData[index]
|
|
||||||
? clanData[index]!.account_name
|
|
||||||
: `Deleted ${player.id}`,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
midnightLeaderboard = isCurrentSeason
|
|
||||||
? await getArchivedRatingsMidnightLeaderboard(
|
|
||||||
region,
|
|
||||||
regionRatingsInfo.current_season,
|
|
||||||
)
|
|
||||||
: await getArchivedRatingsMidnightLeaderboard(region, season!);
|
|
||||||
|
|
||||||
players = neighbors;
|
|
||||||
titleImage = clan
|
|
||||||
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
|
|
||||||
: undefined;
|
|
||||||
titleName = neighbors ? accountInfo.nickname : '';
|
|
||||||
titleDescription = t`bot.commands.ratings.body.neighbors`;
|
|
||||||
playersAfter =
|
|
||||||
regionRatingsInfo.count - neighbors[neighbors.length - 1].position;
|
|
||||||
playerId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedRegionRatingsInfo: typeof regionRatingsInfo & {
|
|
||||||
detail: undefined;
|
|
||||||
} = regionRatingsInfo;
|
|
||||||
|
|
||||||
const items = players.map((player) => {
|
|
||||||
const reward = resolvedRegionRatingsInfo.rewards.find(
|
|
||||||
(reward) =>
|
|
||||||
player.position >= reward.from_position &&
|
|
||||||
player.position <= reward.to_position,
|
|
||||||
);
|
|
||||||
const midnightIndex = midnightLeaderboard?.findIndex(
|
|
||||||
(item) => item.id === player.id,
|
|
||||||
);
|
|
||||||
const midnightScore =
|
|
||||||
midnightIndex === undefined
|
|
||||||
? player.score
|
|
||||||
: midnightLeaderboard![midnightIndex].score;
|
|
||||||
const midnightPosition =
|
|
||||||
midnightIndex === undefined ? player.position : midnightIndex + 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Leaderboard.Item
|
|
||||||
nickname={player.nickname}
|
|
||||||
score={player.score}
|
|
||||||
position={player.position}
|
|
||||||
clan={player.clan}
|
|
||||||
reward={reward}
|
|
||||||
deltaScore={player.score - midnightScore}
|
|
||||||
deltaPosition={midnightPosition - player.position}
|
|
||||||
key={player.id}
|
|
||||||
highlight={player.id === playerId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandWrapper>
|
|
||||||
<TitleBar
|
|
||||||
title={titleName}
|
|
||||||
image={titleImage}
|
|
||||||
description={titleDescription}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{items && (
|
|
||||||
<Leaderboard.Root>
|
|
||||||
{items}
|
|
||||||
{playersAfter > 0 && (
|
|
||||||
<Leaderboard.Gap
|
|
||||||
message={translate('bot.commands.ratings.body.more', [
|
|
||||||
`${playersAfter.toLocaleString(interaction.locale)}`,
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Leaderboard.Root>
|
|
||||||
)}
|
|
||||||
</CommandWrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
autocomplete: autocompleteUsername,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+20
-27
@@ -8,8 +8,8 @@ import getTankStats from '../core/blitz/getTankStats';
|
|||||||
import resolveTankId from '../core/blitz/resolveTankId';
|
import resolveTankId from '../core/blitz/resolveTankId';
|
||||||
import { asset } from '../core/blitzkrieg/asset';
|
import { asset } from '../core/blitzkrieg/asset';
|
||||||
import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine';
|
import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine';
|
||||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
import { emblemURL } from '../core/blitzkrieg/emblemURL';
|
||||||
import { iconPng } from '../core/blitzkrieg/iconPng';
|
import { webpToPng } from '../core/blitzkrieg/iconPng';
|
||||||
import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry';
|
import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry';
|
||||||
import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
|
import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
|
||||||
import {
|
import {
|
||||||
@@ -155,7 +155,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
const line = [...techTreeLine, startingTankId].reverse();
|
const line = [...techTreeLine, startingTankId].reverse();
|
||||||
const { nickname } = await getAccountInfo(region, id);
|
const { nickname } = await getAccountInfo(region, id);
|
||||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||||
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||||
const costs = await Promise.all(
|
const costs = await Promise.all(
|
||||||
line.map(async (id) => {
|
line.map(async (id) => {
|
||||||
const tank = awaitedTankDefinitions[id];
|
const tank = awaitedTankDefinitions[id];
|
||||||
@@ -276,7 +276,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(asset('icons/currencies/xp.webp'))}
|
src={await webpToPng(asset('icons/currencies/xp.webp'))}
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
/>
|
/>
|
||||||
@@ -301,7 +301,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(asset('icons/currencies/silver.webp'))}
|
src={await webpToPng(asset('icons/currencies/silver.webp'))}
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
/>
|
/>
|
||||||
@@ -326,7 +326,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(asset('icons/currencies/silver.webp'))}
|
src={await webpToPng(asset('icons/currencies/silver.webp'))}
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
/>
|
/>
|
||||||
@@ -385,26 +385,17 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
right: '5%',
|
right: '5%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<img
|
||||||
|
src={await tankIconPng(id)}
|
||||||
style={{
|
style={{
|
||||||
height: 120,
|
height: 100,
|
||||||
width: 240,
|
width: 100,
|
||||||
display: 'flex',
|
objectFit: 'contain',
|
||||||
alignItems: 'center',
|
position: 'absolute',
|
||||||
justifyContent: 'flex-start',
|
|
||||||
left: '25%',
|
left: '25%',
|
||||||
top: '5%',
|
top: 0,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<img
|
|
||||||
src={await tankIconPng(id)}
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
objectFit: 'contain',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -452,7 +443,9 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(asset('icons/currencies/xp.webp'))}
|
src={await webpToPng(
|
||||||
|
asset('icons/currencies/xp.webp'),
|
||||||
|
)}
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
/>
|
/>
|
||||||
@@ -479,7 +472,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(
|
src={await webpToPng(
|
||||||
asset('icons/currencies/free-xp.webp'),
|
asset('icons/currencies/free-xp.webp'),
|
||||||
)}
|
)}
|
||||||
width={16}
|
width={16}
|
||||||
@@ -506,7 +499,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(
|
src={await webpToPng(
|
||||||
asset('icons/currencies/silver.webp'),
|
asset('icons/currencies/silver.webp'),
|
||||||
)}
|
)}
|
||||||
width={16}
|
width={16}
|
||||||
@@ -534,7 +527,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={await iconPng(
|
src={await webpToPng(
|
||||||
asset('icons/currencies/silver.webp'),
|
asset('icons/currencies/silver.webp'),
|
||||||
)}
|
)}
|
||||||
width={16}
|
width={16}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import NoData from '../components/NoData';
|
|||||||
import TitleBar from '../components/TitleBar';
|
import TitleBar from '../components/TitleBar';
|
||||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||||
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
|
import { emblemURL } from '../core/blitzkrieg/emblemURL';
|
||||||
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
|
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
|
||||||
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
|
||||||
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
|
||||||
@@ -38,7 +38,7 @@ async function render(
|
|||||||
) {
|
) {
|
||||||
const { nickname } = await getAccountInfo(region, id);
|
const { nickname } = await getAccountInfo(region, id);
|
||||||
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
|
||||||
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined;
|
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
|
||||||
const diffedTankStats = await getStatsInPeriod(
|
const diffedTankStats = await getStatsInPeriod(
|
||||||
region,
|
region,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export interface GlowProps extends ComponentProps<'div'> {
|
||||||
|
color: string;
|
||||||
|
direction?: 'default' | 'reverse';
|
||||||
|
rotation?: number;
|
||||||
|
startOpacity?: number;
|
||||||
|
endOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 16;
|
||||||
|
|
||||||
|
export function Glow({
|
||||||
|
color,
|
||||||
|
direction = 'default',
|
||||||
|
rotation = 0,
|
||||||
|
style,
|
||||||
|
startOpacity = 0,
|
||||||
|
endOpacity = 1,
|
||||||
|
...props
|
||||||
|
}: GlowProps) {
|
||||||
|
const color1 = `${color}${Math.round(endOpacity * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')}`;
|
||||||
|
const color2 = `${color}${Math.round(startOpacity * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')}`;
|
||||||
|
const background = `linear-gradient(${
|
||||||
|
direction === 'default' ? 0 : 180
|
||||||
|
}deg, ${color1} 0%, ${color2} 100%)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div style={{ height: 54, width, background }} />
|
||||||
|
<div style={{ height: 72, width, background }} />
|
||||||
|
<div style={{ height: 80, width, background }} />
|
||||||
|
<div style={{ height: 72, width, background }} />
|
||||||
|
<div style={{ height: 54, width, background }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-19
@@ -1,5 +1,6 @@
|
|||||||
import { Locale } from 'discord.js';
|
import { Locale } from 'discord.js';
|
||||||
import { Percentile } from '../../../../constants/percentiles';
|
import { Percentile } from '../../../../constants/percentiles';
|
||||||
|
import { SupplementaryStats } from '../../../../core/blitz/getAccountInfo';
|
||||||
import { translator } from '../../../../core/localization/translator';
|
import { translator } from '../../../../core/localization/translator';
|
||||||
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
|
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
|
||||||
import { theme } from '../../../../stitches.config';
|
import { theme } from '../../../../stitches.config';
|
||||||
@@ -7,16 +8,19 @@ import { PERCENTILE_COLORS } from '../../../PercentileIndicator/constants';
|
|||||||
import { Glow } from './components/Glow';
|
import { Glow } from './components/Glow';
|
||||||
|
|
||||||
export interface WN8DisplayProps {
|
export interface WN8DisplayProps {
|
||||||
wn8?: number;
|
stats: SupplementaryStats;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WN8Display({ wn8, locale }: WN8DisplayProps) {
|
export function HeroStat({ stats, locale }: WN8DisplayProps) {
|
||||||
const percentile =
|
|
||||||
wn8 === undefined ? Percentile.VeryBad : getWN8Percentile(wn8);
|
|
||||||
const color = PERCENTILE_COLORS[percentile];
|
|
||||||
const { translate } = translator(locale);
|
const { translate } = translator(locale);
|
||||||
|
|
||||||
|
const percentile =
|
||||||
|
stats.WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(stats.WN8);
|
||||||
|
const color = PERCENTILE_COLORS[percentile];
|
||||||
|
const heroStat = stats.WN8 === undefined ? '--' : stats.WN8.toFixed(0);
|
||||||
|
const subtitle = translate(`common.wn8_percentile.${percentile}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -43,23 +47,33 @@ export function WN8Display({ wn8, locale }: WN8DisplayProps) {
|
|||||||
gap: -4,
|
gap: -4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 900,
|
display: 'flex',
|
||||||
fontSize: 32,
|
flexDirection: 'column',
|
||||||
color: theme.colors.textHighContrast,
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: -4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{wn8 === undefined ? '--' : wn8.toFixed(0)}
|
<span
|
||||||
</span>
|
style={{
|
||||||
<span
|
fontWeight: 900,
|
||||||
style={{
|
fontSize: 32,
|
||||||
fontSize: 16,
|
color: theme.colors.textHighContrast,
|
||||||
color: theme.colors.textHighContrast,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{heroStat}
|
||||||
{translate(`common.wn8_percentile.${percentile}`)}
|
</span>
|
||||||
</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textHighContrast,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Glow color={color} direction="reverse" />
|
<Glow color={color} direction="reverse" />
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
export interface GlowProps {
|
|
||||||
color: string;
|
|
||||||
direction?: 'default' | 'reverse';
|
|
||||||
rotation?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Glow({
|
|
||||||
color,
|
|
||||||
direction = 'default',
|
|
||||||
rotation = 0,
|
|
||||||
}: GlowProps) {
|
|
||||||
const color1 = color;
|
|
||||||
const color2 = `${color}00`;
|
|
||||||
|
|
||||||
const background = `linear-gradient(${
|
|
||||||
direction === 'default' ? 0 : 180
|
|
||||||
}deg, ${color1} 0%, ${color2} 100%)`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transform: `rotate(${rotation}deg)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ height: 54, width: 16, background }} />
|
|
||||||
<div style={{ height: 72, width: 16, background }} />
|
|
||||||
<div style={{ height: 80, width: 16, background }} />
|
|
||||||
<div style={{ height: 72, width: 16, background }} />
|
|
||||||
<div style={{ height: 54, width: 16, background }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import { Locale } from 'discord.js';
|
import { Locale } from 'discord.js';
|
||||||
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo';
|
import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
|
||||||
import { translator } from '../../core/localization/translator';
|
import { translator } from '../../core/localization/translator';
|
||||||
import { WN8Display } from './components/WN8Display';
|
import { HeroStat } from './components/HeroStat';
|
||||||
import {
|
import {
|
||||||
WN8SurroundingStat,
|
WN8SurroundingStat,
|
||||||
WN8SurroundingStatAlign,
|
WN8SurroundingStatAlign,
|
||||||
} from './components/WN8SurroundingStat';
|
} from './components/WN8SurroundingStat';
|
||||||
|
|
||||||
export interface AllStatsOverviewProps {
|
export interface AllStatsOverviewProps {
|
||||||
stats: AllStats;
|
stats: {
|
||||||
|
battles: number;
|
||||||
|
wins: number;
|
||||||
|
damage_dealt: number;
|
||||||
|
damage_received: number;
|
||||||
|
survived_battles: number;
|
||||||
|
hits: number;
|
||||||
|
shots: number;
|
||||||
|
frags: number;
|
||||||
|
};
|
||||||
supplementaryStats: SupplementaryStats;
|
supplementaryStats: SupplementaryStats;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
}
|
}
|
||||||
@@ -24,6 +33,7 @@ export default function AllStatsOverview({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
gap: -64,
|
gap: -64,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
@@ -38,18 +48,18 @@ export default function AllStatsOverview({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.battles`}
|
label={t`bot.common.hero_stats.battles`}
|
||||||
value={stats.battles.toLocaleString()}
|
value={stats.battles.toLocaleString()}
|
||||||
align={WN8SurroundingStatAlign.Right}
|
align={WN8SurroundingStatAlign.Right}
|
||||||
/>
|
/>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.winrate`}
|
label={t`bot.common.hero_stats.winrate`}
|
||||||
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
|
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
|
||||||
align={WN8SurroundingStatAlign.Right}
|
align={WN8SurroundingStatAlign.Right}
|
||||||
padded
|
padded
|
||||||
/>
|
/>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.damage`}
|
label={t`bot.common.hero_stats.damage`}
|
||||||
value={Math.round(
|
value={Math.round(
|
||||||
stats.damage_dealt / stats.battles,
|
stats.damage_dealt / stats.battles,
|
||||||
).toLocaleString()}
|
).toLocaleString()}
|
||||||
@@ -57,13 +67,13 @@ export default function AllStatsOverview({
|
|||||||
padded
|
padded
|
||||||
/>
|
/>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.damage_ratio`}
|
label={t`bot.common.hero_stats.damage_ratio`}
|
||||||
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
|
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
|
||||||
align={WN8SurroundingStatAlign.Right}
|
align={WN8SurroundingStatAlign.Right}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WN8Display locale={locale} wn8={supplementaryStats.WN8} />
|
<HeroStat locale={locale} stats={supplementaryStats} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -74,29 +84,26 @@ export default function AllStatsOverview({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.tier`}
|
label={t`bot.common.hero_stats.tier`}
|
||||||
value={
|
value={
|
||||||
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
|
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.survival`}
|
label={t`bot.common.hero_stats.survival`}
|
||||||
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
|
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
|
||||||
2,
|
2,
|
||||||
)}%`}
|
)}%`}
|
||||||
padded
|
padded
|
||||||
/>
|
/>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.accuracy`}
|
label={t`bot.common.hero_stats.accuracy`}
|
||||||
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
|
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
|
||||||
padded
|
padded
|
||||||
/>
|
/>
|
||||||
<WN8SurroundingStat
|
<WN8SurroundingStat
|
||||||
label={t`bot.commands.stats.body.kill_ratio`}
|
label={t`bot.common.hero_stats.kills`}
|
||||||
value={(
|
value={(stats.frags / stats.battles).toFixed(2)}
|
||||||
stats.frags /
|
|
||||||
(stats.battles - stats.survived_battles)
|
|
||||||
).toFixed(2)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const CoreArmorScene = memo(() => {
|
|||||||
const turretContainer = useRef<Group>(null);
|
const turretContainer = useRef<Group>(null);
|
||||||
const gunContainer = useRef<Group>(null);
|
const gunContainer = useRef<Group>(null);
|
||||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||||
|
const protagonist = useDuel((draft) => draft.protagonist!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelDefinitions) return;
|
if (!modelDefinitions) return;
|
||||||
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
|
|||||||
turretContainer.current?.rotation.copy(turretRotation);
|
turretContainer.current?.rotation.copy(turretRotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleModelTransform(useDuel.getState().protagonist!);
|
handleModelTransform(protagonist);
|
||||||
modelTransformEvent.on(handleModelTransform);
|
modelTransformEvent.on(handleModelTransform);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function CoreArmorSceneComponent({
|
|||||||
opaque: { value: null },
|
opaque: { value: null },
|
||||||
useSpacedArmor: { value: null },
|
useSpacedArmor: { value: null },
|
||||||
|
|
||||||
|
inverseProjectionMatrix: { value: null },
|
||||||
resolution: { value: new Vector2() },
|
resolution: { value: new Vector2() },
|
||||||
spacedArmorBuffer: { value: null },
|
spacedArmorBuffer: { value: null },
|
||||||
spacedArmorDepth: { value: null },
|
spacedArmorDepth: { value: null },
|
||||||
@@ -57,10 +58,7 @@ export function CoreArmorSceneComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleShellChange(shell: ShellDefinition) {
|
async function handleShellChange(shell: ShellDefinition) {
|
||||||
material.uniforms.penetration.value = resolveNearPenetration(
|
|
||||||
shell.penetration,
|
|
||||||
);
|
|
||||||
material.uniforms.caliber.value = shell.caliber;
|
material.uniforms.caliber.value = shell.caliber;
|
||||||
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
|
||||||
material.uniforms.normalization.value = degToRad(
|
material.uniforms.normalization.value = degToRad(
|
||||||
@@ -70,6 +68,10 @@ export function CoreArmorSceneComponent({
|
|||||||
material.uniforms.canSplash.value = canSplash(shell.type);
|
material.uniforms.canSplash.value = canSplash(shell.type);
|
||||||
material.uniforms.damage.value = shell.damage.armor;
|
material.uniforms.damage.value = shell.damage.armor;
|
||||||
material.uniforms.explosionRadius.value = shell.explosionRadius;
|
material.uniforms.explosionRadius.value = shell.explosionRadius;
|
||||||
|
|
||||||
|
const duel = useDuel.getState();
|
||||||
|
await handleProtagonistEquipmentChange(duel.protagonist!.equipmentMatrix);
|
||||||
|
await handleAntagonistEquipmentChange(duel.antagonist!.equipmentMatrix);
|
||||||
}
|
}
|
||||||
function handleVisualChange(
|
function handleVisualChange(
|
||||||
visual: TankopediaPersistent['model']['visual'],
|
visual: TankopediaPersistent['model']['visual'],
|
||||||
@@ -82,24 +84,40 @@ export function CoreArmorSceneComponent({
|
|||||||
async function handleProtagonistEquipmentChange(
|
async function handleProtagonistEquipmentChange(
|
||||||
equipment: EquipmentMatrix,
|
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
|
material.uniforms.thickness.value = hasEnhancedArmor
|
||||||
? thickness * 1.04
|
? thickness * 1.04
|
||||||
: thickness;
|
: thickness;
|
||||||
}
|
}
|
||||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||||
const shell = useDuel.getState().antagonist!.shell;
|
const duel = useDuel.getState();
|
||||||
const penetration = resolveNearPenetration(shell.penetration);
|
const penetration = resolveNearPenetration(
|
||||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
duel.antagonist!.shell.penetration,
|
||||||
|
);
|
||||||
|
const hasCalibratedShells = await hasEquipment(
|
||||||
|
103,
|
||||||
|
duel.antagonist!.tank.equipment,
|
||||||
|
equipment,
|
||||||
|
);
|
||||||
|
|
||||||
material.uniforms.penetration.value = hasCalibratedShells
|
material.uniforms.penetration.value = hasCalibratedShells
|
||||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
|
||||||
: penetration;
|
: penetration;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||||
handleVisualChange(useTankopediaPersistent.getState().model.visual);
|
handleVisualChange(useTankopediaPersistent.getState().model.visual);
|
||||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
handleProtagonistEquipmentChange(
|
||||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
useDuel.getState().protagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
handleAntagonistEquipmentChange(
|
||||||
|
useDuel.getState().antagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||||
@@ -108,11 +126,11 @@ export function CoreArmorSceneComponent({
|
|||||||
handleVisualChange,
|
handleVisualChange,
|
||||||
),
|
),
|
||||||
useDuel.subscribe(
|
useDuel.subscribe(
|
||||||
(state) => state.protagonist!.equipment,
|
(state) => state.protagonist!.equipmentMatrix,
|
||||||
handleProtagonistEquipmentChange,
|
handleProtagonistEquipmentChange,
|
||||||
),
|
),
|
||||||
useDuel.subscribe(
|
useDuel.subscribe(
|
||||||
(state) => state.antagonist!.equipment,
|
(state) => state.antagonist!.equipmentMatrix,
|
||||||
handleAntagonistEquipmentChange,
|
handleAntagonistEquipmentChange,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -120,15 +138,17 @@ export function CoreArmorSceneComponent({
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
};
|
};
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
useFrame(({ gl }) => {
|
useFrame(({ gl, camera }) => {
|
||||||
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
|
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
|
||||||
gl.getPixelRatio(),
|
gl.getPixelRatio(),
|
||||||
);
|
);
|
||||||
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
|
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
|
||||||
material.uniforms.spacedArmorDepth.value =
|
material.uniforms.spacedArmorDepth.value =
|
||||||
spacedArmorRenderTarget.depthTexture;
|
spacedArmorRenderTarget.depthTexture;
|
||||||
|
material.uniforms.inverseProjectionMatrix.value =
|
||||||
|
camera.projectionMatrixInverse;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
varying vec3 vViewPosition;
|
varying vec3 vViewPosition;
|
||||||
varying mat4 vProjectionMatrix;
|
|
||||||
|
|
||||||
uniform float thickness;
|
uniform float thickness;
|
||||||
uniform float penetration;
|
uniform float penetration;
|
||||||
@@ -15,13 +14,14 @@ uniform float explosionRadius;
|
|||||||
uniform bool greenPenetration;
|
uniform bool greenPenetration;
|
||||||
uniform bool opaque;
|
uniform bool opaque;
|
||||||
uniform bool useSpacedArmor;
|
uniform bool useSpacedArmor;
|
||||||
|
uniform mat4 inverseProjectionMatrix;
|
||||||
|
|
||||||
uniform highp sampler2D spacedArmorBuffer;
|
uniform highp sampler2D spacedArmorBuffer;
|
||||||
uniform highp sampler2D spacedArmorDepth;
|
uniform highp sampler2D spacedArmorDepth;
|
||||||
|
|
||||||
float depthToDistance(float depth) {
|
float depthToDistance(float depth) {
|
||||||
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
|
||||||
vec4 eyePosition = inverse(vProjectionMatrix) * clipPosition;
|
vec4 eyePosition = inverseProjectionMatrix * clipPosition;
|
||||||
return length(eyePosition.xyz / eyePosition.w);
|
return length(eyePosition.xyz / eyePosition.w);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,36 +42,42 @@ void main() {
|
|||||||
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
|
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
|
||||||
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
|
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
|
||||||
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
|
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
|
||||||
float remainingPenetration = penetration;
|
|
||||||
|
|
||||||
// world space is in meters
|
// world space in meters
|
||||||
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
|
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
|
||||||
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
|
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
|
||||||
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
|
float coreArmorDistance = depthToDistance(gl_FragCoord.z);
|
||||||
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
|
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
|
||||||
|
|
||||||
if (useSpacedArmor && isUnderSpacedArmor) {
|
if (canSplash && useSpacedArmor && isUnderSpacedArmor) {
|
||||||
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
|
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
|
||||||
remainingPenetration -= spacedArmorThickness;
|
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * (finalThickness + spacedArmorThickness);
|
||||||
|
|
||||||
if (isExplosive && remainingPenetration > 0.0) {
|
penetrationChance = 0.0;
|
||||||
// there is a 50% penetration loss per meter for HEAT shells
|
splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
|
||||||
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remainingPenetration = max(0.0, remainingPenetration);
|
|
||||||
float delta = finalThickness - remainingPenetration;
|
|
||||||
float randomization = remainingPenetration * 0.05;
|
|
||||||
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
|
|
||||||
|
|
||||||
if (canSplash) {
|
|
||||||
// only allow splashing if the damage equation deals more than 0 damage
|
|
||||||
float nominalArmorThickness = finalThickness - remainingPenetration;
|
|
||||||
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
|
|
||||||
splashChance = finalDamage > 0.0 ? 1.0 : 0.0; // TODO: add randomization here?
|
|
||||||
} else {
|
} else {
|
||||||
splashChance = 0.0;
|
float remainingPenetration = penetration;
|
||||||
|
|
||||||
|
if (useSpacedArmor && isUnderSpacedArmor) {
|
||||||
|
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
|
||||||
|
remainingPenetration -= spacedArmorThickness;
|
||||||
|
|
||||||
|
if (isExplosive && remainingPenetration > 0.0) {
|
||||||
|
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingPenetration = max(0.0, remainingPenetration);
|
||||||
|
float delta = finalThickness - remainingPenetration;
|
||||||
|
float randomization = remainingPenetration * 0.05;
|
||||||
|
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
|
||||||
|
|
||||||
|
if (canSplash) {
|
||||||
|
float splashDamage = 0.5 * damage - 1.1 * finalThickness;
|
||||||
|
splashChance = splashDamage > 0.0 ? 1.0 : 0.0;
|
||||||
|
} else {
|
||||||
|
splashChance = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
varying vec3 vNormal;
|
varying vec3 vNormal;
|
||||||
varying vec3 vViewPosition;
|
varying vec3 vViewPosition;
|
||||||
varying mat4 vProjectionMatrix;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
|
||||||
vViewPosition = vViewPosition4.xyz;
|
vViewPosition = vViewPosition4.xyz;
|
||||||
vProjectionMatrix = projectionMatrix;
|
|
||||||
vNormal = normalMatrix * normal;
|
vNormal = normalMatrix * normal;
|
||||||
|
|
||||||
gl_Position = projectionMatrix * vViewPosition4;
|
gl_Position = projectionMatrix * vViewPosition4;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useFrame } from '@react-three/fiber';
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Mesh } from 'three';
|
import { Mesh } from 'three';
|
||||||
import { radToDeg } from 'three/src/math/MathUtils';
|
import { radToDeg } from 'three/src/math/MathUtils';
|
||||||
|
import { canSplash } from '../../../core/blitz/canSplash';
|
||||||
import { isExplosive } from '../../../core/blitz/isExplosive';
|
import { isExplosive } from '../../../core/blitz/isExplosive';
|
||||||
import { NaNFallback } from '../../../core/math/NaNFallback';
|
import { NaNFallback } from '../../../core/math/NaNFallback';
|
||||||
import { normalToEuler } from '../../../core/math/normalToEuler';
|
import { normalToEuler } from '../../../core/math/normalToEuler';
|
||||||
@@ -42,6 +43,7 @@ export function ShotDisplay() {
|
|||||||
);
|
);
|
||||||
const { shell } = useDuel.getState().antagonist!;
|
const { shell } = useDuel.getState().antagonist!;
|
||||||
const explosive = isExplosive(shell.type);
|
const explosive = isExplosive(shell.type);
|
||||||
|
const splashing = canSplash(shell.type);
|
||||||
const midPointLayer = (shot?.layers.findLast(
|
const midPointLayer = (shot?.layers.findLast(
|
||||||
(layer) => layer.type !== null && layer.status === 'ricochet',
|
(layer) => layer.type !== null && layer.status === 'ricochet',
|
||||||
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
|
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
|
||||||
@@ -64,6 +66,18 @@ export function ShotDisplay() {
|
|||||||
return accumulator;
|
return accumulator;
|
||||||
}, 0) + LENGTH_INFINITY
|
}, 0) + LENGTH_INFINITY
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const preMidPointThickness = shot
|
||||||
|
? preMidPointLayers!.reduce(
|
||||||
|
(accumulator, layer) =>
|
||||||
|
accumulator +
|
||||||
|
(layer.type === null
|
||||||
|
? 0
|
||||||
|
: layer.type === ArmorType.External
|
||||||
|
? layer.thickness
|
||||||
|
: layer.thicknessAngled),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
const postMidPointGap = shot
|
const postMidPointGap = shot
|
||||||
? postMidPointLayers!.reduce((accumulator, layer) => {
|
? postMidPointLayers!.reduce((accumulator, layer) => {
|
||||||
if (layer.type === null) return accumulator + layer.distance;
|
if (layer.type === null) return accumulator + layer.distance;
|
||||||
@@ -246,8 +260,9 @@ export function ShotDisplay() {
|
|||||||
(1 -
|
(1 -
|
||||||
distanceFromSpacedArmor! / shell.explosionRadius!) -
|
distanceFromSpacedArmor! / shell.explosionRadius!) -
|
||||||
1.1 *
|
1.1 *
|
||||||
(midPointLayer as ShotLayerNonExternal)
|
((midPointLayer as ShotLayerNonExternal)
|
||||||
.thicknessAngled,
|
.thicknessAngled +
|
||||||
|
preMidPointThickness!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
@@ -255,7 +270,7 @@ export function ShotDisplay() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{shot.layers.map((layer, index) => {
|
{shot.layers.map((layer, index) => {
|
||||||
if (layer.type === null && !explosive) return null;
|
if (layer.type === null && (!explosive || splashing)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex key={index} align="center" gap="2">
|
<Flex key={index} align="center" gap="2">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
|||||||
const turretContainer = useRef<Group>(null);
|
const turretContainer = useRef<Group>(null);
|
||||||
const gunContainer = useRef<Group>(null);
|
const gunContainer = useRef<Group>(null);
|
||||||
const initialTankopediaState = useTankopediaPersistent.getState();
|
const initialTankopediaState = useTankopediaPersistent.getState();
|
||||||
|
const protagonist = useDuel((draft) => draft.protagonist!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelDefinitions) return;
|
if (!modelDefinitions) return;
|
||||||
@@ -99,7 +100,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
|
|||||||
turretContainer.current?.rotation.copy(turretRotation);
|
turretContainer.current?.rotation.copy(turretRotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleModelTransform(useDuel.getState().protagonist!);
|
handleModelTransform(protagonist);
|
||||||
modelTransformEvent.on(handleModelTransform);
|
modelTransformEvent.on(handleModelTransform);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
+25
-10
@@ -50,32 +50,47 @@ export function SpacedArmorSubExternal({
|
|||||||
async function handleProtagonistEquipmentChange(
|
async function handleProtagonistEquipmentChange(
|
||||||
equipment: EquipmentMatrix,
|
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
|
material.uniforms.thickness.value = hasEnhancedArmor
|
||||||
? thickness * 1.04
|
? thickness * 1.04
|
||||||
: thickness;
|
: thickness;
|
||||||
}
|
}
|
||||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||||
const shell = useDuel.getState().antagonist!.shell;
|
const duel = useDuel.getState();
|
||||||
const penetration = resolveNearPenetration(shell.penetration);
|
const penetration = resolveNearPenetration(
|
||||||
const hasCalibratedShells = await hasEquipment(103, true, equipment);
|
duel.antagonist!.shell.penetration,
|
||||||
|
);
|
||||||
|
const hasCalibratedShells = await hasEquipment(
|
||||||
|
103,
|
||||||
|
duel.antagonist!.tank.equipment,
|
||||||
|
equipment,
|
||||||
|
);
|
||||||
material.uniforms.penetration.value = hasCalibratedShells
|
material.uniforms.penetration.value = hasCalibratedShells
|
||||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
|
||||||
: penetration;
|
: penetration;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
handleProtagonistEquipmentChange(
|
||||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
useDuel.getState().protagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
handleAntagonistEquipmentChange(
|
||||||
|
useDuel.getState().antagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||||
useDuel.subscribe(
|
useDuel.subscribe(
|
||||||
(state) => state.protagonist!.equipment,
|
(state) => state.protagonist!.equipmentMatrix,
|
||||||
handleProtagonistEquipmentChange,
|
handleProtagonistEquipmentChange,
|
||||||
),
|
),
|
||||||
useDuel.subscribe(
|
useDuel.subscribe(
|
||||||
(state) => state.antagonist!.equipment,
|
(state) => state.antagonist!.equipmentMatrix,
|
||||||
handleAntagonistEquipmentChange,
|
handleAntagonistEquipmentChange,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -83,7 +98,7 @@ export function SpacedArmorSubExternal({
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
};
|
};
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+21
-7
@@ -62,32 +62,46 @@ export function SpacedArmorSubSpaced({
|
|||||||
async function handleProtagonistEquipmentChange(
|
async function handleProtagonistEquipmentChange(
|
||||||
equipment: EquipmentMatrix,
|
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
|
material.uniforms.thickness.value = hasEnhancedArmor
|
||||||
? thickness * 1.04
|
? thickness * 1.04
|
||||||
: thickness;
|
: thickness;
|
||||||
}
|
}
|
||||||
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
|
||||||
|
const duel = useDuel.getState();
|
||||||
const shell = useDuel.getState().antagonist!.shell;
|
const shell = useDuel.getState().antagonist!.shell;
|
||||||
const penetration = resolveNearPenetration(shell.penetration);
|
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
|
material.uniforms.penetration.value = hasCalibratedShells
|
||||||
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
|
||||||
: penetration;
|
: penetration;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleShellChange(useDuel.getState().antagonist!.shell);
|
handleShellChange(useDuel.getState().antagonist!.shell);
|
||||||
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment);
|
handleProtagonistEquipmentChange(
|
||||||
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment);
|
useDuel.getState().protagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
handleAntagonistEquipmentChange(
|
||||||
|
useDuel.getState().antagonist!.equipmentMatrix,
|
||||||
|
);
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
|
||||||
useDuel.subscribe(
|
useDuel.subscribe(
|
||||||
(state) => state.protagonist!.equipment,
|
(state) => state.protagonist!.equipmentMatrix,
|
||||||
handleProtagonistEquipmentChange,
|
handleProtagonistEquipmentChange,
|
||||||
),
|
),
|
||||||
useDuel.subscribe(
|
useDuel.subscribe(
|
||||||
(state) => state.antagonist!.equipment,
|
(state) => state.antagonist!.equipmentMatrix,
|
||||||
handleAntagonistEquipmentChange,
|
handleAntagonistEquipmentChange,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -95,7 +109,7 @@ export function SpacedArmorSubSpaced({
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
};
|
};
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -89,8 +89,17 @@ export function SpacedArmorSceneComponent({
|
|||||||
async onClick(event) {
|
async onClick(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const hasCalibratedShells = await hasEquipment(103, true);
|
const duel = useDuel.getState();
|
||||||
const hasEnhancedArmor = await hasEquipment(110);
|
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 thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
|
||||||
const shell = useDuel.getState().antagonist!.shell;
|
const shell = useDuel.getState().antagonist!.shell;
|
||||||
const explosive = isExplosive(shell.type);
|
const explosive = isExplosive(shell.type);
|
||||||
@@ -179,7 +188,7 @@ export function SpacedArmorSceneComponent({
|
|||||||
const distance = (
|
const distance = (
|
||||||
shot.layers.at(-1) as ShotLayerBase
|
shot.layers.at(-1) as ShotLayerBase
|
||||||
).point.distanceTo(intersections[0].point);
|
).point.distanceTo(intersections[0].point);
|
||||||
if (explosive)
|
if (explosive && !splashing)
|
||||||
remainingPenetration -= 0.5 * remainingPenetration * distance;
|
remainingPenetration -= 0.5 * remainingPenetration * distance;
|
||||||
const blocked = remainingPenetration < 0;
|
const blocked = remainingPenetration < 0;
|
||||||
|
|
||||||
@@ -205,7 +214,7 @@ export function SpacedArmorSceneComponent({
|
|||||||
const previousIntersection = intersections[layerIndex - 1];
|
const previousIntersection = intersections[layerIndex - 1];
|
||||||
const distance =
|
const distance =
|
||||||
intersection.distance - previousIntersection.distance;
|
intersection.distance - previousIntersection.distance;
|
||||||
if (explosive)
|
if (explosive && !splashing)
|
||||||
remainingPenetration -=
|
remainingPenetration -=
|
||||||
0.5 * remainingPenetration * distance;
|
0.5 * remainingPenetration * distance;
|
||||||
const wasted = remainingPenetration < 0;
|
const wasted = remainingPenetration < 0;
|
||||||
@@ -216,7 +225,7 @@ export function SpacedArmorSceneComponent({
|
|||||||
distance,
|
distance,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (wasted) return;
|
if (wasted && !splashing) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intersection.object.userData.type === ArmorType.External) {
|
if (intersection.object.userData.type === ArmorType.External) {
|
||||||
@@ -233,7 +242,11 @@ export function SpacedArmorSceneComponent({
|
|||||||
status: blocked ? 'blocked' : 'penetration',
|
status: blocked ? 'blocked' : 'penetration',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (blocked) break;
|
if (splashing) {
|
||||||
|
remainingPenetration = Math.max(0, remainingPenetration);
|
||||||
|
} else if (blocked) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const type = intersection.object.userData.type;
|
const type = intersection.object.userData.type;
|
||||||
const angle = surfaceNormal.angleTo(shellNormal);
|
const angle = surfaceNormal.angleTo(shellNormal);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function CommandWrapper({ children, fat }: CommandWrapperProps) {
|
|||||||
gap: 16,
|
gap: 16,
|
||||||
width: 480,
|
width: 480,
|
||||||
color: theme.colors.textHighContrast,
|
color: theme.colors.textHighContrast,
|
||||||
|
position: 'relative',
|
||||||
background: theme.colors.appBackground1,
|
background: theme.colors.appBackground1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user