Compare commits

..
187 Commits
Author SHA1 Message Date
TrèsAbhi 3640c16384 Merge pull request #123 from tresabhi/dev 2024-06-06 19:45:10 -05:00
TrèsAbhi e8eb9333b1 feat: logs 2024-06-06 19:45:14 -05:00
TrèsAbhi 8ef0bfd7eb fix: stats wn8 fallback 2024-06-06 19:41:53 -05:00
TrèsAbhi 83bcca7f69 fix: no participation edge case 2024-06-06 19:40:37 -05:00
TrèsAbhi 378f56598d feat: seasonal stats 2024-06-06 19:31:53 -05:00
TrèsAbhi 5031b4f8e3 fix: decreasing position marked positively 2024-06-06 19:23:21 -05:00
TrèsAbhi beab7601a4 Merge branch 'dev' of https://github.com/tresabhi/blitzkit into dev 2024-06-06 19:21:10 -05:00
TrèsAbhi 721bfd9ea2 fix: rating command seasonal fix 2024-06-06 19:21:06 -05:00
TrèsAbhi e5c9c780e9 fix: base64 2024-06-06 19:14:01 -05:00
TrèsAbhi 8a64a2d7c7 fix: cache 3 2024-06-06 19:09:17 -05:00
TrèsAbhi eadd66dc6c fix: cache 2 2024-06-06 19:08:29 -05:00
TrèsAbhi 729a45f8a8 fix: cache 2024-06-06 19:05:34 -05:00
TrèsAbhi b216c1e6b8 feat: stronger threshold 2024-06-06 19:02:58 -05:00
TrèsAbhi 30b59fff02 fix: rating command seasonal fix 2024-06-06 19:00:37 -05:00
TrèsAbhi 324cfe3927 fix: secrets 3 2024-06-06 13:44:59 -05:00
TrèsAbhi 6a0182436e fix: secrets 2 2024-06-06 13:44:19 -05:00
TrèsAbhi 1c2d006eba fix: secrets 2024-06-06 13:43:25 -05:00
TrèsAbhi c943baf6f3 feat: discover ids 2024-06-06 13:37:50 -05:00
TrèsAbhi 93c7392d6e feat: isolated discovery 2024-06-05 18:35:44 -05:00
TrèsAbhi a36ffe74c4 fix: remove trash 2024-06-05 14:11:05 -05:00
TrèsAbhi 16ebea659a Merge branch 'dev' 2024-06-05 14:06:42 -05:00
TrèsAbhi 6fac144005 feat: logs 2024-06-05 14:05:13 -05:00
TrèsAbhi 64c9e2f64d fix: disallow iframs 2024-06-05 13:40:45 -05:00
TrèsAbhi 7cdcf7b9e1 fix: ios turret 2024-06-05 13:28:45 -05:00
TrèsAbhi 331f57e7b0 fix: he no block on core 2024-06-05 13:14:08 -05:00
TrèsAbhi 10c810b228 feat: some tankopedia server rendered 2024-06-05 13:06:44 -05:00
TrèsAbhi 25523b9d8d feat: simplification 2024-06-05 12:56:09 -05:00
TrèsAbhi 2a730bed91 feat: better footer 2024-06-05 12:53:16 -05:00
TrèsAbhi 55a78ebd2f feat: table formatting 2024-06-05 12:26:10 -05:00
TrèsAbhi 69ac95415a feat: better credits format 2024-06-05 12:21:05 -05:00
TrèsAbhi ddb0bd8d09 feat: start on expansive footer 2024-06-05 12:12:18 -05:00
TrèsAbhi 4683de90ba feat: better image alignment and loading performance 2024-06-04 18:18:24 -05:00
TrèsAbhi 429049761f feat: edge plus button 2024-06-04 18:06:15 -05:00
TrèsAbhi 61958e72da fix: non-collapsing compare header 3 2024-06-04 17:56:27 -05:00
TrèsAbhi 2f32a076a1 fix: non-collapsing compare header 2 2024-06-04 17:55:15 -05:00
TrèsAbhi a06bcc6a4d fix: non-collapsing compare header 2024-06-04 17:43:14 -05:00
TrèsAbhi 3e0e05b8cc fix: use bounding rects for rotation inputs 2024-06-04 17:37:59 -05:00
TrèsAbhi d601e9fbc3 fix: high contrast link 2024-06-04 17:32:23 -05:00
TrèsAbhi 1c453f1105 fix: use jpeg 2024-06-03 18:29:57 -05:00
TrèsAbhi 1195f078c7 feat: banner 2024-06-03 18:28:09 -05:00
TrèsAbhi 12d07e7dab feat: smaller tool descs 2024-06-03 18:19:41 -05:00
TrèsAbhi e09b34b1d5 feat: blitzkit tankopedia watermark 2024-06-03 18:16:57 -05:00
TrèsAbhi 0c5bf85bc5 fix: use new link component in navbar 2024-06-03 18:07:01 -05:00
TrèsAbhi 5c328d0f4d feat: highliting sections 2024-06-03 18:02:10 -05:00
TrèsAbhi d4976f07f1 feat: more responsive 2024-06-03 17:53:01 -05:00
TrèsAbhi 87b0824523 fix: max width 2024-06-03 17:46:59 -05:00
TrèsAbhi 3d2b443dc7 feat: new homepage 2024-06-03 17:45:50 -05:00
TrèsAbhi 847d605f08 feat: fully implement roboto flex 2024-06-03 16:47:21 -05:00
TrèsAbhi 17cd7e9dc5 fix: no html pointer events 2024-06-03 15:56:02 -05:00
TrèsAbhi a1b25e1e52 fix: discard shot on tank change 2024-06-03 15:52:30 -05:00
TrèsAbhi 42f1dd33b5 fix: multiplicative camouflage 2024-06-03 15:33:41 -05:00
TrèsAbhi 3c4743731b fix: light tank on move camo now same as still 2024-06-03 14:21:36 -05:00
TrèsAbhi 17439bf559 fix: multiplicative camouflage 2024-06-03 14:20:24 -05:00
TrèsAbhi 94b8cfefcf fix: non-obtrusive hit markers 2024-06-03 13:57:15 -05:00
TrèsAbhi a53cf2111e fix: only use gun masking when clipping plane is available 2024-06-03 13:47:55 -05:00
TrèsAbhi d679641c9d fix: smaller quick action buttons on smaller screens 2024-06-03 13:45:44 -05:00
TrèsAbhi 89bff4dcb1 Update credits.md 2024-05-31 19:25:07 -04:00
TrèsAbhi fece9863f8 feat: remove google ads from ads.txt 2024-05-27 22:44:19 -05:00
TrèsAbhi 079a9884a8 Merge branch 'main' of https://github.com/tresabhi/blitzkit 2024-05-27 22:43:15 -05:00
TrèsAbhi ab0c7d5572 feat: enable pubcenter 2024-05-27 22:43:14 -05:00
TrèsAbhi 433621b384 feat: stuff 2024-05-27 22:42:09 -05:00
TrèsAbhi ec95446bae Update ads.txt 2024-05-27 22:34:47 -05:00
TrèsAbhi 615d7f06aa fix: typing parity 2024-05-27 21:08:05 -05:00
TrèsAbhi 96ce3c7d41 fix: block he with red 2024-05-27 21:01:07 -05:00
TrèsAbhi 3230afc117 fix: keying shot display 2024-05-27 20:58:22 -05:00
TrèsAbhi 00cee28e08 fix: render tracer over armor 2024-05-27 20:57:04 -05:00
TrèsAbhi 2b34863b53 fix: ricochet angle 2024-05-27 20:42:48 -05:00
TrèsAbhi e16f3653bb feat: out tracer 2024-05-26 14:39:16 -05:00
TrèsAbhi 7f8797a449 feat: shot display 2024-05-26 13:42:51 -05:00
TrèsAbhi 3438e51ef1 feat: dispaly 2024-05-25 14:21:36 -05:00
TrèsAbhi dc50c4a758 feat: complete math 2024-05-25 13:32:00 -05:00
TrèsAbhi a2bf592eaf feat: more progress 2024-05-24 20:50:55 -05:00
TrèsAbhi a3653b1c1f fix: select first shell on gun swap 2024-05-24 20:36:10 -05:00
TrèsAbhi 15c41ba7c9 fix: no text select on cam rotate 2024-05-24 20:33:19 -05:00
TrèsAbhi 542145467c feat: start rewrite on onClick armor 2024-05-24 20:32:39 -05:00
TrèsAbhi 82933aaf1e fix: heat doesn't actually ricochet lol 2024-05-24 13:48:50 -05:00
TrèsAbhi 8e2302a6d4 feat: start of homepage tracking 2024-05-23 14:05:25 -05:00
TrèsAbhi 477c35db34 fix: adjective titles 2024-05-23 13:48:21 -05:00
TrèsAbhi f7e7fdff0e feat: tank search hydration issues 2024-05-23 13:46:33 -05:00
TrèsAbhi 02daeff583 feat: reduce tanks per page for mobile 2024-05-23 13:29:39 -05:00
TrèsAbhi ddb822054f fix: navbar links 2024-05-23 13:26:43 -05:00
TrèsAbhi 96b4ab4380 fix: no scroll on desktop 2024-05-23 13:20:07 -05:00
TrèsAbhi 4206395f49 fix: footer hydration issues 2024-05-23 13:17:45 -05:00
TrèsAbhi 2a32a1a7cb fix: checks hydration issues 2024-05-23 13:14:45 -05:00
TrèsAbhi 6e96798d5e feat: navbar hydration issues 2024-05-23 13:03:58 -05:00
TrèsAbhi dab3393ba2 feat: legal stuff 2024-05-23 12:41:21 -05:00
TrèsAbhi e2502abd9b feat: agreements 2024-05-23 12:33:47 -05:00
TrèsAbhi 4f29b1b580 feat: legalities 2024-05-23 12:06:05 -05:00
TrèsAbhi f23db7a02a Merge pull request #121 from tresabhi/main 2024-05-23 11:46:15 -05:00
TrèsAbhi 372f04ee20 fix: he strength 2 2024-05-23 11:43:34 -05:00
TrèsAbhi 78adba82de fix: he strength 2024-05-23 09:54:29 -05:00
TrèsAbhi 8f9b60c7e3 feat: some testing gaps 2024-05-23 09:53:49 -05:00
TrèsAbhi a9497d061d feat: more code splitting 2024-05-22 14:16:46 -05:00
TrèsAbhi 5d6e864451 feat: scroll to view home search 2024-05-22 13:57:57 -05:00
TrèsAbhi be7b00c4be feat: more compact modules 2024-05-22 13:52:30 -05:00
TrèsAbhi 9f867b7933 feat: cleaner consumables border radius 2024-05-22 13:51:16 -05:00
TrèsAbhi a3cbe6d1e6 feat: live input updates 2024-05-22 13:47:35 -05:00
TrèsAbhi 6b63231194 feat: some code splitting 2024-05-22 13:43:08 -05:00
TrèsAbhi 5f47218f16 Merge pull request #120 from tresabhi/main 2024-05-22 13:31:39 -05:00
TrèsAbhi 72a90e7d1f fix: 1+ cali 2024-05-22 13:30:39 -05:00
TrèsAbhi 22cd18956b feat: start of isolation 2024-05-22 13:27:07 -05:00
TrèsAbhi 30a3e10bec feat: icon buttons for page turner 2024-05-22 13:20:56 -05:00
TrèsAbhi fda4f76be3 Merge pull request #119 from tresabhi/main 2024-05-22 13:06:10 -05:00
TrèsAbhi 8b9a13a58e Merge pull request #118 from tresabhi/dev-static-updates 2024-05-22 13:04:32 -05:00
TrèsAbhi 903b7fa677 feat: season 4 updates 2024-05-22 13:04:39 -05:00
TrèsAbhi a7bd30348d feat: larger navbar link text 2024-05-21 20:57:26 -05:00
TrèsAbhi 7c950e1121 feat: complete functionality 2024-05-21 14:18:21 -05:00
TrèsAbhi 3631fb3cab feat: new homepage 2024-05-20 21:00:47 -05:00
TrèsAbhi 2ce69b085a feat: new home screen 2024-05-20 14:16:13 -05:00
TrèsAbhi a7b2a0abe9 feat: apple inspired navbar 2024-05-20 13:33:32 -05:00
TrèsAbhi 1db7354c1b fix: safari blur 2024-05-20 12:31:43 -05:00
TrèsAbhi bde1bd279d feat: clone navbar 2024-05-20 12:30:22 -05:00
TrèsAbhi 4062a09c05 feat: aeson navbar 2024-05-20 12:07:00 -05:00
TrèsAbhi 386cca7226 feat: close menu on click 2024-05-19 21:09:49 -05:00
TrèsAbhi d1c6ad90d5 feat: reveal menu on hover 2024-05-19 21:05:23 -05:00
TrèsAbhi 9ff756e458 feat: bun script 2024-05-19 20:56:04 -05:00
TrèsAbhi 68357f6255 fix: one-liner 2024-05-19 18:07:36 -05:00
TrèsAbhi 8c8867280b feat: script 2024-05-19 18:06:02 -05:00
TrèsAbhi 505f74aa26 feat: complete averages script 2024-05-19 17:50:01 -05:00
TrèsAbhi b4c7b07158 feat: id discovery 2024-05-18 21:12:11 -05:00
TrèsAbhi 87b44d1c27 feat: r 2024-05-17 20:29:27 -05:00
TrèsAbhi 61db42d225 feat: more! 2024-05-17 20:23:51 -05:00
TrèsAbhi 8d69784779 feat: more! 2024-05-17 14:14:44 -05:00
TrèsAbhi 02b5f0c45a feat: more! 2024-05-16 20:35:12 -05:00
TrèsAbhi b5b87fbe17 feat: technical dogs 2024-05-15 19:44:51 -05:00
TrèsAbhi ca418b28b5 fix: default closed navbar 2024-05-14 14:12:06 -05:00
TrèsAbhi 0198829a18 feat: better hamburger menu 2024-05-14 14:07:44 -05:00
TrèsAbhi cdd2a08ec0 feat: logs 2024-05-05 14:24:39 -05:00
TrèsAbhi e0e0624010 feat: stop autorotate on pose 2024-05-05 14:10:50 -05:00
TrèsAbhi 03ee0828fe fix: distance gray 2024-05-05 14:08:33 -05:00
TrèsAbhi 3cda99cbc1 feat: plug 2024-05-05 14:08:13 -05:00
TrèsAbhi 258421e77e feat: space in between modules 2024-05-05 13:59:44 -05:00
TrèsAbhi 121a91ce75 feat: tighter consumables row 2024-05-05 13:57:11 -05:00
TrèsAbhi 0a74e43f67 feat: remove vercel subdomain 2024-05-05 13:52:08 -05:00
TrèsAbhi 4900aa8dd9 feat: dev subdomain 2024-05-05 13:48:33 -05:00
TrèsAbhi 07b82aa383 Merge pull request #117 from tresabhi/main 2024-05-05 13:40:01 -05:00
TrèsAbhi ae852564de fix: analysitcs 2024-05-05 12:12:52 -05:00
TrèsAbhi ea4b244336 feat: root domain sitemaps 2024-05-05 12:05:33 -05:00
TrèsAbhi f6925a93f3 feat: descs 2024-05-05 12:04:27 -05:00
TrèsAbhi 03205d3659 feat: highlight top tier modules 2024-05-04 17:50:06 -05:00
TrèsAbhi def470e805 feat: large tank icons 2024-05-04 17:42:32 -05:00
TrèsAbhi 3f8c2ba322 fix: remove second page turner on low results 2024-05-04 17:28:12 -05:00
TrèsAbhi aaf013c650 feat: smolecraft suggestions 1 2024-05-04 17:25:03 -05:00
TrèsAbhi a099df5133 feat: rotation inputs 2024-05-04 17:14:08 -05:00
TrèsAbhi cebc4392ed feat: cleaner fullscreen 2024-05-04 17:02:03 -05:00
TrèsAbhi c9cfb16469 feat: controls 2024-05-04 16:53:26 -05:00
TrèsAbhi c069d132c1 feat: loading bar 2024-05-04 16:44:37 -05:00
TrèsAbhi a5b3177394 feat: no flex 1 2024-05-04 16:39:41 -05:00
TrèsAbhi a7f2c3e324 feat: responsive tech tree 2024-05-04 16:37:36 -05:00
TrèsAbhi 97cd218796 feat: image alts 2024-05-04 16:28:12 -05:00
TrèsAbhi ab510361b5 feat: better layout 2024-05-04 16:19:27 -05:00
TrèsAbhi 299ccef579 feat: bring back stats 2024-05-04 13:46:57 -05:00
TrèsAbhi 42076c1317 feat: module arrows 2024-05-04 13:16:33 -05:00
TrèsAbhi d0743b54e0 feat: better modules 2024-05-04 12:05:37 -05:00
TrèsAbhi 7af5d5f465 feat: module tree 2024-05-04 11:00:54 -05:00
TrèsAbhi 89e97f484b feat: unlocks proof of concept 2024-05-03 23:58:19 -05:00
TrèsAbhi e5d109c973 fix: use card tank value 2024-05-03 23:01:53 -05:00
TrèsAbhi 67fda9b577 feat: falg 2024-05-03 22:54:53 -05:00
TrèsAbhi 64ddacf5c7 feat: tech tree 2024-05-03 22:16:32 -05:00
TrèsAbhi 04434f2acd feat: disabled autorotate on pointer down 2024-05-03 21:19:25 -05:00
TrèsAbhi 798b6894a9 feat: compare button 2024-05-03 21:17:31 -05:00
TrèsAbhi d3760f7705 feat: shooter icon order 2024-05-03 21:08:50 -05:00
TrèsAbhi cc094ff8e4 fix: bottom padding 2024-05-03 20:54:00 -05:00
TrèsAbhi ee66dd31b5 feat: better hero responsiveness 2024-05-03 20:52:59 -05:00
TrèsAbhi d6993c802f feat: fullscreen hero 2024-05-03 20:49:05 -05:00
TrèsAbhi 98716b7f71 feat: shells 2024-05-03 19:37:41 -05:00
TrèsAbhi cc1e3e1315 fix: margin 2024-05-03 19:19:10 -05:00
TrèsAbhi 7bd84fdaec feat: shells 2024-05-03 19:18:34 -05:00
TrèsAbhi a8bb6d0bb8 feat: characteriscs lauytout 2024-05-03 19:10:51 -05:00
TrèsAbhi 9ecf9ed406 feat: separator 2024-05-03 19:03:25 -05:00
TrèsAbhi 551e562109 feat: combine controls 2024-05-03 17:52:45 -05:00
TrèsAbhi 1d955078ef fix: nation adjectives 2024-05-03 17:42:28 -05:00
TrèsAbhi 80c16bce1c feat: switch 2024-05-03 17:39:06 -05:00
TrèsAbhi a961643c76 feat: better ratios of hero 2024-05-03 17:35:59 -05:00
TrèsAbhi ea7f40a808 feat: center name 2024-05-03 17:26:14 -05:00
TrèsAbhi 797642a3c3 feat: rest of config 2024-05-03 17:22:36 -05:00
TrèsAbhi 1c3a20c627 fix: gun tier in map 2024-05-03 17:10:00 -05:00
TrèsAbhi c855bd1653 feat: start of characteristics 2024-05-03 17:07:27 -05:00
TrèsAbhi 381a4fefd9 feat: exit fullscreen 2024-05-03 15:30:17 -05:00
TrèsAbhi e3d69ab83c feat: revamp 2024-05-03 12:57:04 -05:00
TrèsAbhi 468308ad22 feat: descs 2024-05-03 12:02:56 -05:00
TrèsAbhi d3e3d6d7b6 Merge branch 'dev' of https://github.com/tresabhi/blitzkit into dev 2024-05-03 11:49:33 -05:00
TrèsAbhi 32dbd866d4 fix: filter video definitions 2 2024-05-03 11:49:22 -05:00
TrèsAbhi ed9b61c97d fix: filter video definitions 2024-05-03 11:49:22 -05:00
TrèsAbhi 65014cade3 empty 2024-05-03 11:40:20 -05:00
TrèsAbhi be8d565545 fix: filter video definitions 2 2024-05-03 10:38:55 -05:00
TrèsAbhi b151c7483a fix: filter video definitions 2024-05-03 10:35:29 -05:00
123 changed files with 6742 additions and 3925 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
name: CD Averages
on:
workflow_dispatch:
# schedule:
# - cron: '0 12 * * 0'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: bun install
- name: Build averages
run: |
GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} VIDEOS_SHEET=${{ secrets.VIDEOS_SHEET }} GOOGLE_APPLICATION_CREDENTIALS=${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} bun scripts/averages.ts
+26
View File
@@ -0,0 +1,26 @@
name: CD Discover IDs
on:
push:
branches: main
workflow_dispatch:
schedule:
- cron: '0 8 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: bun install
- name: Discover IDs
run: DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} GH_TOKEN=${{ secrets.GH_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} bun scripts/discoverIds.ts --production
+5 -1
View File
@@ -49,6 +49,7 @@
"degressive",
"devkrieg",
"diacriticless",
"DIDS",
"donotremove",
"drei",
"Droodles",
@@ -65,11 +66,13 @@
"fullstats",
"fuzzysort",
"gamemode",
"gameplay",
"glsl",
"GLTF",
"haha",
"highp",
"iife",
"imgur",
"immer",
"Inclusivity",
"JOINTINDEX",
@@ -180,5 +183,6 @@
],
"deno.enable": false,
"deno.unstable": false,
"cSpell.enableFiletypes": ["glsl"]
"cSpell.enableFiletypes": ["glsl"],
"typescript.tsdk": "node_modules\\typescript\\lib"
}
+1 -1
View File
@@ -12,7 +12,7 @@ Go to [application directory for BlitzKit](https://discord.com/application-direc
## Visit the website
### https://blitzkit.vercel.app/
### https://blitzkit.app/
![BlitzKit website](https://i.imgur.com/bdYMUCL.png)
+17
View File
@@ -0,0 +1,17 @@
# BlitzKit Credits
BlitzKit is a one man band **TrèsAbhi** but I am far from solo.
| Role | People |
| ------------------------------ | ------------------------------ |
| Bot verification | S0AP |
| Bot advertisement platform | S0AP |
| Bot rendering pipeline | Vovko |
| Game asset decompression | Maddox |
| Game 3D model parsing | Pyogenics |
| Game file layout | Aeson |
| 3-caliber rule edge cases | Minitelrose, ChickenMan7777 |
| Embed UX | \_34, HisRoyalFatness, Synx |
| Tankopedia pre-release testing | [OPTML], Aeson, \_34, Droodles |
| Website advertisement platform | Aeson, Droodles |
| Compare pre-release testing | [OPTML], Droodles |
+32
View File
@@ -0,0 +1,32 @@
# BlitzKit 1.20.1
The Tankopedia becomes sexier.
## Features
- Switched domain to https://blitzkit.app/
- Added a hero section with the name of the Tank displayed in a huge font with the model of the tank on the right auto-rotating
- The auto-rotation is disabled upon manual disruption of the camera angle
- Added a historical section for some tanks
- Full screen is cleaner
- The rotation inputs have been moved up to the top of the page
- The shell selector has been moved to the right of the page to mimic the game
- The antagonist (or the shooter) controls have been moved to the bottom of the sandbox
- The "armor" and "model" tabs have been replaced with a single toggle switch labeled as "armor:
- Tank modules are now rendered similar to how they are in the game as a tree
- This is highly experimental; please report issues on [the official Discord server](https://discord.gg/nDt7AjGJQH)
- The XP cost is also listed in this section
- Top tier modules are highlighted in amber
- Added a section to show the tech tree progression for tech tree tanks
- The credit and XP costs are also listed in this section along with the nationality
- The shell selector has been simplified
- Added a "back" button to return to the Tankopedia search page
- The "change" button has now been removed
- The characteristics are now displayed with three columns when possible with the configuration acting as the first row
- Replaced the loading percentage for tank models with a loading bar
- The second page turner controls in the tankopedia search is now hidden when there's not a lot of search results
## Fixes
- Tanks without videos no longer display a broken YouTube video
- Added "alt" texts for all images on the website
+48
View File
@@ -0,0 +1,48 @@
# BlitzKit 1.20.2
Taming the homepage.
## Features
- All tools are now displayed directly on the navbar on wide screens
- On small screens, the tools are hidden within a hamburger menu displaying a list similar to the old homepage
- Added tiny hero image banner to the homepage
- The tools are now displayed in a more readable manner with the most important tool(s) shown twice as large as all others on wider screens
- The pitch and yaw inputs for tank turrets is now update live as the turret and gun is rotated
- Consumables are now displayed in a neater box
- Modules are now displayed in a more compact manner
- Added a privacy policy and terms of service agreement prompt
- The links also point to an on-site page
- Remade the navbar to include important information
- The about section offers credits, a link to Discord, and a link to the GitHub repository
- The legal section displays privacy policy, terms of service, and a link to Google's ad settings
- On the left side, the name of BlitzKit, version, and motto is displayed
- The armor sandbox breakdown has been rewritten from scratch to maintain full parity with the visual armor highlighter, especially for HE shells
- The tracer has also been made more visible by rendering it on top of the armor
- The tracer's penetration locations have also been simplified and no longer show the index
- The number of tanks per page is now reduced for smaller screen sizes
- Added a BlitzKit watermark to the tankopedia sandbox when in fullscreen
- The tool's backgrounds are no longer blurred on their dedicated page
- Added a plus button to add tanks to the right of the rightmost column in the compare tool
## Fixes
- The navbar blur effect now works on Safari too
- Switched buttons out for icon buttons for page turners
- Many hydration issues have been fixed allowing a few pages to load twice as fast
- The nationality of tanks in the title of the tab are now in adjective forms
- HEAT shells no longer ricochet
- Text on page won't be selected on accident when interacting with the tank sandbox
- The first shell is now equipped by default instead of the last one when switching guns/turrets
- Quite a few pages have been more responsive for smaller screens
- Camouflage bonuses are now multiplicative instead of additive
- Light tanks now have the same camouflage on move as when still
- The shot display is now discarded when the tank is swapped out
- Roboto Flex is now properly used instead of the default Radix UI font
- The website now uses Imgur to load images and scale them appropriately for better performance
- The compare headers no longer collapse on Firefox
- Turrets can now be rotated correctly in the Tankopedia on Apple devices
## Technical Changes
- Moved all technical documentations to the `technical` directory within `docs`
+14
View File
@@ -0,0 +1,14 @@
# BlitzKit 1.20.3
A bug turns into a rating command feature!
## Features
- The rating command was giving seasonal statistics instead for the current day; this bug has now been made a feature through the new `/rating season` command
## Fixes
- Missing WN8 is now displayed with "--" instead of `NaN`
- Players who have not participated in the rating gamemode are handled correctly
- Decreasing position is now positive indicated in `/rating`
- `/rating` now displays today's statics correctly once again
-16
View File
@@ -1,16 +0,0 @@
# Team
- TrèsAbhi: creator & programmer
# Contributors
- Prince_NA: general game advisor and tester
- S0AP: bot verification and initial advertizement platform
- Vovko: discord bot rendering pipeline
- Maddox: game file decompression
- Pyogenics: tank model parsing
- Aeson: game files layout
- Minitelrose, ChickenMan7777: 3-caliber rule edge cases
- \_34, HisRoyalFatness, Synx, xkdkw: embed UX
- [OPTML], Aeson, \_34, Droodles: beta testing Tankopedia
- [OPTML], Droodles: beta testing Compare
+1 -1
View File
@@ -15,7 +15,7 @@ BlitzKit is available as a Discord bot and can be [installed very easily](/guide
## Website
Provides a bunch of very helpful tools; https://blitzkit.vercel.app/
Provides a bunch of very helpful tools; https://blitzkit.app/
- Session tracker
- Rating leaderboard
+181 -9
View File
@@ -1,19 +1,191 @@
# BlitzKit Privacy Policy
BlitzKit collects little to no data. Regardless, here is an exhaustive list of BlitzKit's data collection practices.
Last updated: May 23, 2024
## Logs
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.
All BlitzKit logs are available to the public as an effort for open source transparency. A history of all logs [can be found on GitHub](https://github.com/tresabhi/blitzkit/actions/workflows/host.yml). All logs expire automatically and are permanently deleted after 30 days. However, anyone may chose to save a local copy of the logs which is not tracked by GitHub.
We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy. This Privacy Policy has been created with the help of the [Privacy Policy Generator](https://www.termsfeed.com/privacy-policy-generator/).
## Commands
## Interpretation and Definitions
Everytime a command a command is used, the command name and parameters provided is logged. Any identifying information like manually provided in-game usernames are also recorded.
### Interpretation
## Databases
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
No databases are used by BlitzKit meaning that no data is collected outside of what has already been mentioned above. This is no permanent state.
### Definitions
## 3rd Party
For the purposes of this Privacy Policy:
The privacy policies of Discord, Wargaming, and Blitz Stars apply too since they are utilized by the bot.
- **Account** means a unique account created for You to access our Service or parts of our Service.
- **Affiliate** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to BlitzKit.
- **Cookies** are small files that are placed on Your computer, mobile device or any other device by a website, containing the details of Your browsing history on that website among its many uses.
- **Country** refers to: Missouri, United States
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
- **Personal Data** is any information that relates to an identified or identifiable individual.
- **Service** refers to the Website.
- **Service Provider** means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service or to assist the Company in analyzing how the Service is used.
- **Usage Data** refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).
- **Website** refers to BlitzKit, accessible from [https://blitzkit.app/](https://blitzkit.app/)
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
## Collecting and Using Your Personal Data
### Types of Data Collected
#### Personal Data
While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to contact or identify You. Personally identifiable information may include, but is not limited to:
- Usage Data
#### Usage Data
Usage Data is collected automatically when using the Service.
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the type of mobile device You use, Your mobile device unique ID, the IP address of Your mobile device, Your mobile operating system, the type of mobile Internet browser You use, unique device identifiers and other diagnostic data.
We may also collect information that Your browser sends whenever You visit our Service or when You access the Service by or through a mobile device.
#### Tracking Technologies and Cookies
We use Cookies and similar tracking technologies to track the activity on Our Service and store certain information. Tracking technologies used are beacons, tags, and scripts to collect and track information and to improve and analyze Our Service. The technologies We use may include:
- **Cookies or Browser Cookies.** A cookie is a small file placed on Your Device. You can instruct Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do not accept Cookies, You may not be able to use some parts of our Service. Unless you have adjusted Your browser setting so that it will refuse Cookies, our Service may use Cookies.
- **Web Beacons.** Certain sections of our Service and our emails may contain small electronic files known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that permit the Company, for example, to count users who have visited those pages or opened an email and for other related website statistics (for example, recording the popularity of a certain section and verifying system and server integrity).
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal computer or mobile device when You go offline, while Session Cookies are deleted as soon as You close Your web browser. You can learn more about cookies on [TermsFeed website](https://www.termsfeed.com/blog/cookies/#What_Are_Cookies) article.
We use both Session and Persistent Cookies for the purposes set out below:
- **Necessary / Essential Cookies**
Type: Session Cookies
Administered by: Us
Purpose: These Cookies are essential to provide You with services available through the Website and to enable You to use some of its features. They help to authenticate users and prevent fraudulent use of user accounts. Without these Cookies, the services that You have asked for cannot be provided, and We only use these Cookies to provide You with those services.
- **Cookies Policy / Notice Acceptance Cookies**
Type: Persistent Cookies
Administered by: Us
Purpose: These Cookies identify if users have accepted the use of cookies on the Website.
- **Functionality Cookies**
Type: Persistent Cookies
Administered by: Us
Purpose: These Cookies allow us to remember choices You make when You use the Website, such as remembering your login details or language preference. The purpose of these Cookies is to provide You with a more personal experience and to avoid You having to re-enter your preferences every time You use the Website.
- **Google AdSense Cookies**
Type: Persistent Cookies
Administered by: Google AdSense
Purpose: Third party vendors, including Google, use cookies to serve ads based on a user's prior visits to BlitzKit. Google's use of advertising cookies enables it and its partners to serve ads to your users based on their visit to your sites and/or other sites on the Internet. Users may opt out of personalized advertising by visiting [Ads Settings](https://www.google.com/settings/ads).
For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy.
### Use of Your Personal Data
The Company may use Personal Data for the following purposes:
- **To provide and maintain our Service**, including to monitor the usage of our Service.
- **To manage Your Account:** to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.
- **For the performance of a contract:** the development, compliance and undertaking of the purchase contract for the products, items or services You have purchased or of any other contract with Us through the Service.
- **To contact You:** To contact You by email, telephone calls, SMS, or other equivalent forms of electronic communication, such as a mobile application's push notifications regarding updates or informative communications related to the functionalities, products or contracted services, including the security updates, when necessary or reasonable for their implementation.
- **To provide You** with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless You have opted not to receive such information.
- **To manage Your requests:** To attend and manage Your requests to Us.
- **For business transfers:** We may use Your information to evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar proceeding, in which Personal Data held by Us about our Service users is among the assets transferred.
- **For other purposes**: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service, products, services, marketing and your experience.
We may share Your personal information in the following situations:
- **With Service Providers:** We may share Your personal information with Service Providers to monitor and analyze the use of our Service, to contact You.
- **For business transfers:** We may share or transfer Your personal information in connection with, or during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a portion of Our business to another company.
- **With Affiliates:** We may share Your information with Our affiliates, in which case we will require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and any other subsidiaries, joint venture partners or other companies that We control or that are under common control with Us.
- **With business partners:** We may share Your information with Our business partners to offer You certain products, services or promotions.
- **With other users:** when You share personal information or otherwise interact in the public areas with other users, such information may be viewed by all users and may be publicly distributed outside.
- **With Your consent**: We may disclose Your personal information for any other purpose with Your consent.
### Retention of Your Personal Data
The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
### Transfer of Your Personal Data
Your information, including Personal Data, is processed at the Company's operating offices and in any other places where the parties involved in the processing are located. It means that this information may be transferred to — and maintained on — computers located outside of Your state, province, country or other governmental jurisdiction where the data protection laws may differ than those from Your jurisdiction.
Your consent to this Privacy Policy followed by Your submission of such information represents Your agreement to that transfer.
The Company will take all steps reasonably necessary to ensure that Your data is treated securely and in accordance with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of Your data and other personal information.
### Delete Your Personal Data
You have the right to delete or request that We assist in deleting the Personal Data that We have collected about You.
Our Service may give You the ability to delete certain information about You from within the Service.
You may update, amend, or delete Your information at any time by signing in to Your Account, if you have one, and visiting the account settings section that allows you to manage Your personal information. You may also contact Us to request access to, correct, or delete any personal information that You have provided to Us.
Please note, however, that We may need to retain certain information when we have a legal obligation or lawful basis to do so.
### Disclosure of Your Personal Data
#### Business Transactions
If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be transferred. We will provide notice before Your Personal Data is transferred and becomes subject to a different Privacy Policy.
#### Law enforcement
Under certain circumstances, the Company may be required to disclose Your Personal Data if required to do so by law or in response to valid requests by public authorities (e.g. a court or a government agency).
#### Other legal requirements
The Company may disclose Your Personal Data in the good faith belief that such action is necessary to:
- Comply with a legal obligation
- Protect and defend the rights or property of the Company
- Prevent or investigate possible wrongdoing in connection with the Service
- Protect the personal safety of Users of the Service or the public
- Protect against legal liability
### Security of Your Personal Data
The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.
## Children's Privacy
Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.
If We need to rely on consent as a legal basis for processing Your information and Your country requires consent from a parent, We may require Your parent's consent before We collect and use that information.
## Links to Other Websites
Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review the Privacy Policy of every site You visit.
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
## Changes to this Privacy Policy
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page.
We will let You know via email and/or a prominent notice on Our Service, prior to the change becoming effective and update the "Last updated" date at the top of this Privacy Policy.
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
## Contact Us
If you have any questions about this Privacy Policy, You can contact us:
- By visiting this page on our website: [https://discord.gg/nDt7AjGJQH](https://discord.gg/nDt7AjGJQH)
+103 -7
View File
@@ -1,13 +1,109 @@
# BlitzKit Terms of Service
# BLitzKit Terms and Conditions
## About
Last updated: May 23, 2024
BlitzKit is a multi-function Discord bot that serves users with needs specific to the game World of Tanks Blitz.
Please read these terms and conditions carefully before using Our Service.
## License
## Interpretation and Definitions
BlitzKit is licensed under the MIT License.
### Interpretation
## 3rd Party
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
The terms of services of Discord, Wargaming, and Blitz Stars apply too since they are utilized by the bot.
### Definitions
For the purposes of these Terms and Conditions:
- **Affiliate** means an entity that controls, is controlled by or is under common control with a party, where "control" means ownership of 50% or more of the shares, equity interest or other securities entitled to vote for election of directors or other managing authority.
- **Country** refers to: Missouri, United States
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to BlitzKit.
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
- **Service** refers to the Website.
- **Terms and Conditions** (also referred as "Terms") mean these Terms and Conditions that form the entire agreement between You and the Company regarding the use of the Service. This Terms and Conditions agreement has been created with the help of the [Terms and Conditions Generator](https://www.termsfeed.com/terms-conditions-generator/).
- **Third-party Social Media Service** means any services or content (including data, information, products or services) provided by a third-party that may be displayed, included or made available by the Service.
- **Website** refers to BlitzKit, accessible from [https://blitzkit.app/](https://blitzkit.app/)
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
## Acknowledgment
These are the Terms and Conditions governing the use of this Service and the agreement that operates between You and the Company. These Terms and Conditions set out the rights and obligations of all users regarding the use of the Service.
Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and Conditions. These Terms and Conditions apply to all visitors, users and others who access or use the Service.
By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part of these Terms and Conditions then You may not access the Service.
You represent that you are over the age of 18. The Company does not permit those under 18 to use the Service.
Your access to and use of the Service is also conditioned on Your acceptance of and compliance with the Privacy Policy of the Company. Our Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your personal information when You use the Application or the Website and tells You about Your privacy rights and how the law protects You. Please read Our Privacy Policy carefully before using Our Service.
## Links to Other Websites
Our Service may contain links to third-party web sites or services that are not owned or controlled by the Company.
The Company has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the Company shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with the use of or reliance on any such content, goods or services available on or through any such web sites or services.
We strongly advise You to read the terms and conditions and privacy policies of any third-party web sites or services that You visit.
## Termination
We may terminate or suspend Your access immediately, without prior notice or liability, for any reason whatsoever, including without limitation if You breach these Terms and Conditions.
Upon termination, Your right to use the Service will cease immediately.
## Limitation of Liability
Notwithstanding any damages that You might incur, the entire liability of the Company and any of its suppliers under any provision of this Terms and Your exclusive remedy for all of the foregoing shall be limited to the amount actually paid by You through the Service or 100 USD if You haven't purchased anything through the Service.
To the maximum extent permitted by applicable law, in no event shall the Company or its suppliers be liable for any special, incidental, indirect, or consequential damages whatsoever (including, but not limited to, damages for loss of profits, loss of data or other information, for business interruption, for personal injury, loss of privacy arising out of or in any way related to the use of or inability to use the Service, third-party software and/or third-party hardware used with the Service, or otherwise in connection with any provision of this Terms), even if the Company or any supplier has been advised of the possibility of such damages and even if the remedy fails of its essential purpose.
Some states do not allow the exclusion of implied warranties or limitation of liability for incidental or consequential damages, which means that some of the above limitations may not apply. In these states, each party's liability will be limited to the greatest extent permitted by law.
## "AS IS" and "AS AVAILABLE" Disclaimer
The Service is provided to You "AS IS" and "AS AVAILABLE" and with all faults and defects without warranty of any kind. To the maximum extent permitted under applicable law, the Company, on its own behalf and on behalf of its Affiliates and its and their respective licensors and service providers, expressly disclaims all warranties, whether express, implied, statutory or otherwise, with respect to the Service, including all implied warranties of merchantability, fitness for a particular purpose, title and non-infringement, and warranties that may arise out of course of dealing, course of performance, usage or trade practice. Without limitation to the foregoing, the Company provides no warranty or undertaking, and makes no representation of any kind that the Service will meet Your requirements, achieve any intended results, be compatible or work with any other software, applications, systems or services, operate without interruption, meet any performance or reliability standards or be error free or that any errors or defects can or will be corrected.
Without limiting the foregoing, neither the Company nor any of the company's provider makes any representation or warranty of any kind, express or implied: (i) as to the operation or availability of the Service, or the information, content, and materials or products included thereon; (ii) that the Service will be uninterrupted or error-free; (iii) as to the accuracy, reliability, or currency of any information or content provided through the Service; or (iv) that the Service, its servers, the content, or e-mails sent from or on behalf of the Company are free of viruses, scripts, trojan horses, worms, malware, timebombs or other harmful components.
Some jurisdictions do not allow the exclusion of certain types of warranties or limitations on applicable statutory rights of a consumer, so some or all of the above exclusions and limitations may not apply to You. But in such a case the exclusions and limitations set forth in this section shall be applied to the greatest extent enforceable under applicable law.
## Governing Law
The laws of the Country, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. Your use of the Application may also be subject to other local, state, national, or international laws.
## Disputes Resolution
If You have any concern or dispute about the Service, You agree to first try to resolve the dispute informally by contacting the Company.
## For European Union (EU) Users
If You are a European Union consumer, you will benefit from any mandatory provisions of the law of the country in which You are resident.
## United States Legal Compliance
You represent and warrant that (i) You are not located in a country that is subject to the United States government embargo, or that has been designated by the United States government as a "terrorist supporting" country, and (ii) You are not listed on any United States government list of prohibited or restricted parties.
## Severability and Waiver
### Severability
If any provision of these Terms is held to be unenforceable or invalid, such provision will be changed and interpreted to accomplish the objectives of such provision to the greatest extent possible under applicable law and the remaining provisions will continue in full force and effect.
### Waiver
Except as provided herein, the failure to exercise a right or to require performance of an obligation under these Terms shall not affect a party's ability to exercise such right or require such performance at any time thereafter nor shall the waiver of a breach constitute a waiver of any subsequent breach.
## Translation Interpretation
These Terms and Conditions may have been translated if We have made them available to You on our Service. You agree that the original English text shall prevail in the case of a dispute.
## Changes to These Terms and Conditions
We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. If a revision is material We will make reasonable efforts to provide at least 30 days' notice prior to any new terms taking effect. What constitutes a material change will be determined at Our sole discretion.
By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the revised terms. If You do not agree to the new terms, in whole or in part, please stop using the website and the Service.
## Contact Us
If you have any questions about these Terms and Conditions, You can contact us:
- By visiting this page on our website: [https://discord.gg/nDt7AjGJQH](https://discord.gg/nDt7AjGJQH)
+181
View File
@@ -0,0 +1,181 @@
![Neutrality Index](https://i.imgur.com/XcGy5rh.png 'Neutrality Index')
# BlitzKit Neutrality Index
> [!CAUTION]
> This is a working draft and is incomplete. Do not implement.
## Why BkPM over WN8?
BkPM addresses a lot of issues with WN8:
- WN8 incorporates win rate into the metric; a metric like this must be a correlate of win rate, not a dependent
- The weights and biases are not dynamic and their formulation is not transparent
- WN8 values do not asymptote under extreme conditions resulting in meaninglessly large or negative values
- WN8 has too many digits; it doesn't make sense to differentiate between players with sub-one-thousandth's worth of precision
## Preface
The goal of this paper is to deliver a numerical metric for judging player performance by converging all statistics down to a single, easy-to-consume, human-readable value. This must be a mathematically elegant process that yields a reasonable number that asymptotes under extreme situations. More specifically, this value should equal 0, on average across all players and tanks. Extreme cases should be mapped onto the range $[-1, 1]$. To attain the human readable value, we will be transforming the raw metric to a scale of $[0, 2s]$ using $M=\operatorname{round}\left(sR+s\right)$ where $M$ is the resulting human-readable metric, $s = 100$ is an arbitrary scaling factor, and $R$ is the raw metric.
Wargaming provides cumulative statistics for players and tanks. The big idea is to average all provided statistics together to get one unified value. We will also incorporate other composite values based on these default statistics into the average. However, averaging this way poses two problems:
1. All statistics do not scale equally. For example, good players performing with 3,000 damage per battle could also attain a kill-to-death ratio of 3. But averaging them as-is would imply damage per battle has weight 1000 times that of the kill ratio. In other words, damage per battle would totally diminish the impact of kill ratios on the average. Hence, there’s a need for normalization. Normalization, in most cases, simply means dividing by the corresponding averages.
2. All statistics are not equally important. For example, dealing damage is arguably far more important than base capture; hence, it does not make sense to average them together. But this does not mean we totally discard this value as it does make an impact albeit very small. Instead, we will be incorporating weighted averages where the weights will be calculated through correlational analysis against win rate. Specifically, the weights of the statistics will be the corresponding correlation coefficient, r. One caveat to look out for is that r can be negative for statistics like damage received which must be minimized. Fortunately, negative weights are okay and make intuitive sense; after all, bleeding health should punish the metric.
## Computation
$\mu$ is a simple weighted average of statistics across all players where $C_{battles}$ is the number of player battles and $x_{i}$ is the statistic for player $i$.
$$
\mu=\frac{\sum_{ }^{ }C_{battles}x_{i}}{\sum_{ }^{ }C_{battles}}
$$
$\sigma$ is the standard deviation of the statistic across all players. Below is a refresher of the formula where $x_{i}$ is the statistic for player $i$ and $n$ is the number of players (or the sample size).
$$
\sigma=\sqrt{\frac{\sum_{ }^{ }\left(x_{i}-\mu\right)^{2}}{n}}
$$
A normalized atomic statistic is provided by the equation below where $x$ is the player's statistic being evaluated, $\mu$ is the mean of the statistic across all players, and $\sigma$ is the standard deviation of the statistic across all players. During computation, one may pre-compute and reuse $\gamma=x-\mu$.
$$
A=\frac{x-\mu}{\left|x-\mu\right|}\left(1-e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^{2}}\right)=\frac{\gamma}{\left|\gamma\right|}\left(1-e^{-\frac{1}{2}\left(\frac{\gamma}{\sigma}\right)^{2}}\right)
$$
$r$ is the correlation coefficient of the statistic across all players. Below is a refresher of the formula where $x_{i}$ is the statistic for player, $y_{i}$ is the player's win rate, $\mu$ is the mean of the statistic across all players, and $\overline{y}$ is the average win rate across all players.
$$
r=\frac{\sum_{ }^{ }\left(x_{i}-\mu\right)\left(y_{i}-\overline{y}\right)}{\sqrt{\sum_{ }^{ }\left(x_{i}-\mu\right)^{2}\sum_{ }^{ }\left(y_{i}-\overline{y}\right)^{2}}}
$$
$m$ is the slope of the least squares regression line. Below is a refresher of the formula where $x_i$ is the player's statistic being evaluated, $y_i$ is the player's win rate, and $n$ is the number of players (or the sample size).
$$
m=\frac{n\sum_{ }^{ }x_{i}y_{i}-\sum_{ }^{ }x_{i}\sum_{ }^{ }y_{i}}{n\sum_{ }^{ }x_{i}^{2}-\left(\sum_{ }^{ }x_{i}\right)^{2}}
$$
The weight of an atomic statistic is $w$ where $r$ is the correlation coefficient between the statistic and win rate, $\mu$ is the mean of the statistic across all players, and $m$ is the slope of the least squares regression line.
$$
w=\left|r\right|\mu m
$$
The raw metric is the weighted average of all atomic statistics.
$$
R=\frac{\sum_{ }^{ }wA}{\sum_{ }^{ }w}
$$
The final human-readable metric will be passed through the following transformation where $s=100$.
$$
M=\operatorname{round}\left(sR+s\right)
$$
## Constants
A few constants that the rest of the paper depends on. Available under the “constants” object.
```ts
players_per_team = 7;
```
## Wargaming-Provided Statistics
Wargaming provides the following pieces of statistics for each player and tank. These are cumulative; in other words, they simply add up over time and it is up to the recorder to contrast two snapshots of said statistics in time to get periodical statistics. Available under the “all” object.
```ts
battles;
capture_points;
damage_dealt;
damage_received;
dropped_capture_points;
frags; // kills
frags8p; // frags against tanks at or above tier 8
hits;
losses;
max_frags;
max_frags_tank_id;
max_xp;
max_xp_tank_id;
shots;
spotted;
survived_battles;
win_and_survived;
wins;
xp;
```
## Composite Statistics
Raw statistics from Wargaming can be combined into more meaningful numeric snippets. Most of these values are divided by all.battles indicating they are the average across all battles in the given period. Assigned under the “composite” object.
```ts
win_rate = all.wins / all.battles;
capture_points = all.capture_points / all.battles;
damage_dealt = all.damage_dealt / all.battles;
damage_received = all.damage_received / all.battles;
dropped_capture_points = all.dropped_capture_points / all.battles;
frags = all.frags / all.battles;
hits = all.hits / all.battles;
shots = all.shots / all.battles;
spotted = all.spotted / all.battles;
survival_rate = all.survived_battles / all.battles;
xp = all.xp / all.battles;
damage_ratio = all.damage_dealt / all.damage_received;
accuracy = all.hits / all.shots;
damage_per_hit = composite.damage_dealt / composite.hits;
```
## Normalized Statistics
We will be using the standard deviation and the mean of each metric supplied to this expression below to normalize each metric in the range $[-1, 1]$. The goal is to map the mean of the statistic to $0$, and the extrema to $-1$ and $1$ (whichever direction that may be in).
$$
\frac{x-\mu}{\left|x-\mu\right|}\left[1-e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^{2}}\right]
$$
During computation, one may pre-compute and reuse $\gamma=x-\mu$ in:
$$
\frac{\gamma}{\left|\gamma\right|}\left[1-e^{-\frac{1}{2}\left(\frac{\gamma}{\sigma}\right)^{2}}\right]
$$
Here is an example of HisRoyalFatness scoring a very high $0.988$ with the frags metric with his [XM551 Sheridan](https://blitzkit.app/tools/tankopedia/20257). $\mu=0.7694$, $\sigma=0.2492$, and $x=1.5113$, HisRoyalFatness' average frags. At $x = 0$ (if the player manages to get no frags), the metric drops nearly to $-1$ and vice versa. And, at $x=\mu$, the metric is exactly $0$.
![](https://i.imgur.com/aIUQmGF.png)
### Derivation
We start with the standard normal distribution formula
$$
\frac{1}{\sigma\sqrt{2\pi}}e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^{2}}
$$
![](https://i.imgur.com/a10m8Xz.png)
We remove the coefficient to transform the range of the function from $\left(0,\frac{1}{\sigma\sqrt{2\pi}}\right]$ to $\left(0,1\right]$. From this point onwards, I will be using the substitution $\gamma=x-\mu$.
$$
e^{-\frac{1}{2}\left(\frac{\gamma}{\sigma}\right)^{2}}
$$
![](https://i.imgur.com/67DO69i.png)
Then we mirror the function across $y=\frac{1}{2}$ to allow the function to be rewarding as you reach the extrema.
$$
1-e^{-\frac{1}{2}\left(\frac{\gamma}{\sigma}\right)^{2}}
$$
![](https://i.imgur.com/1J0cTyb.png)
Finally, to punish the function as you approach the left extreme, we flip the sign of the function when $x<\mu$. One may choose to implement this as a piece-wise function (especially when implementing this in a computer), but I have chosen to represent it as one unified coefficient in this paper.
$$
\frac{\gamma}{\left|\gamma\right|}\left[1-e^{-\frac{1}{2}\left(\frac{\gamma}{\sigma}\right)^{2}}\right]
$$
![](https://i.imgur.com/O8stVlJ.png)
File renamed without changes.
File renamed without changes.
+18
View File
@@ -0,0 +1,18 @@
# Discovered Ids
```c
primary DiscoveredIds {
utf8"DIDS" magic;
Header header;
primary Body body;
}
Header {
uint16 version; // always 1
}
Body {
uint32 count;
uint32[count] ids;
}
```
File renamed without changes.
+9
View File
@@ -14,6 +14,15 @@ const nextConfig = {
},
}),
async headers() {
return [
{
source: '/(.*)?',
headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
},
];
},
distDir: 'dist/website',
reactStrictMode: false,
};
+9 -4
View File
@@ -1,10 +1,11 @@
{
"name": "blitzkit",
"version": "1.20.0",
"version": "1.20.3",
"description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts",
"type": "module",
"scripts": {
"build:averages": "bun scripts/averages",
"build:db": "prisma generate",
"build:bot": "vite-node --script scripts/build --production",
"build:website": "yarn build:db && yarn build:sitemap && next build",
@@ -29,7 +30,8 @@
"knip": "knip",
"update": "yarn upgrade --latest",
"setup:depotdownloader": "vite-node --script scripts/setupDepotDownloader",
"tunnel": "cloudflared tunnel --url http://localhost:3000"
"tunnel": "cloudflared tunnel --url http://localhost:3000",
"discoverids": "scripts/discoverIds.ts"
},
"license": "MIT",
"dependencies": {
@@ -60,10 +62,12 @@
"lodash": "^4.17.21",
"lz4js": "^0.2.0",
"markdown-escape": "^2.0.0",
"markdown-to-jsx": "^7.4.7",
"next": "^14.1.0",
"parse-dds": "^1.2.1",
"pickleparser": "^0.2.1",
"postprocessing": "^6.34.1",
"progress": "^2.0.3",
"protobufjs": "^7.2.6",
"react": "^18.2.0",
"react-device-detect": "^2.2.3",
@@ -71,7 +75,7 @@
"react-fuzzysort": "^1.0.1",
"react-promise-suspense": "^0.3.4",
"satori": "^0.10.13",
"sharp": "^0.33.3",
"sharp": "^0.33.4",
"three": "0.159.0",
"three-stdlib": "^2.29.4",
"vite-node": "^0.34.5",
@@ -94,6 +98,7 @@
"@types/node": "^20.6.5",
"@types/parse-dds": "^1.0.3",
"@types/pngjs": "^6.0.4",
"@types/progress": "^2.0.7",
"@types/react": "^18.2.22",
"@vanilla-extract/next-plugin": "^2.3.1",
"compare-versions": "^6.1.0",
@@ -103,7 +108,7 @@
"lz4": "^0.6.5",
"prettier": "^3.0.3",
"prisma": "^5.4.1",
"typescript": "^5.2.2"
"typescript": "^5.4.5"
},
"packageManager": "[email protected]"
}
-1
View File
@@ -1,3 +1,2 @@
google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0
appnexus.com, 14163, RESELLER
pubcenter.microsoft.com, 253617120, DIRECT
Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

+3 -3
View File
@@ -1,7 +1,7 @@
User-agent: *
Allow: /
Host: https://blitzkit.vercel.app
Host: https://blitzkit.app
Sitemap: https://blitzkit.vercel.app/sitemap.txt
Sitemap: https://blitzkit.vercel.app/sitemap.xml
Sitemap: https://blitzkit.app/sitemap.txt
Sitemap: https://blitzkit.app/sitemap.xml
+266
View File
@@ -0,0 +1,266 @@
import { times } from 'lodash';
import { compress } from 'lz4js';
import { argv } from 'process';
import ProgressBar from 'progress';
import { Region, REGIONS } from '../src/constants/regions';
import getTankStats from '../src/core/blitz/getTankStats';
import { idToRegion, MAX_IDS, MIN_IDS } from '../src/core/blitz/idToRegion';
import { asset } from '../src/core/blitzkit/asset';
import {
AverageDefinitions,
AverageDefinitionsAllStats,
} from '../src/core/blitzkit/averageDefinitions';
import { averageDefinitionsAllStatsKeys } from '../src/core/blitzkit/averageDefinitions/constants';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { superCompress } from '../src/core/blitzkit/superCompress';
import { DidsReadStream, DidsWriteStream } from '../src/core/streams/dids';
import { IndividualTankStats } from '../src/types/tanksStats';
const RUN_TIME = 1000 * 60 * 60;
const MAX_REQUESTS = 10;
const production = argv.includes('--production');
const startTime = Date.now();
const fields = 'all,tank_id,battle_life_time';
const players: IndividualTankStats[][] = [];
const preDiscovered = await fetch(asset('averages/discovered.dids.lz4')).then(
async (response) => {
if (response.status === 200) {
const buffer = await response.arrayBuffer();
return new DidsReadStream(buffer).dids();
}
return undefined;
},
);
const discoveredIds: number[] = [];
let processes = 0;
if (preDiscovered) {
console.log(
`Revalidating ${preDiscovered.length} pre-discovered ids with ${MAX_REQUESTS} chains...`,
);
const preDiscoveredBar = new ProgressBar(':bar', {
total: preDiscovered.length,
});
let preDiscoveredIndex = 0;
async function chainPreDiscovery() {
processes++;
if (preDiscoveredIndex === preDiscovered!.length) {
processes--;
if (processes !== 0) return;
console.log(
`Rediscovered ${discoveredIds.length} / ${preDiscovered!.length} ids in ${Date.now() - startTime}ms`,
);
discover();
return;
}
const id = preDiscovered![preDiscoveredIndex++];
const region = idToRegion(id);
if (id > MAX_IDS[region]) return;
const stats = await getTankStats(region, id, { fields });
if (stats !== null && stats.length > 0) {
discoveredIds.push(id);
players.push(stats);
preDiscoveredBar.tick();
}
processes--;
setTimeout(chainPreDiscovery); // circumvent max call stack
}
times(MAX_REQUESTS, chainPreDiscovery);
} else {
console.log('No pre-discovered ids found :(');
discover();
}
function discover() {
const index: Record<Region, number> = {
asia:
preDiscovered?.find((id) => idToRegion(id) === 'asia') ?? MIN_IDS.asia,
com: preDiscovered?.find((id) => idToRegion(id) === 'com') ?? MIN_IDS.com,
eu: preDiscovered?.find((id) => idToRegion(id) === 'eu') ?? MIN_IDS.eu,
};
let regionIndex = 0;
let done = 0;
async function chainDiscovery() {
regionIndex = (regionIndex + 1) % REGIONS.length;
const region = REGIONS[regionIndex];
const id = index[region]++;
if (Date.now() - startTime > RUN_TIME || id === MAX_IDS[region]) {
if (++done !== MAX_REQUESTS) return;
console.log(
`Total discovery run time: ${
Date.now() - startTime
}ms\nDiscovered ids: ${discoveredIds.length}`,
);
postWork();
return;
}
if (id > MAX_IDS[region]) return;
const stats = await getTankStats(region, id, { fields });
if (stats !== null && stats.length > 0) {
discoveredIds.push(id);
players.push(stats);
console.log(
`discovered ${id} in ${region} (time left: ${RUN_TIME - (Date.now() - startTime)}ms)`,
);
}
setTimeout(chainDiscovery); // circumvent max call stack
}
console.log(`Spawning ${MAX_REQUESTS} discovery chains...`);
times(MAX_REQUESTS, chainDiscovery);
}
function postWork() {
const tankIds: number[] = [];
const sorted: Record<number, IndividualTankStats[]> = {};
players.forEach((tanks) => {
tanks.forEach((tank) => {
if (tank.all.battles === 0) return;
if (!sorted[tank.tank_id]) {
tankIds.push(tank.tank_id);
sorted[tank.tank_id] = [];
}
sorted[tank.tank_id].push(tank);
});
});
console.log(
`Generating statistics based off ${players.length} players and ${tankIds.length} tanks...`,
);
const averages: AverageDefinitions = {};
tankIds.forEach((id) => {
const tanks = sorted[id];
const samples = tanks.length;
function sum(slice: (tank: AverageDefinitionsAllStats) => number) {
return tanks.reduce((acc, tank) => {
return (
acc + slice({ battle_life_time: tank.battle_life_time, ...tank.all })
);
}, 0);
}
const denominator = sum((tank) => tank.battles);
function moment(slice: (tank: AverageDefinitionsAllStats) => number) {
return tanks.reduce((acc, tank) => {
return (
acc +
tank.all.battles *
slice({ battle_life_time: tank.battle_life_time, ...tank.all })
);
}, 0);
}
function weightedAverage(
slice: (tank: AverageDefinitionsAllStats) => number,
) {
return moment(slice) / denominator;
}
const mu = averageDefinitionsAllStatsKeys.reduce<
Partial<AverageDefinitionsAllStats>
>((acc, key) => {
acc[key] = weightedAverage((tank) => tank[key]);
return acc;
}, {}) as AverageDefinitionsAllStats;
const sigma = averageDefinitionsAllStatsKeys.reduce<
Partial<AverageDefinitionsAllStats>
>((acc, key) => {
acc[key] = Math.sqrt(sum((tank) => (tank[key] - mu[key]) ** 2) / samples);
return acc;
}, {}) as AverageDefinitionsAllStats;
const r = averageDefinitionsAllStatsKeys.reduce<
Partial<AverageDefinitionsAllStats>
>((acc, key) => {
acc[key] =
(samples *
sum(
(tank) => (tank[key] / tank.battles) * (tank.wins / tank.battles),
) -
sum((tank) => tank[key] / tank.battles) *
sum((tank) => tank.wins / tank.battles)) /
Math.sqrt(
(samples * sum((tank) => (tank[key] / tank.battles) ** 2) -
sum((tank) => tank[key] / tank.battles) ** 2) *
(samples * sum((tank) => (tank.wins / tank.battles) ** 2) -
sum((tank) => tank.wins / tank.battles) ** 2),
);
return acc;
}, {}) as AverageDefinitionsAllStats;
const m = averageDefinitionsAllStatsKeys.reduce<
Partial<AverageDefinitionsAllStats>
>((acc, key) => {
acc[key] =
(samples *
sum(
(tank) => (tank[key] / tank.battles) * (tank.wins / tank.battles),
) -
sum((tank) => tank[key] / tank.battles) *
sum((tank) => tank.wins / tank.battles)) /
(samples * sum((tank) => (tank[key] / tank.battles) ** 2) -
sum((tank) => tank[key] / tank.battles) ** 2);
return acc;
}, {}) as AverageDefinitionsAllStats;
const b = averageDefinitionsAllStatsKeys.reduce<
Partial<AverageDefinitionsAllStats>
>((acc, key) => {
acc[key] =
(sum((tank) => tank.wins / tank.battles) -
m[key] * sum((tank) => tank[key] / tank.battles)) /
samples;
return acc;
}, {}) as AverageDefinitionsAllStats;
averages[id] = { samples, mu, sigma, r, m, b };
});
commitAssets(
'averages',
[
{
path: 'definitions/averages.cdon.lz4',
content: superCompress(averages),
encoding: 'base64',
},
{
path: 'definitions/discovered.dids.lz4',
content: Buffer.from(
compress(new DidsWriteStream().dids(discoveredIds).uint8Array),
).toString('base64'),
encoding: 'base64',
},
],
production,
);
}
+60 -6
View File
@@ -3,6 +3,7 @@ import { parse as parsePath } from 'path';
import { Vector3Tuple } from 'three';
import { parse as parseYaml } from 'yaml';
import { TankClass } from '../../src/components/Tanks';
import { WARGAMING_APPLICATION_ID } from '../../src/constants/wargamingApplicationID';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
@@ -34,6 +35,7 @@ import {
TankDefinitionPrice,
TankDefinitions,
Tier,
Unlock,
} from '../../src/core/blitzkit/tankDefinitions';
import { DATA, POI } from './constants';
import { Avatar } from './skillIcons';
@@ -85,7 +87,7 @@ type VehicleDefinitionArmor = Record<
number | { vehicleDamageFactor: 0; '#text': number }
>;
interface UnlocksInner {
cost: number;
cost: number | string;
'#text': number;
}
type Unlocks = {
@@ -365,6 +367,11 @@ export const botPattern = /^.+((tutorial_bot(\d+)?)|(TU(R?)))$/;
export async function definitions(production: boolean) {
console.log('Building definitions...');
const wargamingTankopedia = (await fetch(
`https://api.wotblitz.com/wotb/encyclopedia/vehicles/?application_id=${WARGAMING_APPLICATION_ID}&fields=description`,
).then((response) => response.json())) as {
data: { [key: number]: { description: null | string } };
};
const gameDefinitions: GameDefinitions = {
version: (await readStringDVPL(`${DATA}/version.txt.dvpl`))
.split(' ')[0]
@@ -447,6 +454,46 @@ export async function definitions(production: boolean) {
`${DATA}/${POI.vehicleDefinitions}/${nation}/components/chassis.xml.dvpl`,
);
function resolveUnlocks(unlocks?: Unlocks) {
if (!unlocks) return undefined;
return Object.entries(unlocks)
.map(([type, list]) =>
(Array.isArray(list) ? list : [list]).map((item) => {
const typeTyped = type as keyof Unlocks;
let rawId: number;
if (typeTyped === 'chassis') {
rawId = chassisList.root.ids[item['#text']];
} else if (typeTyped === 'engine') {
rawId = enginesList.root.ids[item['#text']];
} else if (typeTyped === 'gun') {
rawId = gunList.root.ids[item['#text']];
} else if (typeTyped === 'turret') {
rawId = turretList.root.ids[item['#text']];
} else if (typeTyped === 'vehicle') {
rawId = tankList.root[item['#text']].id;
}
return {
type: typeTyped,
id: toUniqueId(nation, rawId!),
cost: {
type:
typeof item.cost === 'number'
? 'xp'
: item.cost.split(':')[0],
value:
typeof item.cost === 'number'
? item.cost
: Number(item.cost.split(':')[1]),
},
} satisfies Unlock;
}),
)
.flat();
}
for (const tankKey in tankList.root) {
if (botPattern.test(tankKey)) continue;
@@ -535,6 +582,8 @@ export async function definitions(production: boolean) {
tankDefinitions[tankId] = {
id: tankId,
description:
wargamingTankopedia.data[tankId]?.description ?? undefined,
fixedCamouflage: fixedCamouflage ? true : undefined,
crew,
weight: tankDefinition.root.hull.weight,
@@ -618,6 +667,7 @@ export async function definitions(production: boolean) {
soft: terrainResistances[2],
},
tier: track.level as Tier,
unlocks: resolveUnlocks(track.unlocks),
});
modelDefinitions[tankId].tracks[trackId] = {
@@ -642,6 +692,7 @@ export async function definitions(production: boolean) {
tier: engineListEntry.level as Tier,
weight: engineListEntry.weight,
power: engineListEntry.power,
unlocks: resolveUnlocks(engine.unlocks),
});
});
@@ -703,6 +754,7 @@ export async function definitions(production: boolean) {
health: turret.maxHealth,
viewRange: turret.circularVisionRadius,
weight: turret.weight,
unlocks: resolveUnlocks(turret.unlocks),
});
modelDefinitions[tankId].turrets[turretId] = {
@@ -821,6 +873,7 @@ export async function definitions(production: boolean) {
shot: shotDispersionFactors.afterShot,
traverse: shotDispersionFactors.turretRotation,
},
unlocks: resolveUnlocks(gun.unlocks),
} as GunDefinition);
modelDefinitions[tankId].turrets[turretId].guns[gunId] = {
@@ -906,7 +959,7 @@ export async function definitions(production: boolean) {
const currentTank = tankDefinitions[tankId];
const successorId = toUniqueId(nation, tankListEntry.id);
tankXps.set(successorId, vehicle.cost);
tankXps.set(successorId, vehicle.cost as number);
if (currentTank.successors === undefined) {
currentTank.successors = [];
@@ -920,7 +973,7 @@ export async function definitions(production: boolean) {
case 'gun': {
gunXps.set(
toUniqueId(nation, gunList.root.ids[vehicle['#text']]),
vehicle.cost,
vehicle.cost as number,
);
break;
}
@@ -928,7 +981,7 @@ export async function definitions(production: boolean) {
case 'turret': {
turretXps.set(
toUniqueId(nation, turretList.root.ids[vehicle['#text']]),
vehicle.cost,
vehicle.cost as number,
);
break;
}
@@ -936,7 +989,7 @@ export async function definitions(production: boolean) {
case 'engine': {
engineXps.set(
toUniqueId(nation, enginesList.root.ids[vehicle['#text']]),
vehicle.cost,
vehicle.cost as number,
);
break;
}
@@ -944,7 +997,7 @@ export async function definitions(production: boolean) {
case 'chassis': {
trackXps.set(
toUniqueId(nation, chassisList.root.ids[vehicle['#text']]),
vehicle.cost,
vehicle.cost as number,
);
break;
}
@@ -994,6 +1047,7 @@ export async function definitions(production: boolean) {
equipmentDefinitions.equipments[optionalDeviceEntry.id] = {
name: strings[optionalDeviceEntry.userString],
description: strings[optionalDeviceEntry.description],
};
},
);
+127
View File
@@ -0,0 +1,127 @@
import { times } from 'lodash';
import { argv } from 'process';
import { Region, REGIONS } from '../src/constants/regions';
import { requestLimitExceededEvent } from '../src/core/blitz/fetchBlitz';
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
import { idToRegion, MIN_IDS } from '../src/core/blitz/idToRegion';
import { asset } from '../src/core/blitzkit/asset';
import { commitAssets } from '../src/core/blitzkit/commitAssets';
import { DidsReadStream, DidsWriteStream } from '../src/core/streams/dids';
const RUN_TIME = 1000 * 60 * 60 * 5.5;
const MAX_REQUESTS = 10;
const ACCOUNTS_PER_CALL = 100;
const TERMINATION_THRESHOLD = 1000;
const production = argv.includes('--production');
const startTime = Date.now();
const indexableRegions = [...REGIONS];
const preDiscoveredRaw = await fetch(
asset('definitions/ids.dids', !production),
).then(async (response) => {
if (response.status === 200) {
const buffer = await response.arrayBuffer();
return new DidsReadStream(buffer).dids();
}
return undefined;
});
console.log(`Running in ${production ? 'production' : 'development'} mode`);
if (preDiscoveredRaw === undefined) {
console.log('No pre-discovered ids found :(');
} else {
console.log(`Found ${preDiscoveredRaw.length} pre-discovered ids`);
}
const ids: number[] = [...(preDiscoveredRaw ?? [])]; // TODO: re-validate these ids
const regionalIdIndex: Record<Region, number> = {
asia: ids.find((id) => idToRegion(id) === 'asia') ?? MIN_IDS.asia,
com: ids.find((id) => idToRegion(id) === 'com') ?? MIN_IDS.com,
eu: ids.find((id) => idToRegion(id) === 'eu') ?? MIN_IDS.eu,
};
const zeroStreak: Record<Region, number> = { asia: 0, com: 0, eu: 0 };
let regionIndex = 0;
let outOfTimeFlagged = false;
async function verify(region: Region, ids: number[]) {
const infos = await getAccountInfo(region, ids, undefined, {
// cheeky way of requesting nothing
fields: 'account_id,-account_id',
});
const filtered = ids.filter((_, index) => {
const info = infos[index];
return info !== null;
});
return filtered;
}
let discardAttempts = 0;
requestLimitExceededEvent.on(() => {
discardAttempts++;
});
const interval = setInterval(async () => {
if (discardAttempts > 0) return discardAttempts--;
const region = indexableRegions[regionIndex];
const idsToVerify = times(
ACCOUNTS_PER_CALL,
(index) => regionalIdIndex[region] + index,
);
const verified = await verify(region, idsToVerify);
console.log(`${region.padEnd(4)} ${verified.length.toString().padStart(3)}`);
if (verified.length === 0) {
zeroStreak[region]++;
if (zeroStreak[region] >= TERMINATION_THRESHOLD) {
const sliceableIndex = indexableRegions.indexOf(region);
if (sliceableIndex !== -1) {
console.log(`Stopped discovery for ${region}`);
indexableRegions.splice(sliceableIndex, 1);
if (indexableRegions.length === 0) {
console.log('All players discovered exhaustively!');
post();
}
}
}
} else {
zeroStreak[region] = 0;
ids.push(...verified);
}
if (Date.now() - startTime > RUN_TIME && !outOfTimeFlagged) {
outOfTimeFlagged = true;
console.log('Out of time');
post();
}
regionIndex = (regionIndex + 1) % indexableRegions.length;
regionalIdIndex[region] += ACCOUNTS_PER_CALL;
}, 1000 / MAX_REQUESTS);
function post() {
clearInterval(interval);
console.log(`Uploading ${ids.length} ids...`);
const didsWriteStream = new DidsWriteStream().dids(ids);
const content = Buffer.from(didsWriteStream.uint8Array).toString('base64');
commitAssets(
'discovered ids',
[
{
content,
encoding: 'base64',
path: 'definitions/ids.dids',
},
],
production,
);
}
+2 -2
View File
@@ -8,14 +8,14 @@ console.log('Building sitemap...');
const awaitedTankDefinitions = await tankDefinitions;
const values = Object.values(awaitedTankDefinitions);
const txt = values
.map((tank) => `https://blitzkit.vercel.app/tools/tankopedia/${tank.id}/`)
.map((tank) => `https://blitzkit.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://blitzkit.vercel.app/tools/tankopedia/${tank.id}/</loc></url>`,
`<url><loc>https://blitzkit.app/tools/tankopedia/${tank.id}/</loc></url>`,
)
.join('\n')}
</urlset>`;
+156
View File
@@ -0,0 +1,156 @@
'use client';
import {
AlertDialog,
Button,
Flex,
Heading,
Link,
Text,
} from '@radix-ui/themes';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { extendAuth } from '../../core/blitz/extendAuth';
import { logout } from '../../core/blitz/logout';
import isDev from '../../core/blitzkit/isDev';
import { isLocalhost } from '../../core/blitzkit/isLocalhost';
import { CURRENT_POLICIES_AGREEMENT_INDEX, useApp } from '../../stores/app';
const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000;
export function Checks() {
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const login = useApp((state) => state.login);
const policiesAgreementIndex = useApp(
(state) => state.policiesAgreementIndex,
);
const pathname = usePathname();
const isLegal = pathname.startsWith('/legal');
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
useEffect(() => {
setShowDevBuildAlert(
isDev() &&
!isLocalhost() &&
Date.now() - useApp.getState().devBuildAgreementTime >=
DEV_BUILD_AGREEMENT_COOLDOWN,
);
}, []);
useEffect(() => {
setShowPoliciesAgreement(
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX && !isLegal,
);
}, [policiesAgreementIndex, isLegal]);
useEffect(() => {
if (!login) return;
const expiresIn = login.expiresAt - Date.now() / 1000;
const expiresInDays = expiresIn / 60 / 60 / 24;
if (expiresInDays < 0) {
logout();
} else if (expiresInDays < 7) {
extendAuth();
}
});
return (
<>
<AlertDialog.Root open={showDevBuildAlert}>
<AlertDialog.Content>
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
<AlertDialog.Description>
This version may have a lot of issues. Report issues to{' '}
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
the official Discord server
</a>
. Also consider using{' '}
<a href="https://blitzkit.app/">the more stable release version</a>.
You will be asked again in 8 days.
</AlertDialog.Description>
<Flex justify="end">
<Button
variant="solid"
onClick={() => {
setShowDevBuildAlert(false);
useApp.setState({ devBuildAgreementTime: Date.now() });
}}
>
Continue
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
{showPoliciesAgreement && (
<Flex
align="end"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'var(--color-overlay)',
zIndex: 2,
}}
>
<Flex
p="8"
style={{
width: '100%',
background: 'var(--color-panel-solid)',
}}
justify="center"
>
<Flex
align="start"
gap="4"
style={{
maxWidth: 640 * 2,
}}
direction="column"
>
<Flex direction="column" gap="2">
<Heading>
{policiesAgreementIndex === -1
? "BlitzKit's policies"
: "BlitzKit's policies have changed"}
</Heading>
<Text>
This website utilizes cookies to perform analytics and
personalize your experience. You can learn more through{' '}
<Link href="/docs/legal/privacy-policy">
our privacy policy
</Link>
. You may opt out of personalized advertizement by visiting{' '}
<Link href="https://www.google.com/settings/ads">
Google Ad Settings
</Link>
. By using BlitzKit, you also agree to our{' '}
<Link href="/docs/legal/terms-of-service">
terms of service
</Link>
.
</Text>
</Flex>
<Button
onClick={() => {
useApp.setState({
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX,
});
}}
>
I Agree
</Button>
</Flex>
</Flex>
</Flex>
)}
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { Flex, Heading, Text } from '@radix-ui/themes';
import { imgur } from '../../core/blitzkit/imgur';
export function Hero() {
return (
<Flex
align="center"
justify="center"
style={{
position: 'relative',
background: `url(${imgur('rUPie9G', { format: 'jpeg' })})`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
}}
px="4"
py="9"
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(var(--color-panel-translucent), var(--color-background))',
}}
/>
<Flex
direction="column"
gap="4"
align="center"
justify="center"
position="relative"
width="100%"
>
<Flex direction="column" align="center">
<Heading size="9" weight="bold" align="center">
BlitzKit
</Heading>
<Text color="gray" align="center" size="4">
Everything World of Tanks Blitz
</Text>
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,38 @@
import { Table } from '@radix-ui/themes';
import Markdown from 'markdown-to-jsx';
import { use } from 'react';
import { Link } from '../../../../components/Link';
import PageWrapper from '../../../../components/PageWrapper';
import isDev from '../../../../core/blitzkit/isDev';
export default function Page({
params,
}: {
params: { document: string; directory: string };
}) {
const markdown = use(
fetch(
`https://raw.githubusercontent.com/tresabhi/blitzkit/${isDev() ? 'dev' : 'main'}/docs/${params.directory}/${params.document}.md`,
).then((response) => response.text()),
);
return (
<PageWrapper>
<Markdown
options={{
overrides: {
a: { component: Link },
table: { component: Table.Root },
thead: { component: Table.Header },
tbody: { component: Table.Body },
tr: { component: Table.Row },
th: { component: Table.ColumnHeaderCell },
td: { component: Table.Cell },
},
}}
>
{markdown}
</Markdown>
</PageWrapper>
);
}
+8
View File
@@ -0,0 +1,8 @@
*,
html {
scroll-behavior: smooth;
}
.radix-themes {
--default-font-family: var(--font-inter);
}
+14 -51
View File
@@ -1,24 +1,20 @@
'use client';
import { AlertDialog, Button, Flex, Theme } from '@radix-ui/themes';
import { Flex, Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css';
import { config } from 'dotenv';
import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation';
import { ReactNode, Suspense, useEffect, useState } from 'react';
import { ReactNode, Suspense } from 'react';
import { Footer } from '../components/Footer';
import { Loader } from '../components/Loader';
import Navbar from '../components/Navbar';
import Navbar, { NAVBAR_HEIGHT } from '../components/Navbar';
import { Party3 } from '../components/Party3';
import isDev from '../core/blitzkit/isDev';
import { isLocalhost } from '../core/blitzkit/isLocalhost';
import { useFullScreen } from '../hooks/useFullScreen';
import { useApp } from '../stores/app';
import { Checks } from './components/Checks';
import './layout.css';
config();
const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000;
interface RootLayoutProps {
children: ReactNode;
}
@@ -26,24 +22,13 @@ interface RootLayoutProps {
const robotoFlex = Roboto_Flex({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-flex',
});
export default function RootLayout({ children }: RootLayoutProps) {
const pathname = usePathname();
const isEmbed = pathname.split('/')[1] === 'embeds';
const isRoot = pathname === '/';
const darkMode = useApp((state) => state.darkMode);
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const isFullScreen = useFullScreen();
useEffect(() => {
setShowDevBuildAlert(
isDev() &&
!isLocalhost() &&
Date.now() - useApp.getState().devBuildAgreementTime >=
DEV_BUILD_AGREEMENT_COOLDOWN,
);
}, []);
return (
<html lang="en" className={robotoFlex.className}>
@@ -70,45 +55,23 @@ export default function RootLayout({ children }: RootLayoutProps) {
}}
>
<Theme
appearance={darkMode ? 'dark' : 'light'}
appearance="dark"
panelBackground="solid"
radius="full"
accentColor="amber"
suppressHydrationWarning
suppressContentEditableWarning
>
<Flex
direction="column"
style={{ minHeight: '100vh', paddingTop: isEmbed ? undefined : 64 }}
style={{
minHeight: '100vh',
paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
}}
>
{!isEmbed && <Navbar />}
<AlertDialog.Root open={showDevBuildAlert}>
<AlertDialog.Content>
<AlertDialog.Title>Experimental version!</AlertDialog.Title>
<AlertDialog.Description>
This version may have a lot of issues. Report issues to{' '}
<a href="https://discord.gg/nDt7AjGJQH" target="_blank">
the official Discord server
</a>
. Also consider using{' '}
<a href="https://blitzkit.vercel.app/">
the more stable release version
</a>
. You will be asked again in 8 days.
</AlertDialog.Description>
<Flex justify="end">
<Button
variant="solid"
onClick={() => {
setShowDevBuildAlert(false);
useApp.setState({ devBuildAgreementTime: Date.now() });
}}
>
Continue
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<Checks />
<Suspense
fallback={
@@ -120,7 +83,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
{children}
</Suspense>
{!isEmbed && !isFullScreen && <Footer />}
{!isEmbed && <Footer />}
</Flex>
</Theme>
</body>
-15
View File
@@ -1,15 +0,0 @@
import { styleVariants } from '@vanilla-extract/css';
import { theme } from '../stitches.config';
export const tool = styleVariants({
enabled: {
boxShadow: `inset 0 -200px 128px -128px ${theme.colors.appBackground1}`,
':hover': {
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
},
},
disabled: {
boxShadow: `inset 0 -192px 128px -128px ${theme.colors.appBackground1}`,
},
});
+136 -78
View File
@@ -1,91 +1,149 @@
'use client';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, Link as LinkRadix } from '@radix-ui/themes';
import { Box, Button, Flex, Grid, Heading, Text } from '@radix-ui/themes';
import Link from 'next/link';
import PageWrapper from '../components/PageWrapper';
import { TOOLS } from '../constants/tools';
import { PatreonIcon } from '../icons/Patreon';
import { theme } from '../stitches.config';
import * as styles from './page.css';
import { ImgurSize, imgur } from '../core/blitzkit/imgur';
import { Hero } from './components/Hero';
export default function Page() {
return (
<PageWrapper>
<Flex
direction="column"
gap="5"
justify="center"
align="center"
style={{ flex: 1 }}
>
<LinkRadix href="https://www.patreon.com/tresabhi" target="_blank">
<Button
color="ruby"
style={{
boxShadow: `0 0 8px ${theme.colors.solidBackground_ruby}f0`,
}}
>
<PatreonIcon style={{ width: '1em', height: '1em' }} /> Consider
donating on Patreon
</Button>
</LinkRadix>
<>
<Hero />
<Flex gap="3" wrap="wrap" align="center" justify="center">
{TOOLS.map((tool) => (
<Link
href={tool.href ?? `/tools/${tool.id}`}
target={tool.href ? '_blank' : undefined}
className={
tool.disabled ? styles.tool.disabled : styles.tool.enabled
}
style={{
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
cursor: tool.disabled ? 'default' : 'pointer',
opacity: tool.disabled ? 0.25 : 1,
height: 128,
minWidth: 256,
flex: 1,
borderRadius: 8,
textDecoration: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: 16,
transition: `box-shadow ${theme.durations.regular}`,
}}
onClick={(event) => {
if (tool.disabled) event.preventDefault();
}}
>
<span
<PageWrapper size={1028}>
<Grid
p="4"
gap="4"
columns={{
initial: undefined,
sm: '2',
}}
flow="row-dense"
>
{TOOLS.map((tool) => {
const size = tool.significant
? ImgurSize.HugeThumbnail
: ImgurSize.LargeThumbnail;
return (
<Flex
key={tool.id}
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
borderRadius: 'var(--radius-2)',
overflow: 'hidden',
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
height: tool.significant ? 'min(320px, 50vh)' : '256px',
}}
gridColumn={{
initial: undefined,
sm: tool.significant ? '1 / 3' : undefined,
}}
>
{tool.title}
<CaretRightIcon style={{ width: '1em', height: '1em' }} />
</span>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{tool.disabled ? 'Coming soon!' : tool.description}
</span>
</Link>
))}
</Flex>
</Flex>
</PageWrapper>
<Link
href={tool.href ?? `/tools/${tool.id}`}
style={{
display: 'flex',
width: '100%',
height: '100%',
flexDirection: 'column',
textDecoration: 'none',
color: 'inherit',
}}
>
<Box
flexGrow="1"
style={{
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Flex
px={{
initial: '6',
md: tool.significant ? '8' : '6',
}}
py={{
initial: '4',
sm: tool.significant ? '6' : '4',
}}
gap="4"
align="center"
justify="between"
width="100%"
direction={{
initial: 'column',
sm: 'row',
}}
style={{
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(64px)',
WebkitBackdropFilter: 'blur(64px)',
}}
>
<Flex
direction="column"
justify="center"
align={{
initial: 'center',
sm: 'start',
}}
>
<Heading
align={{
initial: 'center',
sm: 'left',
}}
size={{
initial: '6',
sm: tool.significant ? '7' : '5',
}}
weight="medium"
>
{tool.title}
</Heading>
<Text
align={{
initial: 'center',
sm: 'left',
}}
size={{
initial: '3',
sm: tool.significant ? '4' : '3',
}}
color="gray"
>
{tool.description}
</Text>
</Flex>
<Button
size={{
initial: undefined,
sm: tool.significant ? '3' : undefined,
}}
color={tool.button.color}
style={{
cursor: 'inherit',
}}
>
{tool.button.text}
<CaretRightIcon />
</Button>
</Flex>
</Link>
</Flex>
);
})}
</Grid>
</PageWrapper>
<div style={{ flex: 1 }} />
</>
);
}
-15
View File
@@ -6,25 +6,10 @@ import { useApp } from '../../stores/app';
export default function Page() {
const developerMode = useApp((state) => state.developerMode);
const darkMode = useApp((state) => state.darkMode);
return (
<PageWrapper justify="center" align="center">
<Flex direction="column" gap="4">
<Flex gap="3" direction="column">
<Heading size="5">Appearance</Heading>
<Flex align="center" gap="2">
<Text>Dark mode (experimental)</Text>
<Switch
checked={darkMode}
onCheckedChange={(checked) =>
useApp.setState({ darkMode: checked })
}
/>
</Flex>
</Flex>
<Flex gap="3" direction="column">
<Heading size="5">Advanced</Heading>
File diff suppressed because it is too large. Load diff
+4 -6
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import { TOOLS } from '../../constants/tools';
import { ImgurSize, imgur } from '../../core/blitzkit/imgur';
import { theme } from '../../stitches.config';
interface ToolsLayoutProps {
@@ -24,10 +25,7 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
{isToolRoot && (
<>
<title>{tool.title}</title>
<meta
property="og:image"
content={`/assets/banners/${toolId}.webp`}
/>
<meta property="og:image" content={imgur(tool.image)} />
{tool.pageDescription !== undefined && (
<>
@@ -59,7 +57,7 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
<div
style={{
position: 'absolute',
backgroundImage: `url(/assets/banners/${toolId}.webp)`,
backgroundImage: `url(${imgur(tool!.image, { format: 'jpeg', size: ImgurSize.HugeThumbnail })})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '100%',
@@ -67,7 +65,7 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
top: 0,
left: 0,
zIndex: -1,
filter: 'blur(4px)',
// filter: 'blur(4px)',
transform: 'scale(calc((128 + 8) / 128))',
}}
/>
@@ -3,7 +3,7 @@ import {
CaretRightIcon,
SymbolIcon,
} from '@radix-ui/react-icons';
import { Button, Flex, TextField } from '@radix-ui/themes';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { range } from 'lodash';
import { useEffect, useRef } from 'react';
import { infiniteSpin } from './index.css';
@@ -42,7 +42,7 @@ export function PageTurner({
return (
<Flex justify="center" wrap="wrap" gap="2">
<Flex gap="2">
<Button
<IconButton
variant="soft"
onClick={() => onPageChange(Math.max(page - 1, 0), true)}
disabled={page === 0 || leftLoading}
@@ -52,7 +52,7 @@ export function PageTurner({
) : (
<CaretLeftIcon />
)}
</Button>
</IconButton>
<TextField.Root
type="number"
@@ -75,7 +75,7 @@ export function PageTurner({
<TextField.Slot>Page</TextField.Slot>
<TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root>
<Button
<IconButton
variant="soft"
onClick={() => {
onPageChange(Math.min(page + 1, pages - 1), true);
@@ -87,7 +87,7 @@ export function PageTurner({
) : (
<CaretRightIcon />
)}
</Button>
</IconButton>
</Flex>
</Flex>
);
+90 -32
View File
@@ -15,6 +15,7 @@ import {
IconButton,
Link,
Select,
Spinner,
Table,
Text,
TextField,
@@ -24,16 +25,16 @@ import { use, useEffect, useMemo, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { UNLOCALIZED_REGION_NAMES_SHORT } from '../../../constants/regions';
import {
generateStats,
prettifyStats,
Stat,
STAT_KEYS,
STAT_NAMES,
Stat,
generateStats,
prettifyStats,
sumStats,
} from '../../../core/blitz/generateStats';
import {
getAccountInfo,
IndividualAccountInfo,
getAccountInfo,
} from '../../../core/blitz/getAccountInfo';
import getTankStats from '../../../core/blitz/getTankStats';
import { idToRegion } from '../../../core/blitz/idToRegion';
@@ -52,7 +53,12 @@ import mutateSession, {
} from '../../../stores/session';
import { IndividualTankStats } from '../../../types/tanksStats';
export default function Page() {
export default function Page({
searchParams,
}: {
searchParams: { id?: string };
}) {
const [loaded, setLoaded] = useState(false);
const wideFormat = useWideFormat(640);
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedTankAvearges = use(tankAverages);
@@ -105,6 +111,7 @@ export default function Page() {
: undefined,
[delta],
);
const [showLinkOverridePrompt, setShowLinkOverridePrompt] = useState(true);
const search = debounce(async () => {
if (!input.current) return;
@@ -113,6 +120,32 @@ export default function Page() {
setSearching(false);
}, 500);
async function track(id: number) {
const region = idToRegion(id);
const player = await getAccountInfo(region, id);
const stats = await getTankStats(region, id);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
setShowSearch(false);
mutateSession((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as SessionTracking).player = {
id,
region,
since: Date.now(),
stats,
};
input.current.value = player.nickname;
});
}
useEffect(() => {
async function update() {
if (!session.tracking) return;
@@ -130,8 +163,24 @@ export default function Page() {
return () => clearInterval(interval);
}, [session.tracking && session.player.id]);
useEffect(() => {
setLoaded(true);
if (searchParams.id && !session.tracking) {
track(Number(searchParams.id));
}
}, []);
if (!loaded) {
return (
<PageWrapper align="center" justify="center">
<Spinner />
</PageWrapper>
);
}
return (
<PageWrapper>
<PageWrapper color="blue">
<AlertDialog.Root
open={showCCInaccessibilityPrompt}
onOpenChange={setShowCCInaccessibilityPrompt}
@@ -154,6 +203,39 @@ export default function Page() {
</AlertDialog.Content>
</AlertDialog.Root>
{searchParams.id &&
session.tracking &&
Number(searchParams.id) !== session.player.id && (
<AlertDialog.Root
open={showLinkOverridePrompt}
onOpenChange={setShowLinkOverridePrompt}
>
<AlertDialog.Content>
<AlertDialog.Title>
You are currently tracking {accountInfo?.nickname}
</AlertDialog.Title>
<AlertDialog.Description>
Would you like to stop tracking {accountInfo?.nickname} and
start tracking a new player?
</AlertDialog.Description>
<Flex mt="4" justify="end">
<AlertDialog.Action>
<Button
variant="solid"
onClick={() => {
setShowLinkOverridePrompt(false);
track(Number(searchParams.id));
}}
>
Proceed
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)}
{!session.tracking && (
<Flex
style={{ flex: 1 }}
@@ -222,32 +304,7 @@ export default function Page() {
<Button
key={player.account_id}
variant="ghost"
onClick={async () => {
const stats = await getTankStats(
player.region,
player.account_id,
);
if (stats === null) {
setShowCCInaccessibilityPrompt(true);
return;
}
setShowSearch(false);
mutateSession((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as SessionTracking).player = {
id: player.account_id,
region: player.region,
since: Date.now(),
stats,
};
input.current.value = player.nickname;
});
}}
onClick={() => track(player.account_id)}
>
{player.nickname} (
{UNLOCALIZED_REGION_NAMES_SHORT[player.region]})
@@ -435,6 +492,7 @@ export default function Page() {
>
<Link href={`/tools/tankopedia/${tank.id}`}>
<img
alt={tank.name}
draggable={false}
src={tankIcon(tank.id)}
style={{
-107
View File
@@ -1,107 +0,0 @@
import { CaretDownIcon, CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, Table, Text } from '@radix-ui/themes';
import { random } from 'lodash';
import { use, useMemo, useState } from 'react';
import { tankDefinitions } from '../../../../core/blitzkit/tankDefinitions';
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
interface DayProps {
offset: number;
}
export function Day({ offset }: DayProps) {
const timeTo = new Date(Date.now() - offset * 24 * 60 * 60 * 1000);
const timeFrom = new Date(Date.now() - (offset + 1) * 24 * 60 * 60 * 1000);
const awaitedTankDefinitions = use(tankDefinitions);
const tanksList = useMemo(
() => Object.values(awaitedTankDefinitions),
[awaitedTankDefinitions],
);
const [expanded, setExpanded] = useState(offset === 0);
const tanks = tanksList
.filter((tank) => tank.tier === 10)
.sort(() => Math.random() - 0.5)
.slice(0, random(2, 5));
return (
<>
<Table.Row>
<Table.RowHeaderCell>
<Button
style={{
width: '100%',
}}
variant="ghost"
onClick={() => setExpanded((state) => !state)}
>
<Flex
align="center"
gap="1"
style={{
width: '100%',
}}
>
{expanded ? <CaretDownIcon /> : <CaretRightIcon />}
{timeTo.toLocaleString('default', {
month: 'short',
day: 'numeric',
})}
</Flex>
</Button>
</Table.RowHeaderCell>
<Table.Cell align="right">{random(1, 10)}</Table.Cell>
<Table.Cell align="right">
{random(60, 80, true).toFixed(0)}%
</Table.Cell>
</Table.Row>
{expanded &&
tanks.map((tank) => (
<Table.Row
key={tank.id}
style={{
overflow: 'hidden',
}}
>
<Table.RowHeaderCell
style={{
paddingLeft: 32,
position: 'relative',
overflowY: 'hidden',
}}
>
<img
draggable={false}
src={tankIcon(tank.id)}
style={{
position: 'absolute',
width: 128 + 32,
height: '200%',
top: '-50%',
left: 0,
boxShadow: 'inset 128px 0 64px -64px #00000040',
objectFit: 'contain',
objectPosition: '50% 50%',
overflow: 'hidden',
}}
/>
<Text
style={{
position: 'relative',
textWrap: 'nowrap',
textShadow: 'black 0 0 4px',
}}
>
{tank.name}
</Text>
</Table.RowHeaderCell>
<Table.Cell align="right">{random(1, 10)}</Table.Cell>
<Table.Cell align="right">
{random(60, 80, true).toFixed(0)}%
</Table.Cell>
</Table.Row>
))}
</>
);
}
-40
View File
@@ -1,40 +0,0 @@
'use client';
import { Flex, Heading, Table } from '@radix-ui/themes';
import { random, times } from 'lodash';
import { HeroStat } from '../../../components/AllStatsOverview/components/HeroStat';
import PageWrapper from '../../../components/PageWrapper';
import { Day } from './components/Day';
export default function Page() {
return (
<PageWrapper color="iris">
<Flex gap="2">
<Heading>TresAbhi</Heading>
<Heading color="gray">[OPTML]</Heading>
</Flex>
<HeroStat color="#8E4EC6" subtitle="Super Unicum" value={3142} />
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>Day</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell width="0" align="right">
Battles
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell width="0" align="right">
Winrate
</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{times(random(5, 10), (index) => (
<Day offset={index} key={index} />
))}
</Table.Body>
</Table.Root>
</PageWrapper>
);
}
@@ -1,251 +0,0 @@
import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
import { useState } from 'react';
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
import { SmallTankIcon } from '../../../../../components/SmallTankIcon';
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
import { pushTankopediaPath } from '../../../../../core/blitzkit/pushTankopediaPath';
import {
SHELL_NAMES,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkit/tankDefinitions/constants';
import { mutateDuel, useDuel } from '../../../../../stores/duel';
import {
mutateTankopediaTemporary,
useTankopediaPersistent,
} from '../../../../../stores/tankopedia';
import { TankSearch } from '../../components/TankSearch';
interface AntagonistBarProps {
floating?: boolean;
}
export function AntagonistBar({ floating }: AntagonistBarProps) {
const antagonist = useDuel((state) => state.antagonist!);
const mode = useTankopediaPersistent((state) => state.mode);
const [tab, setTab] = useState('search');
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
useState(false);
if (mode !== 'armor') return null;
return (
<Flex justify="center">
<Card
style={
floating
? {
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
}
: undefined
}
>
<Flex
align="center"
justify="center"
wrap="wrap"
style={{
gap: 'min(5vw, 32px)',
}}
>
<Flex>
{antagonist.gun.shells.map((shell, index) => {
return (
<ShellButton
selected={antagonist.shell.id === shell.id}
shell={shell.icon}
rowChild
first={index === 0}
key={shell.id}
last={index === antagonist.gun.shells.length - 1}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.shell = shell;
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
});
}}
/>
);
})}
</Flex>
<Button
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
<Flex align="center" gap="4">
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorVisible}
>
<Dialog.Trigger>
<Button variant="ghost">
<Flex gap="2" align="center">
Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} />
</Flex>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Tabs.Root
value={tab}
onValueChange={setTab}
style={{ position: 'relative' }}
>
<Dialog.Close>
<Button
variant="ghost"
style={{ position: 'absolute', right: 0, top: 8 }}
>
<Cross1Icon />
</Button>
</Dialog.Close>
<Flex gap="4" direction="column">
<Tabs.List>
<Tabs.Trigger value="search">Search</Tabs.Trigger>
<Tabs.Trigger value="configure">Configure</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="search">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
mutateDuel((draft) => {
draft.antagonist!.tank = tank;
draft.antagonist!.engine = tank.engines.at(-1)!;
draft.antagonist!.track = tank.tracks.at(-1)!;
draft.antagonist!.turret = tank.turrets.at(-1)!;
draft.antagonist!.gun =
draft.antagonist!.turret.guns.at(-1)!;
draft.antagonist!.shell =
draft.antagonist!.gun.shells[0];
});
setAntagonistSelectorVisible(false);
}}
/>
</Flex>
</Tabs.Content>
<Tabs.Content value="configure">
<Flex direction="column" gap="4">
<Flex direction="column" gap="2" style={{ flex: 1 }}>
<Heading size="4">
{antagonist.tank.name} modules
</Heading>
<Flex gap="2" wrap="wrap">
<Flex>
{antagonist.tank.turrets.map((turret, index) => (
<ModuleButton
rowChild
first={index === 0}
last={
index === antagonist.tank.turrets.length - 1
}
key={turret.id}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.turret = turret;
draft.antagonist!.gun =
turret.guns.at(-1)!;
draft.antagonist!.shell =
draft.antagonist!.gun.shells[0];
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
selected={antagonist.turret.id === turret.id}
discriminator={
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
))}
</Flex>
<Flex>
{antagonist.turret.guns.map((gun, index) => (
<ModuleButton
key={gun.id}
rowChild
first={index === 0}
last={
index === antagonist.turret.guns.length - 1
}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.gun = gun;
draft.antagonist!.shell = gun.shells[0];
});
}}
selected={antagonist.gun.id === gun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
module="gun"
/>
))}
</Flex>
</Flex>
</Flex>
<Flex direction="column" style={{ flex: 1 }}>
<Heading size="4">Properties</Heading>
<ul>
<li>
Turret: <b>{antagonist.turret.name}</b>
</li>
<li>
Gun: <b>{antagonist.gun.name}</b>
</li>
<li>Shells:</li>
<ul>
{antagonist.gun.shells.map((shell) => (
<li key={shell.id}>
{SHELL_NAMES[shell.type]}:{' '}
<b>
{resolveNearPenetration(shell.penetration)}
mm
</b>
, {shell.damage.armor}HP
</li>
))}
</ul>
</ul>
</Flex>
</Flex>
</Tabs.Content>
</Flex>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
</Flex>
</Flex>
</Card>
</Flex>
);
}
@@ -32,7 +32,7 @@ export function Info({
style={{ width: '100%', paddingLeft: indent ? 24 : 0 }}
gap="4"
>
<Text color={highlight ? 'amber' : undefined}>
<Text color={highlight ? 'amber' : 'gray'}>
{name}
{unit != undefined && (
<>
@@ -1,16 +1,311 @@
import { Button, Flex, Heading } from '@radix-ui/themes';
import { ModuleManager } from '../../../../../../../components/ModuleManager';
import {
Button,
ChevronDownIcon,
Flex,
Heading,
IconButton,
Link,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
import {
ModuleDefinition,
ModuleType,
TankDefinition,
tankDefinitions,
Tier,
Unlock,
} from '../../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../../../core/blitzkit/tankIcon';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
function ModuleButton({
tier,
selected,
unlock,
top,
onClick,
}: {
unlock: Unlock;
tier: Tier;
selected: boolean;
top: boolean;
onClick: () => void;
}) {
const numberFormat = Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 0,
});
const isTank = unlock.type === 'vehicle';
const button = (
<IconButton
size="4"
radius="small"
variant={selected ? 'surface' : 'soft'}
color={top ? 'amber' : selected ? undefined : 'gray'}
onClick={onClick}
style={{
position: 'relative',
width: isTank ? 72 : undefined,
height: isTank ? 72 : undefined,
}}
>
<Text
size="1"
color="gray"
style={{
position: 'absolute',
left: isTank ? 8 : 4,
top: isTank ? 8 : 4,
}}
>
{TIER_ROMAN_NUMERALS[tier]}
</Text>
{unlock.cost.value > 0 && (
<Flex
style={{
position: 'absolute',
left: '50%',
bottom: 4,
transform: 'translateX(-50%)',
}}
align="center"
gap={isTank ? '1' : undefined}
>
<Text size="1" color="gray">
{numberFormat.format(unlock.cost.value)}
</Text>
<img
alt={unlock.cost.type}
src={asset(`icons/currencies/${unlock.cost.type}.webp`)}
style={{
width: 12,
height: 12,
objectFit: 'contain',
}}
/>
</Flex>
)}
<img
alt={unlock.type}
src={
isTank
? tankIcon(unlock.id)
: asset(`icons/modules/${unlock.type}.webp`)
}
style={{
width: isTank ? 64 : 32,
height: isTank ? 64 : 32,
objectFit: 'contain',
}}
/>
</IconButton>
);
return isTank ? (
<Link href={`/tools/tankopedia/${unlock.id}`}>{button}</Link>
) : (
button
);
}
export function Modules() {
const protagonist = useDuel((state) => state.protagonist!);
const awaitedTankDefinitions = use(tankDefinitions);
const tank = useDuel((state) => state.protagonist!.tank);
const hasUpgrades =
protagonist.tank.turrets.length > 1 ||
protagonist.tank.turrets[0].guns.length > 1 ||
protagonist.tank.engines.length > 1 ||
protagonist.tank.tracks.length > 1;
tank.turrets.length > 1 ||
tank.turrets[0].guns.length > 1 ||
tank.engines.length > 1 ||
tank.tracks.length > 1;
const turret = useDuel((state) => state.protagonist!.turret);
const gun = useDuel((state) => state.protagonist!.gun);
const engine = useDuel((state) => state.protagonist!.engine);
const track = useDuel((state) => state.protagonist!.track);
const turret0 = tank.turrets[0];
const gun0 = turret0.guns[0];
const engine0 = tank.engines[0];
const track0 = tank.tracks[0];
const topTurret = tank.turrets.at(-1)!;
const topGun = topTurret.guns.at(-1)!;
const topEngine = tank.engines.at(-1)!;
const topTrack = tank.tracks.at(-1)!;
function setByUnlock(unlock: Unlock) {
mutateDuel((draft) => {
if (unlock.type === 'turret') {
draft.protagonist!.turret = draft.protagonist!.tank.turrets.find(
(turret) => turret.id === unlock.id,
)!;
if (
!draft.protagonist!.turret.guns.some(
(gun) => gun.id === draft.protagonist!.gun.id,
)
) {
draft.protagonist!.gun = draft.protagonist!.turret.guns.at(-1)!;
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
}
} else if (unlock.type === 'gun') {
const gunInTurret = draft.protagonist!.turret.guns.find(
(gun) => gun.id === unlock.id,
);
if (gunInTurret) {
draft.protagonist!.gun = gunInTurret;
draft.protagonist!.shell = gunInTurret.shells[0];
} else {
// TODO: warn somehow?
const suitableTurret = draft.protagonist!.tank.turrets.find(
(turret) => turret.guns.some((gun) => gun.id === unlock.id),
)!;
const gunInSuitableTurret = suitableTurret.guns.find(
(gun) => gun.id === unlock.id,
)!;
draft.protagonist!.turret = suitableTurret;
draft.protagonist!.gun = gunInSuitableTurret;
draft.protagonist!.shell = gunInSuitableTurret.shells[0];
}
} else if (unlock.type === 'engine') {
draft.protagonist!.engine = draft.protagonist!.tank.engines.find(
(engine) => engine.id === unlock.id,
)!;
} else if (unlock.type === 'chassis') {
draft.protagonist!.track = draft.protagonist!.tank.tracks.find(
(track) => track.id === unlock.id,
)!;
}
});
}
function tree(type: ModuleType, unlocks: Unlock[]) {
return (
<Flex gap="2">
{unlocks.map((unlock, index) => {
const first = index === 0;
const last = index === unlocks.length - 1;
const central = !first && !last;
let module: ModuleDefinition | TankDefinition | undefined = undefined;
if (unlock.type === 'chassis') {
module = tank.tracks.find((track) => track.id === unlock.id)!;
} else if (unlock.type === 'engine') {
module = tank.engines.find((engine) => engine.id === unlock.id)!;
} else if (unlock.type === 'turret') {
module = tank.turrets.find((turret) => turret.id === unlock.id)!;
} else if (unlock.type === 'gun') {
module = tank.turrets
.find((turret) =>
turret.guns.some((gun) => gun.id === unlock.id),
)!
.guns.find((gun) => gun.id === unlock.id)!;
} else if (unlock.type === 'vehicle') {
module = awaitedTankDefinitions[unlock.id];
}
if (module === undefined) return null;
return (
<Flex
align="center"
direction="column"
gap="2"
style={{
position: 'relative',
}}
>
{unlock.cost.value > 0 && (
<>
{unlocks.length > 1 && (
<div
style={{
backgroundColor: 'currentcolor',
width: `calc(${central ? 100 : 50}% + ${central ? 8 : 4}px)`,
height: 1,
position: 'absolute',
right: first ? 0 : undefined,
left: last ? 0 : undefined,
transform: `translateX(${first ? 4 : last ? -4 : 0}px)`,
}}
/>
)}
<Flex
direction="column"
align="center"
style={{
marginBottom: -7,
}}
>
<div
style={{
backgroundColor: 'currentcolor',
width: 1,
height: 8,
}}
/>
<ChevronDownIcon
style={{
transform: 'translateY(-7px)',
}}
/>
</Flex>
</>
)}
<ModuleButton
unlock={unlock}
tier={module.tier}
selected={
(unlock.type === 'turret'
? turret.id
: unlock.type === 'gun'
? gun.id
: unlock.type === 'engine'
? engine.id
: unlock.type === 'chassis'
? track.id
: -1) === unlock.id
}
top={
unlock.type === 'turret'
? module.id == topTurret.id
: unlock.type === 'gun'
? module.id === topGun.id
: unlock.type === 'engine'
? module.id === topEngine.id
: unlock.type === 'chassis'
? module.id === topTrack.id
: false
}
onClick={() => setByUnlock(unlock)}
/>
{(module as ModuleDefinition).unlocks &&
(module as ModuleDefinition).unlocks!.length > 1 && (
<div
style={{
backgroundColor: 'currentcolor',
width: 1,
height: 8,
marginBottom: -8,
}}
/>
)}
{(module as ModuleDefinition).unlocks &&
tree(type, (module as ModuleDefinition).unlocks!)}
</Flex>
);
})}
</Flex>
);
}
return (
<ConfigurationChildWrapper>
@@ -43,8 +338,7 @@ export function Modules() {
draft.protagonist!.tank.turrets.at(-1)!;
draft.protagonist!.gun =
draft.protagonist!.turret.guns.at(-1)!;
draft.protagonist!.shell =
draft.protagonist!.gun.shells.at(-1)!;
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
draft.protagonist!.engine =
draft.protagonist!.tank.engines.at(-1)!;
draft.protagonist!.track =
@@ -58,22 +352,36 @@ export function Modules() {
)}
</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;
});
}}
/>
<Flex gap="2">
{tree('engine', [
{
cost: { type: 'xp', value: 0 },
id: engine0.id,
type: 'engine',
},
])}
{tree('chassis', [
{
cost: { type: 'xp', value: 0 },
id: track0.id,
type: 'chassis',
},
])}
{tree('turret', [
{
cost: { type: 'xp', value: 0 },
id: turret0.id,
type: 'turret',
},
])}
{tree('gun', [
{
cost: { type: 'xp', value: 0 },
id: gun0.id,
type: 'gun',
},
])}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -11,9 +11,11 @@ import {
import { debounce } from 'lodash';
import { Fragment, use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils';
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
import { Link } from '../../../../../../components/Link';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { asset } from '../../../../../../core/blitzkit/asset';
import { coefficient } from '../../../../../../core/blitzkit/coefficient';
import { equipmentDefinitions } from '../../../../../../core/blitzkit/equipmentDefinitions';
import { modelDefinitions } from '../../../../../../core/blitzkit/modelDefinitions';
@@ -22,22 +24,21 @@ import { tankCharacteristics } from '../../../../../../core/blitzkit/tankCharact
import {
CREW_MEMBER_NAMES,
GUN_TYPE_NAMES,
SHELL_NAMES,
} from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta';
export function Characteristics() {
const wideFormat = useWideFormat(960);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedModelDefinitions = use(modelDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
const crewSkills = useTankopediaTemporary((state) => state.skills);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const isFullScreen = useFullScreen();
const hasImprovedVentilation = useEquipment(102);
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
const [penetrationDistance, setPenetrationDistance] = useState(250);
@@ -50,7 +51,7 @@ export function Characteristics() {
? total + awaitedProvisionDefinitions[provision].crew! / 100
: total,
0,
) + (hasImprovedVentilation ? 0.05 : 0);
) + (hasImprovedVentilation ? 0.08 : 0);
const commanderMastery = crewMastery + provisionCrewBonus;
const crewMemberMastery = commanderMastery * 1.1;
const consumables = useDuel((state) => state.protagonist!.consumables);
@@ -86,7 +87,7 @@ export function Characteristics() {
stockGun,
stockTrack,
stockTurret,
applyReactive: false,
applyReactiveArmor: false,
applyDynamicArmor: false,
applySpallLiner: false,
},
@@ -102,11 +103,11 @@ export function Characteristics() {
const hasImprovedVerticalStabilizer = useEquipment(122);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
isExplosive(shell.type) ? 0.1 : 0.05,
resolvePenetrationCoefficient(true, shell.type) - 1,
]);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.5,
-0.6,
]);
useEffect(() => {
@@ -126,439 +127,449 @@ export function Characteristics() {
});
}, [hasImprovedVerticalStabilizer]);
if (isFullScreen) return null;
return (
<Flex direction="column" gap="8" style={{ width: '100%' }}>
<Flex direction="column" gap="2">
<Flex align="center" gap="4">
<Heading size="5">Fire</Heading>
<Flex direction={wideFormat ? 'row' : 'column'} gap="8" wrap="wrap">
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Flex align="center" gap="4">
<Heading size="5">Fire</Heading>
<Flex>
{gun.shells.map((thisShell, index) => {
return (
<ShellButton
<Flex>
{gun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === shell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
shell={thisShell.icon}
discriminator={SHELL_NAMES[thisShell.type]}
selected={thisShell.id === shell.id}
first={index === 0}
last={index === gun.shells.length - 1}
rowChild
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderBottomLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
borderBottomRightRadius:
shellIndex === gun.shells.length - 1 ? undefined : 0,
marginLeft: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.shell = thisShell;
});
}}
/>
);
})}
</Flex>
</Flex>
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
{stats.dpm}
</InfoWithDelta>
{gun.type === 'autoReloader' && (
<>
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
{stats.dpmMaximum!}
</InfoWithDelta>
<InfoWithDelta
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
<Info
indent
name={
<a
target="_blank"
href="https://tresabhi.github.io/blitzkit/guide/dpm.html"
>
What's the difference?
</a>
}
/>
</>
)}
{gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)}
{gun.type === 'autoReloader' ? (
<>
<Info indent name="Most optimal">
{stats.mostOptimalShellIndex}
</Info>
<Info name="Shell reloads" unit="s" />
{stats.shellReloads!.map((reload, index) => (
<InfoWithDelta
key={index}
indent
name={`Shell ${index + 1}`}
decimals={2}
deltaType="lowerIsBetter"
>
{reload}
<img
alt={thisShell.name}
width={16}
height={16}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
/>
</IconButton>
))}
</Flex>
</Flex>
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
<InfoWithDelta name="DPM" decimals={0} unit="hp / min">
{stats.dpm}
</InfoWithDelta>
{gun.type === 'autoReloader' && (
<>
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
{stats.dpmMaximum!}
</InfoWithDelta>
))}
</>
) : (
<InfoWithDelta
decimals={2}
name="Reload"
unit="s"
deltaType="lowerIsBetter"
>
{stats.shellReload!}
</InfoWithDelta>
)}
{(gun.type === 'autoLoader' || gun.type === 'autoReloader') && (
<InfoWithDelta
indent
decimals={2}
name="Intra-clip"
unit="s"
deltaType="lowerIsBetter"
>
{stats.intraClip!}
</InfoWithDelta>
)}
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
{stats.penetration}
</InfoWithDelta>
{typeof shell.penetration !== 'number' && (
<>
<InfoWithDelta
decimals={0}
indent
name="Effective at 60s"
unit="hp / min"
>
{stats.dpmEffective!}
</InfoWithDelta>
<Info
indent
name={
<Link target="_blank" href="/docs/guide/dpm">
What's the difference?
</Link>
}
/>
</>
)}
{gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)}
{gun.type === 'autoReloader' ? (
<>
<Info indent name="Most optimal">
{stats.mostOptimalShellIndex}
</Info>
<Info name="Shell reloads" unit="s" />
{stats.shellReloads!.map((reload, index) => (
<InfoWithDelta
key={index}
indent
name={`Shell ${index + 1}`}
decimals={2}
deltaType="lowerIsBetter"
>
{reload}
</InfoWithDelta>
))}
</>
) : (
<InfoWithDelta
decimals={2}
name="Reload"
unit="s"
deltaType="lowerIsBetter"
>
{stats.shellReload!}
</InfoWithDelta>
)}
{(gun.type === 'autoLoader' || gun.type === 'autoReloader') && (
<InfoWithDelta
indent
decimals={0}
name={`At ${penetrationDistance}m`}
decimals={2}
name="Intra-clip"
unit="s"
deltaType="lowerIsBetter"
>
{lerp(
shell.penetration[0],
shell.penetration[1],
(penetrationDistance * penetrationLossOverDistanceCoefficient) /
500,
) * penetrationCoefficient}
{stats.intraClip!}
</InfoWithDelta>
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
<Text>Distance</Text>
<Slider
key={penetrationDistance}
min={0}
max={500}
style={{ flex: 1 }}
defaultValue={[penetrationDistance]}
onValueChange={debounce(([value]) => {
setPenetrationDistance(value);
}, 500)}
/>
<TextField.Root
style={{ width: 64 }}
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
>
<TextField.Slot side="right">m</TextField.Slot>
</TextField.Root>
</Flex>
</>
)}
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
{stats.damage}
</InfoWithDelta>
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{stats.moduleDamage}
</InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{stats.explosionRadius!}
)}
<InfoWithDelta decimals={0} name="Penetration" unit="mm">
{stats.penetration}
</InfoWithDelta>
)}
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
{stats.caliber}
</InfoWithDelta>
<InfoWithDelta name="Normalization" decimals={0} unit="°">
{stats.shellNormalization}
</InfoWithDelta>
{shell.type !== 'he' && (
<InfoWithDelta
name="Ricochet"
decimals={0}
deltaType="lowerIsBetter"
unit="°"
>
{stats.shellRicochet!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{stats.shellVelocity}
</InfoWithDelta>
<InfoWithDelta
decimals={2}
name="Aim time"
unit="s"
deltaType="lowerIsBetter"
>
{stats.aimTime}
</InfoWithDelta>
<Info name="Dispersion at 100m" />
<InfoWithDelta
decimals={3}
indent
name="Still"
unit="m"
deltaType="lowerIsBetter"
>
{stats.dispersion}
</InfoWithDelta>
<InfoWithDelta
prefix="+ "
decimals={3}
indent
name="Moving"
deltaType="lowerIsBetter"
>
{stats.dispersionMoving}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
indent
name="Hull traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionHullTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
indent
name="Turret traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionTurretTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
indent
name="After shooting"
deltaType="lowerIsBetter"
>
{stats.dispersionShooting}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
prefix="x "
indent
name="Gun damaged"
unit="scalar"
deltaType="lowerIsBetter"
>
{stats.dispersionGunDamaged}
</InfoWithDelta>
<Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression">
{stats.gunDepression}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation">
{stats.gunElevation}
</InfoWithDelta>
{gunModelDefinition.pitch.front && (
<>
<InfoWithDelta decimals={1} indent name="Frontal depression">
{stats.gunFrontalDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Frontal elevation">
{stats.gunFrontalElevation!}
</InfoWithDelta>
</>
)}
{gunModelDefinition.pitch.back && (
<>
<InfoWithDelta decimals={1} indent name="Rear depression">
{stats.gunRearDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Rear elevation">
{stats.gunRearElevation!}
</InfoWithDelta>
</>
)}
{turretModelDefinition.yaw && (
<>
<InfoWithDelta decimals={1} indent name="Azimuth left">
{stats.azimuthLeft!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Azimuth right">
{stats.azimuthRight!}
</InfoWithDelta>
</>
)}
</Flex>
<Flex direction="column" gap="2">
<Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="km/hr" />
<InfoWithDelta decimals={0} indent name="Forwards">
{stats.speedForwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} indent name="Backwards">
{stats.speedBackwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} name="Engine power" unit="hp">
{stats.enginePower}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
name="Weight"
unit="tn"
deltaType="lowerIsBetter"
>
{stats.weight}
</InfoWithDelta>
<Info name="Power to weight ratio" unit="hp/tn" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.powerToWeightRatioHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.powerToWeightRatioMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.powerToWeightRatioSoftTerrain}
</InfoWithDelta>
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
{stats.turretTraverseSpeed}
</InfoWithDelta>
<Info name="Hull traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.hullTraverseHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.hullTraverseMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.hullTraverseSoftTerrain}
</InfoWithDelta>
</Flex>
<Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading>
<InfoWithDelta name="Health" unit="hp" decimals={0}>
{stats.health}
</InfoWithDelta>
<InfoWithDelta
name="Fire chance"
unit="%"
deltaType="lowerIsBetter"
decimals={0}
>
{stats.fireChance * 100}
</InfoWithDelta>
<InfoWithDelta name="View range" unit="m" decimals={0}>
{stats.viewRange}
</InfoWithDelta>
<Info name="Camouflage" unit="%" />
<InfoWithDelta indent name="Still" decimals={0}>
{stats.camouflageStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Moving" decimals={0}>
{stats.camouflageMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting still" decimals={0}>
{stats.camouflageShootingStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting moving" decimals={0}>
{stats.camouflageShootingMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Caught on fire" decimals={0}>
{stats.camouflageCaughtOnFire * 100}
</InfoWithDelta>
<Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.width}
</Info>
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.height}
</Info>
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.length}
</Info>
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.volume}
</Info>
</Flex>
<Flex direction="column" gap="2">
<Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>represents the other roles a crew member can take.</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => {
const count = member.count ?? 1;
return (
<Fragment key={member.type}>
{typeof shell.penetration !== 'number' && (
<>
<InfoWithDelta
key={`${member.type}-root`}
name={`${CREW_MEMBER_NAMES[member.type]}${count > 1 ? ` x ${count}` : ''}`}
unit="%"
indent
decimals={0}
name={`At ${penetrationDistance}m`}
>
{(member.type === 'commander'
? commanderMastery
: crewMemberMastery) * 100}
{lerp(
shell.penetration[0],
shell.penetration[1],
(penetrationDistance *
penetrationLossOverDistanceCoefficient) /
500,
) * penetrationCoefficient}
</InfoWithDelta>
{member.substitute && (
<Info
key={`${member.type}-substitute`}
indent
name={
<>
<Flex align="center" gap="1">
<AccessibilityIcon />
{member.substitute
.map((sub, index) =>
index === 0 ? CREW_MEMBER_NAMES[sub] : sub,
)
.join(', ')}
</Flex>
</>
}
<Flex align="center" gap="2" style={{ paddingLeft: 24 }}>
<Text color="gray">Distance</Text>
<Slider
key={penetrationDistance}
min={0}
max={500}
style={{ flex: 1 }}
defaultValue={[penetrationDistance]}
onValueChange={debounce(([value]) => {
setPenetrationDistance(value);
}, 500)}
/>
)}
</Fragment>
);
})}
<TextField.Root
style={{ width: 64 }}
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
>
<TextField.Slot side="right">m</TextField.Slot>
</TextField.Root>
</Flex>
</>
)}
<InfoWithDelta name="Damage" unit="hp" decimals={0}>
{stats.damage}
</InfoWithDelta>
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{stats.moduleDamage}
</InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{stats.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
{stats.caliber}
</InfoWithDelta>
<InfoWithDelta name="Normalization" decimals={0} unit="°">
{stats.shellNormalization}
</InfoWithDelta>
{!isExplosive(shell.type) && (
<InfoWithDelta
name="Ricochet"
decimals={0}
deltaType="lowerIsBetter"
unit="°"
>
{stats.shellRicochet!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{stats.shellVelocity}
</InfoWithDelta>
<InfoWithDelta
decimals={2}
name="Aim time"
unit="s"
deltaType="lowerIsBetter"
>
{stats.aimTime}
</InfoWithDelta>
<Info name="Dispersion at 100m" />
<InfoWithDelta
decimals={3}
indent
name="Still"
unit="m"
deltaType="lowerIsBetter"
>
{stats.dispersion}
</InfoWithDelta>
<InfoWithDelta
prefix="+ "
decimals={3}
indent
name="Moving"
deltaType="lowerIsBetter"
>
{stats.dispersionMoving}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
indent
name="Hull traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionHullTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
indent
name="Turret traversing"
deltaType="lowerIsBetter"
>
{stats.dispersionTurretTraversing}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
prefix="+ "
indent
name="After shooting"
deltaType="lowerIsBetter"
>
{stats.dispersionShooting}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
prefix="x "
indent
name="Gun damaged"
unit="scalar"
deltaType="lowerIsBetter"
>
{stats.dispersionGunDamaged}
</InfoWithDelta>
<Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression">
{stats.gunDepression}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation">
{stats.gunElevation}
</InfoWithDelta>
{gunModelDefinition.pitch.front && (
<>
<InfoWithDelta decimals={1} indent name="Frontal depression">
{stats.gunFrontalDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Frontal elevation">
{stats.gunFrontalElevation!}
</InfoWithDelta>
</>
)}
{gunModelDefinition.pitch.back && (
<>
<InfoWithDelta decimals={1} indent name="Rear depression">
{stats.gunRearDepression!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Rear elevation">
{stats.gunRearElevation!}
</InfoWithDelta>
</>
)}
{turretModelDefinition.yaw && (
<>
<InfoWithDelta decimals={1} indent name="Azimuth left">
{stats.azimuthLeft!}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Azimuth right">
{stats.azimuthRight!}
</InfoWithDelta>
</>
)}
</Flex>
<Flex direction="column" gap="2">
<Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>
represents the other roles a crew member can take.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => {
const count = member.count ?? 1;
return (
<Fragment key={member.type}>
<InfoWithDelta
key={`${member.type}-root`}
name={`${CREW_MEMBER_NAMES[member.type]}${count > 1 ? ` x ${count}` : ''}`}
unit="%"
decimals={0}
>
{(member.type === 'commander'
? commanderMastery
: crewMemberMastery) * 100}
</InfoWithDelta>
{member.substitute && (
<Info
key={`${member.type}-substitute`}
indent
name={
<>
<Flex align="center" gap="1">
<AccessibilityIcon />
{member.substitute
.map((sub, index) =>
index === 0 ? CREW_MEMBER_NAMES[sub] : sub,
)
.join(', ')}
</Flex>
</>
}
/>
)}
</Fragment>
);
})}
</Flex>
</Flex>
<Flex direction="column" gap="8" style={{ flex: 1 }}>
<Flex direction="column" gap="2">
<Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="km/hr" />
<InfoWithDelta decimals={0} indent name="Forwards">
{stats.speedForwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} indent name="Backwards">
{stats.speedBackwards}
</InfoWithDelta>
<InfoWithDelta decimals={0} name="Engine power" unit="hp">
{stats.enginePower}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
name="Weight"
unit="tn"
deltaType="lowerIsBetter"
>
{stats.weight}
</InfoWithDelta>
<Info name="Power to weight ratio" unit="hp/tn" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.powerToWeightRatioHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.powerToWeightRatioMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.powerToWeightRatioSoftTerrain}
</InfoWithDelta>
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
{stats.turretTraverseSpeed}
</InfoWithDelta>
<Info name="Hull traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.hullTraverseHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.hullTraverseMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.hullTraverseSoftTerrain}
</InfoWithDelta>
</Flex>
<Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading>
<InfoWithDelta name="Health" unit="hp" decimals={0}>
{stats.health}
</InfoWithDelta>
<InfoWithDelta
name="Fire chance"
unit="%"
deltaType="lowerIsBetter"
decimals={0}
>
{stats.fireChance * 100}
</InfoWithDelta>
<InfoWithDelta name="View range" unit="m" decimals={0}>
{stats.viewRange}
</InfoWithDelta>
<Info name="Camouflage" unit="%" />
<InfoWithDelta indent name="Still" decimals={0}>
{stats.camouflageStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Moving" decimals={0}>
{stats.camouflageMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting still" decimals={0}>
{stats.camouflageShootingStill * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Shooting moving" decimals={0}>
{stats.camouflageShootingMoving * 100}
</InfoWithDelta>
<InfoWithDelta indent name="Caught on fire" decimals={0}>
{stats.camouflageCaughtOnFire * 100}
</InfoWithDelta>
<Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.width}
</Info>
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.height}
</Info>
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.length}
</Info>
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.volume}
</Info>
</Flex>
</Flex>
</Flex>
);
@@ -1,6 +1,6 @@
import { OrbitControls } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Vector3 } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
@@ -19,6 +19,7 @@ const poseDistances: Record<Pose, number> = {
export function Controls() {
const camera = useThree((state) => state.camera);
const canvas = useThree((state) => state.gl.domElement);
const orbitControls = useRef<OrbitControlsClass>(null);
const awaitedModelDefinitions = useAwait(modelDefinitions);
const protagonist = useDuel((state) => state.protagonist!);
@@ -53,7 +54,7 @@ export function Controls() {
protagonistTrackModelDefinition.origin[1] +
antagonistModelDefinition.turretOrigin[1] +
antagonistTurretModelDefinition.gunOrigin[1];
// const hasSetInitialPose = useRef(false);
const [autoRotate, setAutoRotate] = useState(true);
useEffect(() => {
camera.position.set(-4, 4, -16);
@@ -162,6 +163,20 @@ export function Controls() {
};
}, [camera, protagonist.tank.id, antagonist.tank.id]);
useEffect(() => {
function handleDisturbance() {
setAutoRotate(false);
}
poseEvent.on(handleDisturbance);
canvas.addEventListener('pointerdown', handleDisturbance);
return () => {
canvas.removeEventListener('pointerdown', handleDisturbance);
poseEvent.off(handleDisturbance);
};
}, []);
return (
<OrbitControls
maxDistance={20}
@@ -170,6 +185,8 @@ export function Controls() {
enabled={useTankopediaPersistent.getState().model.visual.controlsEnabled}
rotateSpeed={0.25}
enableDamping={false}
autoRotate={autoRotate}
autoRotateSpeed={1 / 4}
/>
);
}
@@ -0,0 +1,54 @@
import { Flex, Heading } from '@radix-ui/themes';
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { Characteristics } from '../Characteristics';
import { Consumables } from '../Characteristics/components/Consumables';
import { Equipment } from '../Characteristics/components/Equipment';
import { Miscellaneous } from '../Characteristics/components/Miscellaneous';
import { Modules } from '../Characteristics/components/Modules';
import { Provisions } from '../Characteristics/components/Provisions';
import { Skills } from '../Characteristics/components/Skills';
export function CharacteristicsSection() {
const wideFormat = useWideFormat(880);
return (
<Flex
align="center"
px="4"
style={{
boxSizing: 'border-box',
}}
direction={wideFormat ? 'row' : 'column'}
>
<Flex
gap="6"
style={{ width: '100%' }}
direction={wideFormat ? 'row' : 'column'}
justify="center"
>
<Flex direction="column" gap="4">
<Heading>Configuration</Heading>
<Modules />
<Equipment />
<Provisions />
<Consumables />
<Skills />
<Miscellaneous />
</Flex>
<Flex
style={{
flex: 1,
maxWidth: wideFormat ? 640 : undefined,
minWidth: 320,
}}
direction="column"
gap="4"
>
<Heading>Characteristics</Heading>
<Characteristics />
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,125 @@
import { ChevronLeftIcon, MixIcon } from '@radix-ui/react-icons';
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import strings from '../../../../../../lang/en-US.json';
import { theme } from '../../../../../../stitches.config';
import { useDuel } from '../../../../../../stores/duel';
import { TankSandbox } from './TankSandbox';
import { Options } from './components/Options';
export function HeroSection() {
const protagonistTank = useDuel((state) => state.protagonist!.tank);
const antagonistTank = useDuel((state) => state.antagonist!.tank);
const wideFormat = useWideFormat(880);
const compareTanks =
protagonistTank.id === antagonistTank.id
? [protagonistTank.id]
: [protagonistTank.id, antagonistTank.id];
const isFullScreen = useFullScreen();
return (
<Flex
justify="center"
pt={wideFormat ? '0' : '6'}
style={{
background: 'var(--color-surface)',
position: 'relative',
height: wideFormat ? undefined : 'calc(75vh - 64px)',
}}
gap="4"
>
<Flex
direction={wideFormat ? 'row' : 'column'}
style={{
maxWidth: 1600,
flex: 1,
}}
>
<Flex
align="center"
justify="center"
style={{
flex: wideFormat ? 1 : undefined,
}}
>
<Flex
gap={wideFormat ? '4' : '2'}
direction="column"
ml={wideFormat ? '8' : undefined}
align={wideFormat ? undefined : 'center'}
justify="center"
style={{
height: '100%',
}}
>
<Heading size={wideFormat ? '9' : '8'}>
{protagonistTank.name}
</Heading>
<Text color="gray">
Tier {TIER_ROMAN_NUMERALS[protagonistTank.tier]}{' '}
{
(strings.common.nations_adjectives as Record<string, string>)[
protagonistTank.nation
]
}{' '}
{strings.common.tank_class_short[protagonistTank.class]}
</Text>
<Flex gap="4" mt="-1">
<Link href="/tools/tankopedia">
<Button variant="ghost" size="1" ml="-1">
<ChevronLeftIcon />
Back
</Button>
</Link>
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
<Button variant="ghost" size="1">
<MixIcon />
Compare
</Button>
</Link>
</Flex>
</Flex>
</Flex>
<div
className="tank-sandbox-container"
style={{
flex: 2,
position: isFullScreen ? 'fixed' : 'relative',
top: isFullScreen ? 0 : undefined,
left: isFullScreen ? 0 : undefined,
width: isFullScreen ? '100vw' : undefined,
height: isFullScreen ? '100vh' : wideFormat ? 512 : undefined,
zIndex: isFullScreen ? 3 : undefined,
background: isFullScreen ? theme.colors.appBackground1 : undefined,
}}
>
<div
style={{
width: '100%',
height: '100%',
position: 'absolute',
}}
>
<div
style={{
width: '100%',
height: '100%',
}}
>
<TankSandbox />
</div>
<Options />
</div>
</div>
</Flex>
</Flex>
);
}
@@ -1,50 +1,29 @@
import { LightningBoltIcon } from '@radix-ui/react-icons';
import { Button, Card, Flex, Tabs, Text, Theme } from '@radix-ui/themes';
import { PerspectiveCamera } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { Suspense, use, useEffect, useRef, useState } from 'react';
import { isMobile } from 'react-device-detect';
import { Suspense, use, useEffect, useRef } from 'react';
import { Armor } from '../../../../../../components/Armor';
import { ShotDisplay } from '../../../../../../components/Armor/components/ShotDisplay';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkit/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkit/pose';
import { tankIcon } from '../../../../../../core/blitzkit/tankIcon';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { BlitzkitWide } from '../../../../../../icons/BlitzkitWide';
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import mutateTankopediaPersistent, {
TankopediaMode,
mutateTankopediaTemporary,
useTankopediaPersistent,
} from '../../../../../../stores/tankopedia';
import { AntagonistBar } from '../AntagonistBar';
import { mutateTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { Controls } from '../Control';
import { Lighting } from '../Lighting';
import { RotationInputs } from '../QuickInputs';
import { SceneProps } from '../SceneProps';
import { ModelLoader } from './components/ModelLoader';
import { Options } from './components/Options';
import { TankModel } from './components/TankModel';
export function TankSandbox() {
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const awaitedModelDefinitions = use(modelDefinitions);
const canvasWrapper = useRef<HTMLDivElement>(null);
const mode = useTankopediaPersistent((state) => state.mode);
const protagonist = useDuel((state) => state.protagonist!);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const wideFormat = useWideFormat();
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
const duel = useDuel();
const isFullScreen = useFullScreen();
function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove);
@@ -138,137 +117,32 @@ export function TankSandbox() {
}, [protagonist.gun, protagonist.turret]);
return (
<Theme radius={isFullScreen ? 'none' : undefined}>
<Card
style={{
position: isFullScreen ? 'fixed' : 'relative',
border: isFullScreen ? 'none' : 'unset',
width: isFullScreen ? '100vw' : 'unset',
height: isFullScreen ? 'calc(100vh)' : 'unset',
top: isFullScreen ? 0 : 'unset',
left: isFullScreen ? 0 : 'unset',
zIndex: isFullScreen ? 2 : 'unset',
}}
ref={canvasWrapper}
>
<Flex
style={{
height: isFullScreen ? '100%' : '75vh',
maxHeight: isFullScreen ? 'unset' : 576,
}}
direction="column"
gap="2"
>
<Tabs.Root
value={mode}
onValueChange={(mode) => {
mutateTankopediaPersistent((draft) => {
draft.mode = mode as TankopediaMode;
});
}}
>
<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>
<Canvas
gl={{
clippingPlanes: Object.freeze([]),
localClippingEnabled: true,
}}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
style={{
userSelect: 'none',
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
<SceneProps />
{wideFormat && (
<Text
color="gray"
style={{
position: 'absolute',
left: '50%',
top: 10,
}}
>
<BlitzkitWide height={32} />
</Text>
)}
<div
style={{
height: 'calc(100% - 64px)',
position: 'absolute',
width: '100%',
bottom: 0,
}}
>
{loadModel ? (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
gl={{
clippingPlanes: Object.freeze([]),
localClippingEnabled: true,
}}
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
<SceneProps />
<Suspense fallback={<ModelLoader />}>
<Lighting />
<TankModel />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
</div>
) : (
<Flex
align="center"
justify="center"
style={{ height: '100%', position: 'relative' }}
>
<Button
style={{
zIndex: 1,
}}
onClick={() => {
setLoadModel(true);
}}
>
<LightningBoltIcon /> Load model
</Button>
<Flex
style={{
filter: 'blur(16px)',
width: 256,
height: 128,
display: 'flex',
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
transform: 'translate(25%, -25%) scale(300%)',
}}
>
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
</Flex>
</Flex>
)}
</div>
<Options canvasWrapper={canvasWrapper} isFullScreen={isFullScreen} />
{isFullScreen && <AntagonistBar floating />}
{loadModel && <RotationInputs isFullScreen={isFullScreen} />}
</Flex>
</Card>
</Theme>
<Suspense fallback={<ModelLoader />}>
<Lighting />
<TankModel />
<Armor />
</Suspense>
</Canvas>
);
}
@@ -0,0 +1,30 @@
import { Flex, Link, Text } from '@radix-ui/themes';
import PageWrapper from '../../../../../../components/PageWrapper';
import { PatreonIcon } from '../../../../../../icons/Patreon';
export function TankopediaPlug() {
return (
<PageWrapper>
<Flex justify="center">
<Text>
Liking BlitzKit? Consider donating on{' '}
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
underline="hover"
>
<PatreonIcon
style={{
width: '1em',
height: '1em',
transform: 'translateY(12.5%)',
}}
/>{' '}
Patreon
</Link>
.
</Text>
</Flex>
</PageWrapper>
);
}
@@ -0,0 +1,220 @@
import { ChevronRightIcon } from '@radix-ui/react-icons';
import { ChevronDownIcon, Flex, Text } from '@radix-ui/themes';
import Link from 'next/link';
import { ComponentProps, use } from 'react';
import PageWrapper from '../../../../../../components/PageWrapper';
import { asset } from '../../../../../../core/blitzkit/asset';
import { tankDefinitions } from '../../../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { useDuel } from '../../../../../../stores/duel';
export function TreeArrow({
style,
down = false,
...props
}: ComponentProps<typeof Text> & { down?: boolean }) {
const Icon = down ? ChevronDownIcon : ChevronRightIcon;
return (
<Text
color="gray"
style={{
flex: 1,
...style,
}}
{...props}
>
<Flex
style={{
backgroundColor: 'currentcolor',
height: down ? '100%' : 1,
width: down ? 1 : '100%',
position: 'relative',
}}
direction={down ? 'column' : 'row'}
justify="end"
>
<Icon
style={{
position: 'absolute',
right: down ? undefined : 0,
top: down ? undefined : '50%',
bottom: down ? 0 : undefined,
left: down ? '50%' : undefined,
transform: down ? 'translate(-50%, 2px)' : 'translate(5px, -50%)',
}}
/>
</Flex>
</Text>
);
}
export function TechTreeSection() {
const wideFormat = useWideFormat(720);
const awaitedTankDefinitions = use(tankDefinitions);
const tank = useDuel((state) => state.protagonist!.tank);
if (tank.treeType !== 'researchable') return null;
function Card({ id }: { id: number }) {
const ancestor = awaitedTankDefinitions[id];
return (
<Link
href={`/tools/tankopedia/${id}`}
key={id}
style={{
textDecoration: 'none',
color: 'inherit',
position: 'relative',
}}
>
<Flex direction="column" align="center">
<img
alt={ancestor.name}
src={asset(`icons/tanks/big/${id}.webp`)}
width={64}
height={64}
style={{
objectFit: 'contain',
}}
/>
<Flex direction="column" align="center">
<Flex gap="2" align="center">
<Text color="gray" size="1">
{TIER_ROMAN_NUMERALS[ancestor.tier]}
</Text>
<Text>{ancestor.name}</Text>
</Flex>
<Flex gap="2" align="center">
<Flex gap="1" align="center">
<img
alt="XP"
src={asset('icons/currencies/xp.webp')}
width={16}
height={16}
style={{
objectFit: 'contain',
}}
/>
<Text color="gray" size="1">
{ancestor.xp?.toLocaleString()}
</Text>
</Flex>
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset('icons/currencies/silver.webp')}
width={16}
height={16}
style={{
objectFit: 'contain',
}}
/>
<Text color="gray" size="1">
{ancestor.price.value?.toLocaleString()}
</Text>
</Flex>
</Flex>
</Flex>
</Flex>
</Link>
);
}
return (
<PageWrapper
noFlex1
containerProps={{
style: {
overflow: 'hidden',
marginTop: 16,
backgroundColor: 'var(--color-surface)',
},
}}
size={tank.ancestors && tank.successors ? 880 : 480}
gap="0"
>
{/* <Flex justify="center">
<Heading>Tech tree</Heading>
</Flex> */}
<Flex
align="center"
gap="6"
style={{}}
direction={wideFormat ? 'row' : 'column'}
>
{tank.ancestors && (
<>
<Flex
wrap="wrap"
direction={wideFormat ? 'column' : 'row'}
align="center"
justify="center"
style={{
maxHeight: 360,
}}
gap="4"
>
{tank.ancestors.map((id) => (
<Card key={id} id={id} />
))}
</Flex>
<TreeArrow down={!wideFormat} />
</>
)}
<Flex
direction="column"
style={{
position: 'relative',
}}
>
<img
alt={tank.nation}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
position: 'absolute',
top: '50%',
left: '50%',
height: '200%',
transform: 'translate(-40%, -50%)',
backgroundRepeat: 'no-repeat',
opacity: 1 / 4,
filter: 'blur(4px)',
}}
/>
<Card id={tank.id} />
</Flex>
{tank.successors && (
<>
<TreeArrow down={!wideFormat} />
<Flex
wrap="wrap"
direction={wideFormat ? 'column' : 'row'}
align="center"
justify="center"
style={{
maxHeight: 360,
}}
gap="4"
>
{tank.successors.map((id) => (
<Card key={id} id={id} />
))}
</Flex>
</>
)}
</Flex>
</PageWrapper>
);
}
@@ -1,12 +1,12 @@
import { Progress } from '@radix-ui/themes';
import { Html, useProgress } from '@react-three/drei';
import { Loader } from '../../../../../../../components/Loader';
export function ModelLoader() {
const { progress } = useProgress();
return (
<Html center position={[0, 1.5, 0]}>
<Loader naked progress={progress / 100} />
<Progress value={progress} style={{ width: 160 }} />
</Html>
);
}
@@ -1,25 +1,48 @@
import {
Cross1Icon,
EnterFullScreenIcon,
ExitFullScreenIcon,
EyeOpenIcon,
GearIcon,
} from '@radix-ui/react-icons';
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { RefObject } from 'react';
import {
Box,
Button,
Dialog,
DropdownMenu,
Flex,
Heading,
IconButton,
Strong,
Switch,
Tabs,
Text,
} from '@radix-ui/themes';
import { useState } from 'react';
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon';
import { resolveNearPenetration } from '../../../../../../../core/blitz/resolveNearPenetration';
import { asset } from '../../../../../../../core/blitzkit/asset';
import { Pose, poseEvent } from '../../../../../../../core/blitzkit/pose';
import {
SHELL_NAMES,
TIER_ROMAN_NUMERALS,
} from '../../../../../../../core/blitzkit/tankDefinitions/constants';
import { useEquipment } from '../../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../../hooks/useFullScreen';
import { useApp } from '../../../../../../../stores/app';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import mutateTankopediaPersistent, {
mutateTankopediaTemporary,
useTankopediaPersistent,
} from '../../../../../../../stores/tankopedia';
import { TankSearch } from '../../../../components/TankSearch';
import { ENVIRONMENTS } from '../../Lighting';
import { RotationInputs } from '../../QuickInputs';
interface OptionsProps {
isFullScreen: boolean;
canvasWrapper: RefObject<HTMLElement>;
}
export function Options({ isFullScreen }: OptionsProps) {
export function Options() {
const mode = useTankopediaPersistent((state) => state.mode);
const isFullScreen = useFullScreen();
const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid,
);
@@ -42,157 +65,506 @@ export function Options({ isFullScreen }: OptionsProps) {
(state) => state.model.visual.environment,
);
const developerMode = useApp((state) => state.developerMode);
const antagonistGun = useDuel((state) => state.antagonist!.gun);
const antagonistShell = useDuel((state) => state.antagonist!.shell);
const [antagonistSelectorOpen, setAntagonistSelectorOpen] = useState(false);
const antagonistTank = useDuel((state) => state.antagonist!.tank);
const antagonistTurret = useDuel((state) => state.antagonist!.turret);
const hasCalibratedShells = useEquipment(103, true);
const [tab, setTab] = useState('search');
const hasEnhancedArmor = useEquipment(110);
return (
<Flex
gap="4"
style={{
position: 'absolute',
top: 18,
right: 18,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost">
<EyeOpenIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost">
<GearIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showSpacedArmor}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showSpacedArmor = checked;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
<>
{isFullScreen && (
<Box
pl="4"
style={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translateY(-50%)',
}}
>
<Text
size={{
initial: '3',
xs: '5',
}}
color="gray"
style={{
writingMode: 'vertical-lr',
}}
>
Spaced armor
</DropdownMenu.CheckboxItem>
<Strong>BlitzKit</Strong> Tankopedia
</Text>
</Box>
)}
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
<RotationInputs />
{developerMode && (
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
{mode === 'armor' && (
<Flex
gap="2"
direction="column"
style={{
position: 'absolute',
right: 16,
top: '50%',
transform: 'translateY(-50%)',
}}
>
<Flex direction="column">
{antagonistGun.shells.map((thisShell, shellIndex) => (
<IconButton
color={thisShell.id === antagonistShell.id ? undefined : 'gray'}
variant="soft"
key={thisShell.id}
size={{
initial: '2',
sm: '3',
}}
style={{
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
borderTopRightRadius: shellIndex === 0 ? undefined : 0,
borderBottomRightRadius:
shellIndex === antagonistGun.shells.length - 1
? undefined
: 0,
borderBottomLeftRadius:
shellIndex === antagonistGun.shells.length - 1
? undefined
: 0,
marginTop: shellIndex === 0 ? 0 : -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.shell = thisShell;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt={thisShell.name}
src={asset(`icons/shells/${thisShell.icon}.webp`)}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
))}
</Flex>
<Flex direction="column" style={{ pointerEvents: 'auto' }}>
<IconButton
color={hasCalibratedShells ? undefined : 'gray'}
variant="soft"
style={{
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
}}
size={{
initial: '2',
sm: '3',
}}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
Wireframe
</DropdownMenu.CheckboxItem>
)}
<img
alt="Calibrated Shells"
src={asset('icons/equipment/103.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
<IconButton
color={hasEnhancedArmor ? undefined : 'gray'}
variant="soft"
size={{
initial: '2',
sm: '3',
}}
style={{
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
marginTop: -1,
}}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.equipmentMatrix[1][1] = hasEnhancedArmor
? 0
: -1;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<img
alt="Enhanced Armor"
src={asset('icons/equipment/110.webp')}
style={{
width: '50%',
height: '50%',
}}
/>
</IconButton>
</Flex>
</Flex>
)}
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
<Flex
style={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
}}
direction="column"
align="center"
gap="2"
>
{mode === 'armor' && (
<Flex align="center" gap="2">
<Text color="gray" size="2">
Shooter:
</Text>
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorOpen}
>
<Dialog.Trigger>
<Button variant="ghost">
<Flex gap="2" align="center">
<SmallTankIcon id={antagonistTank.id} size={16} />
{antagonistTank.name}
</Flex>
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Tabs.Root
value={tab}
onValueChange={setTab}
style={{ position: 'relative' }}
>
<Dialog.Close>
<Button
variant="ghost"
style={{ position: 'absolute', right: 0, top: 8 }}
>
<Cross1Icon />
</Button>
</Dialog.Close>
<Flex gap="4" direction="column">
<Tabs.List>
<Tabs.Trigger value="search">Search</Tabs.Trigger>
<Tabs.Trigger value="configure">Configure</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="search">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
mutateDuel((draft) => {
draft.antagonist!.tank = tank;
draft.antagonist!.engine = tank.engines.at(-1)!;
draft.antagonist!.track = tank.tracks.at(-1)!;
draft.antagonist!.turret = tank.turrets.at(-1)!;
draft.antagonist!.gun =
draft.antagonist!.turret.guns.at(-1)!;
draft.antagonist!.shell =
draft.antagonist!.gun.shells[0];
});
setAntagonistSelectorOpen(false);
}}
/>
</Flex>
</Tabs.Content>
<Tabs.Content value="configure">
<Flex direction="column" gap="4">
<Flex direction="column" gap="2" style={{ flex: 1 }}>
<Heading size="4">
{antagonistTank.name} modules
</Heading>
<Flex gap="2" wrap="wrap">
<Flex>
{antagonistTank.turrets.map((turret, index) => (
<ModuleButton
rowChild
first={index === 0}
last={
index === antagonistTank.turrets.length - 1
}
key={turret.id}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.turret = turret;
draft.antagonist!.gun =
turret.guns.at(-1)!;
draft.antagonist!.shell =
draft.antagonist!.gun.shells[0];
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
selected={antagonistTurret.id === turret.id}
discriminator={
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
))}
</Flex>
<Flex>
{antagonistTurret.guns.map((gun, index) => (
<ModuleButton
key={gun.id}
rowChild
first={index === 0}
last={
index === antagonistTurret.guns.length - 1
}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.gun = gun;
draft.antagonist!.shell = gun.shells[0];
});
}}
selected={antagonistGun.id === gun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
module="gun"
/>
))}
</Flex>
</Flex>
</Flex>
<Flex direction="column" style={{ flex: 1 }}>
<Heading size="4">Properties</Heading>
<ul>
<li>
Turret: <b>{antagonistTurret.name}</b>
</li>
<li>
Gun: <b>{antagonistGun.name}</b>
</li>
<li>Shells:</li>
<ul>
{antagonistGun.shells.map((shell) => (
<li key={shell.id}>
{SHELL_NAMES[shell.type]}:{' '}
<b>
{resolveNearPenetration(shell.penetration)}
mm
</b>
, {shell.damage.armor}HP
</li>
))}
</ul>
</ul>
</Flex>
</Flex>
</Tabs.Content>
</Flex>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
</Flex>
)}
<Flex gap="3" align="center">
<Flex
style={{
cursor: 'default',
userSelect: 'none',
}}
align="center"
gap="2"
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
draft.mode = draft.mode === 'armor' ? 'model' : 'armor';
});
}}
mr="1"
>
Opaque
</DropdownMenu.CheckboxItem>
<Switch size="1" checked={mode === 'armor'} />
Armor
</Flex>
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<EyeOpenIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
</DropdownMenu.Item>
<DropdownMenu.CheckboxItem
checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem
key={environment}
value={environment}
onClick={() => {
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{mode === 'armor' && (
<>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showSpacedArmor}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment;
draft.model.visual.showSpacedArmor = checked;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
{environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
Spaced armor
</DropdownMenu.CheckboxItem>
{fullScreenAvailable && (
<Button
variant="ghost"
onClick={() => {
if (isFullScreen) {
document.exitFullscreen();
} else document.body.requestFullscreen();
}}
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</Button>
)}
</Flex>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
{developerMode && (
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
Wireframe
</DropdownMenu.CheckboxItem>
)}
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
});
}}
>
Opaque
</DropdownMenu.CheckboxItem>
</>
)}
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
{/* TODO: consider removing this? */}
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem
key={environment}
value={environment}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment;
});
}}
>
{environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
{fullScreenAvailable && (
<IconButton
color="gray"
variant="ghost"
onClick={() => {
if (isFullScreen) {
document.exitFullscreen();
} else document.body.requestFullscreen();
}}
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</IconButton>
)}
</Flex>
</Flex>
</>
);
}
@@ -1,6 +1,6 @@
import { ThreeEvent, useThree } from '@react-three/fiber';
import { memo, useEffect, useRef } from 'react';
import { Euler, Group, Vector3 } from 'three';
import { Euler, Group, Vector2, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../constants/axis';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
@@ -142,6 +142,8 @@ export const TankModel = memo(() => {
node.name ===
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentTurret;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
@@ -152,6 +154,7 @@ export const TankModel = memo(() => {
event.stopPropagation();
if (isTurret) {
position.set(event.clientX, event.clientY);
yaw = protagonist.yaw;
pitch = protagonist.pitch;
@@ -172,9 +175,14 @@ export const TankModel = memo(() => {
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch,
yaw + event.movementX * (Math.PI / canvas.width),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
@@ -215,6 +223,8 @@ export const TankModel = memo(() => {
node.name ===
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isVisible = isCurrentGun || isCurrentMantlet;
const position = new Vector2();
const delta = new Vector2();
if (!isVisible) return null;
@@ -230,8 +240,11 @@ export const TankModel = memo(() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
position.set(event.clientX, event.clientY);
pitch = protagonist.pitch;
yaw = protagonist.yaw;
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
@@ -242,9 +255,13 @@ export const TankModel = memo(() => {
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const boundingRect = canvas.getBoundingClientRect();
delta.set(event.clientX, event.clientY).sub(position);
position.set(event.clientX, event.clientY);
[pitch, yaw] = applyPitchYawLimits(
pitch - event.movementY * (Math.PI / canvas.height),
yaw + event.movementX * (Math.PI / canvas.width),
pitch - delta.y * (Math.PI / boundingRect.height),
yaw + delta.x * (Math.PI / boundingRect.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
@@ -1,25 +0,0 @@
import { Button } from '@radix-ui/themes';
import { ComponentProps } from 'react';
import { asset } from '../../../../../../../core/blitzkit/asset';
interface QuickEquipmentButtonProps extends ComponentProps<typeof Button> {
equipment: number;
active: boolean;
}
export function QuickEquipmentButton({
equipment,
active,
...props
}: QuickEquipmentButtonProps) {
return (
<Button variant="ghost" {...props}>
<img
style={{ opacity: active ? 1 : 0.5 }}
src={asset(`icons/equipment/${equipment}.webp`)}
width={24}
height={24}
/>
</Button>
);
}
@@ -0,0 +1,11 @@
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'none',
selectors: {
'.tank-sandbox-container:hover &': {
display: 'flex',
},
},
});
@@ -1,185 +1,169 @@
import { HeightIcon, WidthIcon } from '@radix-ui/react-icons';
import { Flex, TextField } from '@radix-ui/themes';
import { use, useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkit/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkit/modelTransform';
import {
modelTransformEvent,
ModelTransformEventData,
} from '../../../../../../core/blitzkit/modelTransform';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import { useTankopediaPersistent } from '../../../../../../stores/tankopedia';
import { QuickEquipmentButton } from './components/QuickEquipmentButton';
import * as styles from './index.css';
interface RotationInputsProps {
isFullScreen?: boolean;
}
export function RotationInputs({ isFullScreen }: RotationInputsProps) {
export function RotationInputs() {
const protagonist = useDuel((state) => state.protagonist!);
const awaitedModelDefinitions = use(modelDefinitions);
const turretYawInput = useRef<HTMLInputElement>(null);
const gunPitchInput = useRef<HTMLInputElement>(null);
const mode = useTankopediaPersistent((state) => state.mode);
const hasCalibratedShells = useEquipment(103, true);
const hasEnhancedArmor = useEquipment(110);
const yawInput = useRef<HTMLInputElement>(null);
const pitchInput = useRef<HTMLInputElement>(null);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
const isFullScreen = useFullScreen();
useEffect(() => {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}, [protagonist.yaw]);
useEffect(() => {
gunPitchInput.current!.value = (
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}, [protagonist.pitch]);
useEffect(() => {
function handleTransformEvent({ pitch, yaw }: ModelTransformEventData) {
if (!pitchInput.current || !yawInput.current) return;
pitchInput.current.value = (-radToDeg(pitch)).toFixed(1);
if (yaw === undefined) return;
yawInput.current.value = radToDeg(yaw).toFixed(1);
}
modelTransformEvent.on(handleTransformEvent);
return () => {
modelTransformEvent.off(handleTransformEvent);
};
}, []);
return (
<Flex
justify="center"
align="center"
className={isFullScreen ? undefined : styles.container}
gap="2"
style={{
width: 'calc(100% - 16px)',
width: 128 + 32,
pointerEvents: 'auto',
position: 'absolute',
left: 8,
bottom: isFullScreen ? 80 : 8,
pointerEvents: 'none',
left: '50%',
top: 16,
transform: 'translateX(-50%)',
}}
gap="4"
>
<Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
<TextField.Root
radius="full"
<TextField.Root
size="1"
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(yawInput.current!.value);
if (isNaN(value)) {
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
yawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
yawInput.current?.blur();
}
}}
onFocus={() => yawInput.current?.focus()}
ref={yawInput}
>
<TextField.Slot
style={{
flex: 1,
textAlign: 'right',
userSelect: 'none',
}}
onBlur={() => {
const value = Number(turretYawInput.current!.value);
if (isNaN(value)) {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
Yaw
</TextField.Slot>
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
</TextField.Root>
<WidthIcon />
</TextField.Slot>
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
</TextField.Root>
<TextField.Root
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(gunPitchInput.current!.value);
if (isNaN(value)) {
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
<TextField.Root
size="1"
radius="full"
style={{
flex: 1,
textAlign: 'right',
}}
onBlur={() => {
const value = Number(pitchInput.current!.value);
if (isNaN(value)) {
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
pitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
pitchInput.current?.blur();
}
}}
onFocus={() => pitchInput.current?.focus()}
ref={pitchInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
Pitch
</TextField.Slot>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root>
</Flex>
{mode === 'armor' && (
<Flex gap="4" style={{ pointerEvents: 'auto' }}>
<QuickEquipmentButton
equipment={103}
active={hasCalibratedShells}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
});
}}
/>
<QuickEquipmentButton
equipment={110}
active={hasEnhancedArmor}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.equipmentMatrix[1][1] = hasEnhancedArmor
? 0
: -1;
});
}}
/>
</Flex>
)}
<HeightIcon />
</TextField.Slot>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root>
</Flex>
);
}
@@ -0,0 +1,10 @@
import { Separator } from '@radix-ui/themes';
import PageWrapper from '../../../../../components/PageWrapper';
export function TankopediaSeparator() {
return (
<PageWrapper>
<Separator mt="4" style={{ width: '100%' }} />
</PageWrapper>
);
}
@@ -38,6 +38,7 @@ export function Title() {
>
<Flex align="center" gap="2">
<img
alt={protagonist.tank.name}
src={
TREE_TYPE_ICONS[protagonist.tank.treeType][
protagonist.tank.class
@@ -1,64 +0,0 @@
import { PlusIcon } from '@radix-ui/react-icons';
import { Card, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { use } from 'react';
import { videoDefinitions } from '../../../../../core/blitzkit/videos';
import { useFullScreen } from '../../../../../hooks/useFullScreen';
import { useDuel } from '../../../../../stores/duel';
export function Videos() {
const isFullScreen = useFullScreen();
const tank = useDuel((state) => state.protagonist!.tank);
const awaitedVideoDefinitions = use(videoDefinitions);
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? [];
if (isFullScreen) return null;
return (
<Flex direction="column" gap="4" mt="4" align="start">
<Heading size="6">Review videos</Heading>
<Flex gap="3" wrap="wrap">
{videos.map((video) => (
<Link
key={video}
href={`https://www.youtube.com/watch?v=${video}`}
target="_blank"
>
<img
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`}
style={{
aspectRatio: '16 / 9',
objectFit: 'cover',
height: 128,
borderRadius: 8,
}}
/>
</Link>
))}
<Link href="https://discord.gg/nDt7AjGJQH" target="_blank">
<Card
style={{
aspectRatio: '16 / 9',
height: 128,
}}
>
<Flex
align="center"
justify="center"
gap="1"
style={{
width: '100%',
height: '100%',
}}
>
<Text color="purple">
{videos.length === 0 ? 'Suggest' : 'Suggest more'} <PlusIcon />
</Text>
</Flex>
</Card>
</Link>
</Flex>
</Flex>
);
}
+42 -1
View File
@@ -1,7 +1,10 @@
import { Flex, Heading, Link, Text } from '@radix-ui/themes';
import { ReactNode } from 'react';
import PageWrapper from '../../../../components/PageWrapper';
import { tankDefinitions } from '../../../../core/blitzkit/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
import { videoDefinitions } from '../../../../core/blitzkit/videos';
import strings from '../../../../lang/en-US.json';
interface TankopediaLayoutProps {
@@ -15,11 +18,13 @@ export default async function TankopediaLayout({
}: TankopediaLayoutProps) {
const id = Number(params.id);
const awaitedTankDefinitions = await tankDefinitions;
const awaitedVideoDefinitions = await videoDefinitions;
const tank = awaitedTankDefinitions[id];
const title = `${tank.name} - Tier ${TIER_ROMAN_NUMERALS[tank.tier]} ${
(strings.common.nations as Record<string, string>)[tank.nation]
(strings.common.nations_adjectives as Record<string, string>)[tank.nation]
} ${strings.common.tank_class_short[tank.class]}`;
const description = `Statistics, armor profiles, and equipment for ${tank.name}`;
const videos = awaitedVideoDefinitions[tank.id]?.videos ?? [];
return (
<>
@@ -30,6 +35,42 @@ export default async function TankopediaLayout({
<meta property="og:description" content={description} />
{children}
<PageWrapper noFlex1>
<Flex direction="column" gap="4" align="start">
<Flex direction="column">
<Heading size="6">Review videos</Heading>
<Text color="gray">
BlitzKit does not endorse any review videos.
</Text>
</Flex>
<Flex gap="3" wrap="wrap">
{videos.map((video) => (
<Link
key={video}
href={`https://www.youtube.com/watch?v=${video}`}
target="_blank"
>
<img
alt="Video thumbnail"
src={`https://i.ytimg.com/vi/${video}/hqdefault.jpg`}
style={{
aspectRatio: '16 / 9',
objectFit: 'cover',
height: 128,
borderRadius: 8,
}}
/>
</Link>
))}
</Flex>
</Flex>
</PageWrapper>
<PageWrapper noFlex1>
<Heading>History of the {tank.name}</Heading>
<Text>{tank.description}</Text>
</PageWrapper>
</>
);
}
+23 -58
View File
@@ -1,28 +1,18 @@
'use client';
import { Flex } from '@radix-ui/themes';
import { useEffect } from 'react';
import PageWrapper from '../../../../components/PageWrapper';
import { assignDuelMember } from '../../../../core/blitzkit/assignDuelMember';
import { useWideFormat } from '../../../../hooks/useWideFormat';
import { mutateDuel, useDuel } from '../../../../stores/duel';
import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
import { AntagonistBar } from './components/AntagonistBar';
import { Characteristics } from './components/Characteristics';
import { Consumables } from './components/Characteristics/components/Consumables';
import { Equipment } from './components/Characteristics/components/Equipment';
import { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
import { Modules } from './components/Characteristics/components/Modules';
import { Provisions } from './components/Characteristics/components/Provisions';
import { Skills } from './components/Characteristics/components/Skills';
import { TankSandbox } from './components/Model/TankSandbox';
import { Title } from './components/Title';
import { Videos } from './components/Videos';
import { CharacteristicsSection } from './components/Model/CharacteristicsSection';
import { HeroSection } from './components/Model/HeroSection';
import { TankopediaPlug } from './components/Model/TankopediaPlug';
import { TechTreeSection } from './components/Model/TechTreeSection';
export default function Page({ params }: { params: { id: string } }) {
const initialId = parseInt(params.id);
const assigned = useDuel((state) => state.assigned);
const wideFormat = useWideFormat();
useEffect(() => {
assignDuelMember('both', initialId);
@@ -65,53 +55,28 @@ export default function Page({ params }: { params: { id: string } }) {
};
}, [initialId, assigned]);
useEffect(() => {
const unsubscribe = useDuel.subscribe(
(state) => state.protagonist?.tank.id,
() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
},
);
return unsubscribe;
}, []);
return (
<PageWrapper color="purple" size={1600}>
<PageWrapper noPadding noMaxWidth color="purple" size={1600}>
{assigned && (
<>
<Title />
<Flex
style={{ width: '100%' }}
gap="8"
direction={wideFormat ? 'row' : 'column'}
align="start"
justify="center"
>
<Flex
gap="8"
direction="column"
style={{
flex: 1,
width: '100%',
top: 64 + 16,
position: wideFormat ? 'sticky' : undefined,
}}
>
<Flex gap="4" direction="column">
<TankSandbox />
<AntagonistBar />
</Flex>
<Flex gap="5" wrap="wrap">
<Modules />
<Provisions />
<Consumables />
<Flex gap="5" wrap="wrap">
<Skills />
<Equipment />
</Flex>
<Miscellaneous />
</Flex>
</Flex>
<Flex style={{ width: wideFormat ? 320 : '100%' }}>
<Characteristics />
</Flex>
</Flex>
<Videos />
<HeroSection />
<TankopediaPlug />
<CharacteristicsSection />
<TechTreeSection />
{/* <TankopediaSeparator /> */}
</>
)}
</PageWrapper>
@@ -1,5 +1,5 @@
import { CaretLeftIcon, CaretRightIcon } from '@radix-ui/react-icons';
import { Button, Flex, TextField } from '@radix-ui/themes';
import { Flex, IconButton, TextField } from '@radix-ui/themes';
import { useEffect, useRef } from 'react';
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
import mutateTankopediaPersistent, {
@@ -21,7 +21,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
return (
<Flex align="center" justify="center" gap="2">
<Button
<IconButton
variant="soft"
disabled={page === 0}
onClick={() => {
@@ -31,7 +31,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
}}
>
<CaretLeftIcon />
</Button>
</IconButton>
<TextField.Root
defaultValue={1}
type="number"
@@ -61,7 +61,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot>
</TextField.Root>
<Button
<IconButton
variant="soft"
disabled={Math.floor(searchedList.length / tanksPerPage) === page}
onClick={() => {
@@ -74,7 +74,7 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
}}
>
<CaretRightIcon />
</Button>
</IconButton>
</Flex>
);
}
@@ -5,7 +5,7 @@ import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkit/tankDefinit
interface CompactSearchResultRowProps {
tanks: TankDefinition[];
onSelect: (tank: TankDefinition) => void;
onSelect?: (tank: TankDefinition) => void;
}
export function CompactSearchResultRow({
@@ -57,6 +57,7 @@ export function Options() {
}
>
<img
alt={type}
src={TREE_TYPE_IMAGES[type]}
style={{ width: '1em', height: '1em' }}
/>
@@ -124,6 +125,7 @@ export function Options() {
}
>
<img
alt={type}
src={TREE_TYPE_ICONS.researchable[type]}
style={{ width: '1em', height: '1em' }}
/>
@@ -0,0 +1,175 @@
import { slateDark } from '@radix-ui/colors';
import { Card, Flex, Inset, Link, Text } from '@radix-ui/themes';
import { asset } from '../../../../../../core/blitzkit/asset';
import { TankDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
} from '../../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../../core/blitzkit/tankIcon';
import { theme } from '../../../../../../stitches.config';
import * as styles from '../../../page.css';
import { CompactSearchResultRow } from './CompactSearchResultRow';
interface ResultsProps {
compact?: boolean;
results: TankDefinition[];
onSelect?: (tank: TankDefinition) => void;
}
export function Results({ compact, results, onSelect }: ResultsProps) {
const firstChunk = compact
? results.slice(0, Math.ceil(results.length / 2))
: [];
const secondChunk = compact
? results.slice(Math.ceil(results.length / 2))
: [];
return (
<Flex
wrap="wrap"
gap="2"
style={{
justifyContent:
compact && firstChunk.length > 0 && secondChunk.length > 0
? 'space-around'
: 'center',
}}
>
{!compact &&
results.map((tank) => (
<Link
key={tank.id}
className={styles.listing}
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
minWidth: 256,
}}
>
<Card
key={tank.id}
style={{
cursor: 'pointer',
height: 64,
width: '100%',
}}
>
<Inset
key={tank.id}
style={{
minHeight: 64,
position: 'relative',
display: 'flex',
}}
>
<img
alt={tank.name}
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
alt={tank.name}
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
alt={tank.name}
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Link>
))}
{compact && firstChunk.length > 0 && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)}
{compact && secondChunk.length > 0 && (
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)}
</Flex>
);
}
@@ -0,0 +1,337 @@
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu } from '@radix-ui/themes';
import mutateTankopediaPersistent, {
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../../../../stores/tankopedia';
export function Sort() {
const sort = useTankopediaPersistent((state) => state.sort);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Meta</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.tier'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.tier';
});
}}
>
Tier
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.name'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.name';
});
}}
>
Name
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dpm'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dpm';
});
}}
>
DPM
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.reload'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.reload';
});
}}
>
Reload
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.caliber'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.caliber';
});
}}
>
Caliber
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.standardPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.standardPenetration';
});
}}
>
Standard penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.premiumPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.premiumPenetration';
});
}}
>
Premium penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.damage'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.damage';
});
}}
>
Damage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.shellVelocity'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.shellVelocity';
});
}}
>
Shell velocity
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.aimTime'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.aimTime';
});
}}
>
Aim time
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionStill';
});
}}
>
Dispersion still
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionMoving';
});
}}
>
Dispersion moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunDepression'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunDepression';
});
}}
>
Gun depression
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunElevation'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunElevation';
});
}}
>
Gun elevation
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Maneuverability</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.forwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.forwardsSpeed';
});
}}
>
Forwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.backwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.backwardsSpeed';
});
}}
>
Backwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.power'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.power';
});
}}
>
Power
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.powerToWeight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.powerToWeight';
});
}}
>
Power to weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.weight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.weight';
});
}}
>
Weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.traverseSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.traverseSpeed';
});
}}
>
Traverse speed
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Survivability</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.health'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.health';
});
}}
>
Health
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.viewRange'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.viewRange';
});
}}
>
View range
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageStill';
});
}}
>
Camouflage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageMoving';
});
}}
>
Camouflage moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageShooting'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageShooting';
});
}}
>
Camouflage shooting
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.volume'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.volume';
});
}}
>
Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
@@ -1,6 +1,4 @@
import { slateDark } from '@radix-ui/colors';
import {
CaretDownIcon,
CaretRightIcon,
MagnifyingGlassIcon,
TrashIcon,
@@ -8,20 +6,17 @@ import {
import {
AlertDialog,
Button,
Card,
DropdownMenu,
Flex,
Inset,
Spinner,
Text,
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import Link from 'next/link';
import { use, useEffect, useMemo, useRef, useState } from 'react';
import { isMobileOnly } from 'react-device-detect';
import { TreeType } from '../../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
import { asset } from '../../../../../core/blitzkit/asset';
import { modelDefinitions } from '../../../../../core/blitzkit/modelDefinitions';
import { normalizeBoundingBox } from '../../../../../core/blitzkit/normalizeBoundingBox';
import { resolveDpm } from '../../../../../core/blitzkit/resolveDpm';
@@ -31,24 +26,15 @@ import {
TankDefinition,
tanksDefinitionsArray,
} from '../../../../../core/blitzkit/tankDefinitions';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkit/tankDefinitions/constants';
import { tankIcon } from '../../../../../core/blitzkit/tankIcon';
import { unionBoundingBox } from '../../../../../core/blitzkit/unionBoundingBox';
import { theme } from '../../../../../stitches.config';
import mutateTankopediaPersistent, {
SORT_NAMES,
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../../../stores/tankopedia';
import * as styles from '../../page.css';
import { PageTurner } from '../PageTurner';
import { CompactSearchResultRow } from './components/CompactSearchResultRow';
import { Options } from './components/Options';
import { Results } from './components/Results';
import { Sort } from './components/Sort';
interface TankSearchProps {
compact?: boolean;
@@ -63,12 +49,13 @@ export function TankSearch({
onSelect = () => {},
onSelectAll,
}: TankSearchProps) {
const tanksPerPage = compact ? 16 : 96;
const tanksPerPage = (compact ? 16 : 96) * (isMobileOnly ? 0.5 : 1);
const nations = use(NATIONS);
const filters = useTankopediaPersistent((state) => state.filters);
const sort = useTankopediaPersistent((state) => state.sort);
const awaitedTanks = use(tanksDefinitionsArray);
const awaitedModelDefinitions = use(modelDefinitions);
const [loaded, setLoaded] = useState(false);
const defaultSortedTanks = useMemo(
() =>
awaitedTanks
@@ -414,13 +401,6 @@ export function TankSearch({
page * tanksPerPage,
(page + 1) * tanksPerPage,
);
const firstChunk = searchResultsPageSlice.slice(
0,
Math.ceil(searchResultsPageSlice.length / 2),
);
const secondChunk = searchResultsPageSlice.slice(
Math.ceil(searchResultsPageSlice.length / 2),
);
useEffect(() => {
mutateTankopediaPersistent((draft) => {
@@ -431,6 +411,18 @@ export function TankSearch({
});
}, [searchResults]);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded) {
return (
<Flex justify="center">
<Spinner />
</Flex>
);
}
return (
<>
<Flex direction="column" gap="4">
@@ -472,332 +464,7 @@ export function TankSearch({
)}
</TextField.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Meta</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.tier'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.tier';
});
}}
>
Tier
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'meta.name'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'meta.name';
});
}}
>
Name
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Fire</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dpm'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dpm';
});
}}
>
DPM
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.reload'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.reload';
});
}}
>
Reload
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.caliber'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.caliber';
});
}}
>
Caliber
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.standardPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.standardPenetration';
});
}}
>
Standard penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.premiumPenetration'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.premiumPenetration';
});
}}
>
Premium penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.damage'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.damage';
});
}}
>
Damage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.shellVelocity'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.shellVelocity';
});
}}
>
Shell velocity
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.aimTime'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.aimTime';
});
}}
>
Aim time
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionStill';
});
}}
>
Dispersion still
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.dispersionMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.dispersionMoving';
});
}}
>
Dispersion moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunDepression'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunDepression';
});
}}
>
Gun depression
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'fire.gunElevation'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'fire.gunElevation';
});
}}
>
Gun elevation
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
Maneuverability
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.forwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.forwardsSpeed';
});
}}
>
Forwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.backwardsSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.backwardsSpeed';
});
}}
>
Backwards speed
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.power'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.power';
});
}}
>
Power
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.powerToWeight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.powerToWeight';
});
}}
>
Power to weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.weight'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.weight';
});
}}
>
Weight
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'maneuverability.traverseSpeed'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'maneuverability.traverseSpeed';
});
}}
>
Traverse speed
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Survivability</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.health'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.health';
});
}}
>
Health
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.viewRange'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.viewRange';
});
}}
>
View range
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageStill'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageStill';
});
}}
>
Camouflage
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageMoving'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageMoving';
});
}}
>
Camouflage moving
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.camouflageShooting'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.camouflageShooting';
});
}}
>
Camouflage shooting
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.volume'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.volume';
});
}}
>
Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Sort />
<Button
color="red"
@@ -873,147 +540,15 @@ export function TankSearch({
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)}
<Flex
wrap="wrap"
gap="2"
style={{
justifyContent:
compact && firstChunk.length > 0 && secondChunk.length > 0
? 'space-around'
: 'center',
}}
>
{!compact &&
searchResultsPageSlice.map((tank) => (
<Link
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
minWidth: 256,
}}
>
<Card
key={tank.id}
className={styles.listing}
style={{
cursor: 'pointer',
height: 64,
width: '100%',
}}
>
<Inset
key={tank.id}
style={{
minHeight: 64,
position: 'relative',
display: 'flex',
}}
>
<img
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<Results
compact={compact}
results={searchResultsPageSlice}
onSelect={onSelect}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Link>
))}
{compact && firstChunk.length > 0 && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)}
{compact && secondChunk.length > 0 && (
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)}
</Flex>
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
{searchResultsPageSlice.length > tanksPerPage / 4 && (
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)}
</>
);
}
+3
View File
@@ -86,6 +86,7 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="avatar"
src={
interaction.user.avatarURL({ extension: 'png' }) ??
'https://i.stack.imgur.com/l60Hf.png'
@@ -139,6 +140,7 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="accounts linked"
src="https://i.imgur.com/jIcRgog.png"
style={{
width: 16,
@@ -160,6 +162,7 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
>
{clanAccountInfo?.clan ? (
<img
alt="clan emblem"
style={{
width: 64,
height: 64,
+40 -12
View File
@@ -1,3 +1,4 @@
import { SlashCommandSubcommandBuilder } from 'discord.js';
import markdownEscape from 'markdown-escape';
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
import CommandWrapper from '../components/CommandWrapper';
@@ -31,20 +32,42 @@ const LEAGUE_ACCENT = [
export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command:
createLocalizedCommand('rating').addStringOption(addUsernameChoices),
command: createLocalizedCommand('rating', [
{
subcommand: 'today',
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
},
},
{
subcommand: 'season',
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
},
},
]),
inProduction: true,
inPublic: true,
async handler(interaction) {
const subcommand = interaction.options.getSubcommand(true) as
| 'season'
| 'today';
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 ratingInfo = await getRatingInfo(region);
if (ratingInfo.detail) {
throw new Error(`Rating info not available for ${region}`);
}
const leaderboard = await getArchivedRatingMidnightLeaderboard(
region,
53,
ratingInfo.current_season - (subcommand === 'today' ? 0 : 1),
);
if (leaderboard?.format === BkrlFormat.Base) {
@@ -53,7 +76,6 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
);
}
const ratingInfo = await getRatingInfo(region);
const accountInfo = await getAccountInfo(region, id, [
'statistics.rating',
]);
@@ -63,12 +85,17 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
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 statsB2Array = (await getRatingNeighbors(region, id, 0)).neighbors;
if (statsB2Array === undefined) {
return translate('bot.commands.rating.errors.no_participation', [
markdownEscape(accountInfo.nickname),
]);
}
const [statsB2] = statsB2Array;
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`;
}
@@ -98,7 +125,7 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
<TitleBar
title={accountInfo.nickname}
image={clanImage}
description={`${t`bot.commands.rating.body.subtitle`} • ${new Date().toLocaleDateString(
description={`${translate(`bot.commands.rating.body.subtitle.${subcommand}`)} • ${new Date().toLocaleDateString(
interaction.locale,
)}`}
/>
@@ -142,6 +169,7 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt={`League ${league.name}`}
style={{
width: 64,
height: 64,
@@ -211,13 +239,13 @@ export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
</span>
{positionDelta !== undefined && positionDelta !== 0 && (
<>
<DeltaCaret delta={positionDelta} />
<DeltaCaret delta={-positionDelta} />
<span
style={{
color:
positionDelta > 0
? theme.colors.textLowContrast_green
: theme.colors.textLowContrast_red,
? theme.colors.textLowContrast_red
: theme.colors.textLowContrast_green,
fontSize: 16,
}}
>
+1
View File
@@ -271,6 +271,7 @@ async function playerListing(
}}
>
<img
alt={tank.name}
src={await iconPng(tankIcon(tank.id))}
style={{
width: 32,
+9
View File
@@ -280,6 +280,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="XP icon"
src={await iconPng(asset('icons/currencies/xp.webp'))}
width={16}
height={16}
@@ -305,6 +306,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="Silver icon"
src={await iconPng(asset('icons/currencies/silver.webp'))}
width={16}
height={16}
@@ -330,6 +332,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="Silver icon"
src={await iconPng(asset('icons/currencies/silver.webp'))}
width={16}
height={16}
@@ -390,6 +393,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="Tank icon"
src={await tankIconPng(id)}
style={{
height: 100,
@@ -414,6 +418,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt={tank.name}
src={TANK_ICONS[tank.class]}
width={16}
height={16}
@@ -447,6 +452,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="XP icon"
src={await iconPng(asset('icons/currencies/xp.webp'))}
width={16}
height={16}
@@ -474,6 +480,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="Free XP icon"
src={await iconPng(
asset('icons/currencies/free-xp.webp'),
)}
@@ -501,6 +508,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="Silver icon"
src={await iconPng(
asset('icons/currencies/silver.webp'),
)}
@@ -529,6 +537,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}}
>
<img
alt="Silver icon"
src={await iconPng(
asset('icons/currencies/silver.webp'),
)}
+7 -6
View File
@@ -32,14 +32,15 @@ export default function AllStatsOverview({
}: AllStatsOverviewProps) {
const { t, translate } = translator(locale);
const percentile =
supplementaryStats.WN8 === undefined
? Percentile.VeryBad
: getWN8Percentile(supplementaryStats.WN8);
typeof supplementaryStats.WN8 === 'number'
? getWN8Percentile(supplementaryStats.WN8)
: Percentile.VeryBad;
const color = PERCENTILE_COLORS[percentile];
const heroStat =
supplementaryStats.WN8 === undefined
? '--'
: supplementaryStats.WN8.toFixed(0);
typeof supplementaryStats.WN8 === 'number'
? supplementaryStats.WN8.toFixed(0)
: '--';
return (
<div
@@ -13,6 +13,7 @@ import { useModelDefinitions } from '../../../hooks/useModelDefinitions';
import { useDuel } from '../../../stores/duel';
import { useTankopediaPersistent } from '../../../stores/tankopedia';
import { CoreArmorSceneComponent } from './CoreArmorSceneComponent';
import { ShotDisplay } from './ShotDisplay';
export const CoreArmorScene = memo(() => {
const wrapper = useRef<Group>(null);
@@ -135,86 +136,94 @@ export const CoreArmorScene = memo(() => {
).applyAxisAngle(I_HAT, Math.PI / 2);
return (
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
tankModelDefinition.armor,
armorId,
);
<>
<ShotDisplay />
if (!isVisible || spaced || thickness === undefined) return null;
return (
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={turretContainer}>
<group
ref={wrapper}
rotation={[-Math.PI / 2, 0, 0]}
visible={initialTankopediaState.mode === 'armor'}
>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentTurret;
const isHull = node.name.startsWith('hull_');
const isVisible = isHull;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
turretModelDefinition.armor,
tankModelDefinition.armor,
armorId,
);
if (!isVisible || spaced || thickness === undefined) return null;
return (
<group key={node.uuid} position={turretOrigin}>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
);
})}
</group>
<group ref={gunContainer}>
<group ref={turretContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
const isCurrentTurret = node.name.startsWith(
`turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
turretModelDefinition.armor,
armorId,
);
if (!isVisible || spaced || thickness === undefined) return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<CoreArmorSceneComponent thickness={thickness} node={node} />
<group key={node.uuid} position={turretOrigin}>
<CoreArmorSceneComponent
key={node.uuid}
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
<group ref={gunContainer}>
<group position={hullOrigin}>
{armorNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
gunModelDefinition.armor,
armorId,
);
if (!isVisible || spaced || thickness === undefined)
return null;
return (
<group
key={node.uuid}
position={turretOrigin.clone().add(gunOrigin)}
>
<CoreArmorSceneComponent
thickness={thickness}
node={node}
/>
</group>
);
})}
</group>
</group>
</group>
</group>
</group>
</>
);
});
@@ -5,6 +5,7 @@ import { degToRad } from 'three/src/math/MathUtils';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../core/blitzkit/tankDefinitions';
@@ -60,7 +61,9 @@ export function CoreArmorSceneComponent({
useEffect(() => {
async function handleShellChange(shell: ShellDefinition) {
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
@@ -91,23 +94,22 @@ export function CoreArmorSceneComponent({
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = useDuel.getState();
const penetration = resolveNearPenetration(
duel.antagonist!.shell.penetration,
);
const shell = duel.antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
: penetration;
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(useDuel.getState().antagonist!.shell);
+337 -265
View File
@@ -1,27 +1,18 @@
import { Card, Flex, Text } from '@radix-ui/themes';
import { Card, Flex, Inset, Separator, Text } from '@radix-ui/themes';
import { Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import { useEffect, useRef } from 'react';
import { Mesh } from 'three';
import { ComponentProps, useRef } from 'react';
import { Euler, Mesh, Quaternion } from 'three';
import { radToDeg } from 'three/src/math/MathUtils';
import { canSplash } from '../../../core/blitz/canSplash';
import { isExplosive } from '../../../core/blitz/isExplosive';
import { NaNFallback } from '../../../core/math/NaNFallback';
import { normalToEuler } from '../../../core/math/normalToEuler';
import { AngledPenetration } from '../../../icons/AngledPenetration';
import { Block } from '../../../icons/Block';
import { NominalPenetration } from '../../../icons/NominalPenetration';
import { Ricochet } from '../../../icons/Ricochet';
import { useDuel } from '../../../stores/duel';
import { J_HAT } from '../../../constants/axis';
import {
ShotLayer,
ShotLayerBase,
ShotLayerGap,
ShotLayerNonExternal,
mutateTankopediaTemporary,
ShotStatus,
useTankopediaTemporary,
} from '../../../stores/tankopedia';
import { ArmorType } from './SpacedArmorScene';
import { ExternalModuleVariant } from './SpacedArmorSceneComponent';
const layerTypeNames: Record<ArmorType | 'null', string> = {
[ArmorType.Core]: 'Core',
@@ -30,297 +21,378 @@ const layerTypeNames: Record<ArmorType | 'null', string> = {
null: 'Gap',
};
const externalLayerNames: Record<ExternalModuleVariant, string> = {
gun: 'Gun',
track: 'Tracks',
};
const shotStatusNames: Record<ShotStatus, string> = {
blocked: 'Blocked',
ricochet: 'Ricochet',
penetration: 'penetration',
splash: 'splash',
};
const shotStatusColors: Record<
ShotStatus,
ComponentProps<typeof Text>['color']
> = {
blocked: 'red',
penetration: 'green',
ricochet: 'yellow',
splash: 'orange',
};
const SURFACE_POINT_SIZE = 12;
const LENGTH_INFINITY = 4;
const TRACER_THICK = 1 / 64;
const TRACER_THICK = 1 / 32;
const TRACER_THIN = TRACER_THICK / 2;
function LayerEntry({
layerIndex,
shotStatusColor,
layerName,
layer,
angle,
}: {
layerIndex?: string;
shotStatusColor: ComponentProps<typeof Text>['color'];
layerName: string;
layer: ShotLayer;
angle?: number;
}) {
return (
<Flex align="center" gap="2">
<Text
size="1"
color="gray"
style={{
width: 12,
}}
>
{layerIndex}
</Text>
<Text size="2" color={shotStatusColor} style={{ width: 47 }}>
{layerName}
</Text>
{layer.type === null && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.distance * 1000).toLocaleString()}
<Text size="1">mm</Text>
</Text>
<Text size="2" color="gray">
{Math.max(-100, Math.round(-50 * layer.distance))}% penetration
</Text>
</>
)}
{layer.type === ArmorType.External && (
<Text
size="2"
color={shotStatusColor}
style={{
width: 64,
}}
>
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text>
</Text>
)}
{(layer.type === ArmorType.Core || layer.type === ArmorType.Spaced) && (
<>
<Text
size="2"
color={shotStatusColor}
style={{
width: 80,
}}
>
{Math.round(layer.thicknessAngled).toLocaleString()}
<Text size="1">{`mm${angle === undefined ? '' : ` ${Math.round(radToDeg(angle))}°`}`}</Text>
</Text>
<Text size="2" color="gray">
{Math.round(layer.thickness).toLocaleString()}
<Text size="1">mm</Text> nominal
</Text>
</>
)}
</Flex>
);
}
export function ShotDisplay() {
const shot = useTankopediaTemporary((state) => state.shot);
const preMidPointTracer = useRef<Mesh>(null);
const postMidPointTracer = useRef<Mesh>(null);
const hasMultipleLayers = shot?.layers.some(
(layer, index) => index !== 0 && layer.type !== null,
);
const { shell } = useDuel.getState().antagonist!;
const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const midPointLayer = (shot?.layers.findLast(
(layer) => layer.type !== null && layer.status === 'ricochet',
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
const firstLayer = shot?.layers[0] as Exclude<ShotLayer, ShotLayerGap>;
const lastLayer = shot?.layers.at(-1)!;
const distanceFromSpacedArmor = shot
? firstLayer.point.distanceTo(shot.point)
: undefined;
const preMidPointLayers = shot?.layers.slice(
0,
shot.layers.indexOf(midPointLayer as ShotLayer),
);
const postMidPointLayers = shot?.layers.slice(
shot.layers.indexOf(midPointLayer as ShotLayer),
shot.layers.length,
);
const preMidPointGap = shot
? preMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) + LENGTH_INFINITY
: 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
? postMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) || LENGTH_INFINITY
: undefined;
const inTracer = useRef<Mesh>(null);
const outTracer = useRef<Mesh>(null);
useFrame(({ clock }) => {
if (!shot || !preMidPointTracer.current) return;
if (!shot || !inTracer.current) return;
const t1 = clock.elapsedTime % 1;
const t2 = (clock.elapsedTime + 0.5) % 1;
preMidPointTracer.current.position.set(0, (1 - t1) * preMidPointGap!, 0);
preMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
inTracer.current.scale.set(1, 1 - 2 * Math.abs(t1 - 0.5), 1);
inTracer.current.position.set(0, (1 - t1) * inLength, 0);
if (!postMidPointTracer.current) return;
if (!outTracer.current) return;
postMidPointTracer.current.position.set(0, t2 * postMidPointGap!, 0);
postMidPointTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
});
useEffect(() => {
function resetShot() {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}
const unsubscribes = [
useDuel.subscribe((state) => state.protagonist?.tank, resetShot),
useDuel.subscribe((state) => state.antagonist?.tank, resetShot),
];
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
outTracer.current.scale.set(1, 1 - 2 * Math.abs(t2 - 0.5), 1);
outTracer.current.position.set(0, t2 * outLength, 0);
});
if (!shot) return null;
const inLength =
shot.in.layers.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance;
return accumulator;
}, 0) + LENGTH_INFINITY;
const outLength =
shot.out && shot.out.layers.length > 0
? (shot.in.layers.at(-1) as ShotLayerNonExternal).point.distanceTo(
(shot.out.layers.at(-1) as ShotLayerNonExternal).point,
)
: LENGTH_INFINITY;
const inLast = shot.in.layers.findLast(
(layer) => layer.type !== null,
) as ShotLayerNonExternal;
const outTitleColor = shot.out
? shotStatusColors[shot.out.status]
: undefined;
const inTitleColor = shotStatusColors[shot.in.status];
return (
<>
<group
position={midPointLayer.point}
rotation={normalToEuler(midPointLayer.shellNormal)}
<group>
<Html
position={inLast.point}
style={{
pointerEvents: 'none',
transform: 'translateY(-50%)',
}}
>
<mesh position={[0, preMidPointGap! / 2, 0]}>
<cylinderGeometry args={[TRACER_THIN, TRACER_THIN, preMidPointGap]} />
<meshBasicMaterial depthTest={false} />
<Card ml="9" style={{ width: 300 }}>
<Flex direction="column" gap="2">
<Flex direction="column" gap="1">
<Text color={inTitleColor} weight="bold">
{shot.in.status !== 'blocked' &&
shot.in.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.in.status]} `}
</Text>
<Flex direction="column">
{shot.in.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
const layerIndex =
layer.type === null
? undefined
: `${(shot.containsGaps ? index / 2 : index) + 1}.`;
const shotStatusColor =
shot.in.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Core ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</Flex>
{shot.out && shot.out.layers.length > 0 && (
<>
<Inset side="x">
<Flex align="center" gap="2">
<Separator
style={{
flex: 1,
}}
/>
<Text size="1" color="gray">
ricochet (-25% penetration)
</Text>
<Separator
style={{
flex: 1,
}}
/>
</Flex>
</Inset>
<Text color={outTitleColor} weight="bold">
{shot.out.status !== 'blocked' &&
shot.out.status !== 'ricochet' && (
<>
{Math.round(shot.damage).toLocaleString()}
<Text size="1">hp</Text>
</>
)}
{` ${shotStatusNames[shot.out.status]} `}
</Text>
<Flex direction="column">
{shot.out.layers.map((layer, index) => {
const layerName =
layer.type === ArmorType.External
? externalLayerNames[layer.variant]
: layerTypeNames[`${layer.type}`];
const shiftedIndex = index + shot.out!.layers.length;
const layerIndex =
layer.type === null
? undefined
: `${(shot.containsGaps ? shiftedIndex / 2 : shiftedIndex) + 1}.`;
const shotStatusColor =
shot.out!.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<LayerEntry
key={index}
layer={layer}
layerIndex={layerIndex}
layerName={layerName}
shotStatusColor={shotStatusColor}
angle={
layer.type === ArmorType.Core ||
layer.type === ArmorType.Spaced
? layer.angle
: undefined
}
/>
);
})}
</Flex>
</>
)}
</Flex>
</Card>
</Html>
{[...shot.in.layers, ...(shot.out?.layers ?? [])].map((layer, index) => {
if (layer.type === null) return null;
const shotStatusColor =
shot.in.status === 'splash'
? shotStatusColors.splash
: shotStatusColors[layer.status];
return (
<Html
position={layer.point}
key={index}
style={{
pointerEvents: 'none',
transform: 'translateY(-50%)',
}}
>
<Flex
style={{
marginLeft: -SURFACE_POINT_SIZE / 2,
}}
>
<div
style={{
width: SURFACE_POINT_SIZE,
height: SURFACE_POINT_SIZE,
backgroundColor: shotStatusColor,
opacity: 0.75,
borderRadius: SURFACE_POINT_SIZE / 2,
}}
/>
</Flex>
</Html>
);
})}
<group
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(J_HAT, shot.in.surfaceNormal),
)}
>
<mesh position={[0, inLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN / 2, TRACER_THIN / 2, inLength]}
/>
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh position={[0, preMidPointGap! / 2, 0]} ref={preMidPointTracer}>
<mesh ref={inTracer} position={[0, inLength / 2, 0]} renderOrder={3}>
<cylinderGeometry
args={[TRACER_THICK, TRACER_THICK, preMidPointGap]}
args={[TRACER_THICK / 2, TRACER_THICK / 2, inLength]}
/>
<meshBasicMaterial
color={
lastLayer.status === 'blocked'
? 0xff4040
: lastLayer.status === 'penetration'
? 0x00ff00
: 0xffff00
}
color={inTitleColor}
depthTest={false}
transparent
/>
</mesh>
</group>
{midPointLayer.status === 'ricochet' && (
{shot.out && (
<group
position={midPointLayer.point}
rotation={normalToEuler(
midPointLayer.shellNormal
.clone()
.reflect(midPointLayer.surfaceNormal)
.multiplyScalar(-1),
position={inLast.point}
rotation={new Euler().setFromQuaternion(
new Quaternion().setFromUnitVectors(
J_HAT,
shot.out.surfaceNormal.clone(),
),
)}
>
<mesh position={[0, postMidPointGap! / 2, 0]}>
<mesh position={[0, outLength / 2, 0]} renderOrder={2}>
<cylinderGeometry
args={[TRACER_THIN, TRACER_THIN, postMidPointGap]}
args={[TRACER_THIN / 2, TRACER_THIN / 2, outLength]}
/>
<meshBasicMaterial depthTest={false} />
<meshBasicMaterial depthTest={false} transparent />
</mesh>
<mesh
position={[0, postMidPointGap! / 2, 0]}
ref={postMidPointTracer}
ref={outTracer}
position={[0, outLength / 2, 0]}
renderOrder={3}
>
<cylinderGeometry
args={[TRACER_THICK, TRACER_THICK, postMidPointGap]}
args={[TRACER_THICK / 2, TRACER_THICK / 2, outLength]}
/>
<meshBasicMaterial
color={outTitleColor}
depthTest={false}
transparent
/>
<meshBasicMaterial color={0xffff00} />
</mesh>
</group>
)}
{shot.layers.map((layer, index) => {
if (layer.type === null) return;
const shellRotation = normalToEuler(layer.shellNormal);
const nextLayer = shot.layers[index + 1] as ShotLayerGap | undefined;
const trueStatus =
nextLayer?.status === 'blocked' ? 'blocked' : layer.status;
return (
<>
<group
key={`${index}-line`}
position={layer.point}
rotation={shellRotation}
>
<Html
center
style={{
pointerEvents: 'none',
}}
>
<div
style={{
backgroundColor:
trueStatus === 'penetration'
? '#00ff0080'
: trueStatus === 'blocked'
? '#ff000080'
: '#ffff0080',
borderRadius: 8,
border: 'none',
padding: 4,
width: hasMultipleLayers ? undefined : 8,
height: hasMultipleLayers ? undefined : 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{hasMultipleLayers && (
<Text
size="2"
style={{
width: '100%',
display: 'block',
textAlign: 'center',
color: trueStatus === 'ricochet' ? 'black' : undefined,
}}
>
<b>{layer.index + 1}</b>
</Text>
)}
</div>
</Html>
</group>
</>
);
})}
<Html
position={midPointLayer.point}
style={{
transform: 'translateY(-50%)',
marginLeft: 32,
pointerEvents: 'none',
}}
>
<Card style={{ width: 256 + 32 }}>
<Flex direction="column">
<Text>
{lastLayer.status[0].toUpperCase()}
{lastLayer.status.slice(1)}
{lastLayer.status === 'penetration' &&
`: ${Math.round(shell.damage.armor)}hp`}
{lastLayer.status === 'blocked' &&
shell.type === 'he' &&
`: ${NaNFallback(
Math.max(
0,
Math.round(
0.5 *
shell.damage.armor *
(1 -
distanceFromSpacedArmor! / shell.explosionRadius!) -
1.1 *
((midPointLayer as ShotLayerNonExternal)
.thicknessAngled +
preMidPointThickness!),
),
),
0,
)}hp`}
</Text>
{shot.layers.map((layer, index) => {
if (layer.type === null && (!explosive || splashing)) return null;
return (
<Flex key={index} align="center" gap="2">
<Flex>
{hasMultipleLayers && (
<Text size="2" style={{ width: 16 }}>
{layer.type === null ? null : `${layer.index + 1}.`}
</Text>
)}
<Text size="2">{layerTypeNames[`${layer.type}`]}</Text>
</Flex>
{layer.type !== null && (
<>
<Flex align="center" style={{ width: 64 }}>
<NominalPenetration width={16} height={16} />
<Text size="2">{Math.round(layer.thickness)}mm</Text>
</Flex>
{layer.type !== ArmorType.External && (
<Flex align="center">
{layer.status === 'ricochet' ? (
<Ricochet width={16} height={16} />
) : layer.status === 'blocked' ? (
<Block width={16} height={16} />
) : (
<AngledPenetration width={16} height={16} />
)}
<Text size="2">
{Math.round(layer.thicknessAngled)}mm (
{Math.round(radToDeg(layer.angle))}°)
</Text>
</Flex>
)}
</>
)}
{layer.type === null && (
<Text size="2">
{Math.round(layer.distance * 1000)}mm (-
{Math.round(Math.min(100, layer.distance * 50))}%
penetration)
</Text>
)}
</Flex>
);
})}
</Flex>
</Card>
</Html>
</>
</group>
);
}
@@ -259,9 +259,10 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
</group>
{modelNodes.map((node) => {
const isCurrentGun = node.name.startsWith(
`gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
const gunString = `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
const isCurrentGun = gunModelDefinition.mask
? node.name.startsWith(gunString)
: node.name === gunString;
const isVisible = isCurrentGun;
if (!isVisible) return null;
@@ -7,8 +7,8 @@ import {
ShaderMaterial,
} from 'three';
import { ArmorUserData, ExternalModuleVariant } from '../..';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
@@ -62,22 +62,22 @@ export function SpacedArmorSubExternal({
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = useDuel.getState();
const penetration = resolveNearPenetration(
duel.antagonist!.shell.penetration,
);
const shell = duel.antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
: penetration;
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(useDuel.getState().antagonist!.shell);
@@ -9,6 +9,7 @@ import { degToRad } from 'three/src/math/MathUtils';
import { ArmorUserData } from '../..';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../../../core/blitzkit/jsxTree';
import { ShellDefinition } from '../../../../../../core/blitzkit/tankDefinitions';
@@ -54,7 +55,9 @@ export function SpacedArmorSubSpaced({
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.ricochet.value = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
material.uniforms.normalization.value = degToRad(
shell.normalization ?? 0,
);
@@ -69,7 +72,7 @@ export function SpacedArmorSubSpaced({
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04
? thickness * 1.03
: thickness;
}
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
@@ -81,9 +84,10 @@ export function SpacedArmorSubSpaced({
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
: penetration;
material.uniforms.penetration.value =
penetration *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
}
handleShellChange(useDuel.getState().antagonist!.shell);
@@ -10,16 +10,17 @@ import {
Vector3,
} from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
import { resolvePenetrationCoefficient } from '../../../../core/blitz/resolvePenetrationCoefficient';
import { hasEquipment } from '../../../../core/blitzkit/hasEquipment';
import { jsxTree } from '../../../../core/blitzkit/jsxTree';
import { ShellType } from '../../../../core/blitzkit/tankDefinitions';
import { useDuel } from '../../../../stores/duel';
import {
Shot,
ShotLayerBase,
useTankopediaPersistent,
ShotLayerNonExternal,
useTankopediaTemporary,
} from '../../../../stores/tankopedia';
import { ArmorType } from '../SpacedArmorScene';
@@ -78,6 +79,275 @@ export function SpacedArmorSceneComponent({
}: SpacedArmorSceneComponentProps) {
const camera = useThree((state) => state.camera);
async function shoot(
point: Vector3,
intersections: Intersection[],
allowRicochet: boolean,
remainingPenetrationInput?: number,
) {
const { antagonist, protagonist } = useDuel.getState();
const shell = antagonist!.shell;
const cameraNormal = camera.position.clone().sub(point).normalize();
const shot: Shot = {
damage: -1,
containsGaps: shell.type === ShellType.HEAT,
in: {
surfaceNormal: cameraNormal,
layers: [],
status: 'blocked',
},
};
const hasCalibratedShells = await hasEquipment(
103,
antagonist!.tank.equipment,
antagonist!.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
110,
protagonist!.tank.equipment,
protagonist!.equipmentMatrix,
);
const penetration =
resolveNearPenetration(shell.penetration) *
resolvePenetrationCoefficient(hasCalibratedShells, shell.type);
const thicknessCoefficient = hasEnhancedArmor ? 1.03 : 1;
const filteredIntersections = intersections.filter(
(intersection) => 'type' in intersection.object.userData,
) as unknown as Intersection<Object3D & { userData: ArmorUserData }>[];
const encounteredExternalModuleVariants: ExternalModuleVariant[] = [];
const noDuplicateIntersections: typeof filteredIntersections = [];
for (const intersection of filteredIntersections) {
if (intersection.object.userData.type === ArmorType.External) {
if (
!encounteredExternalModuleVariants.includes(
intersection.object.userData.variant,
)
) {
noDuplicateIntersections.push(intersection);
encounteredExternalModuleVariants.push(
intersection.object.userData.variant,
);
}
} else {
noDuplicateIntersections.push(intersection);
if (intersection.object.userData.type === ArmorType.Core) {
break;
}
}
}
let remainingPenetration = remainingPenetrationInput ?? penetration;
let index = 0;
for (const intersection of noDuplicateIntersections) {
const layer = intersection.object.userData;
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
intersection.object.getWorldQuaternion(new Quaternion()),
);
if (shell.type === ShellType.HEAT && index > 0) {
const lastLayer = shot.in.layers.at(-1) as ShotLayerBase;
const distance = lastLayer.point.distanceTo(intersection.point);
remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
type: null,
distance,
status: blocked ? 'blocked' : 'penetration',
});
if (blocked) break;
}
if (layer.type === ArmorType.External) {
const thickness = layer.thickness * thicknessCoefficient;
remainingPenetration -= thickness;
const blocked =
remainingPenetration <= 0 || shell.type === ShellType.HE;
shot.in.layers.push({
type: ArmorType.External,
index,
thickness,
point: intersection.point,
surfaceNormal,
status:
shell.type === ShellType.HE
? 'blocked'
: blocked
? 'blocked'
: 'penetration',
variant: layer.variant,
});
} else {
const thickness = layer.thickness;
const ricochet = degToRad(
isExplosive(shell.type) ? 90 : shell.ricochet!,
);
const angle = surfaceNormal.angleTo(cameraNormal);
const threeCalibersRule =
shell.caliber > thickness * 3 || index > 0 || !allowRicochet;
const twoCalibersRule = shell.caliber > thickness * 2;
const normalization = degToRad(shell.normalization ?? 0);
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) / (2.0 * thickness)
: normalization;
const finalThickness =
thickness / Math.cos(Math.max(0, angle - finalNormalization));
if (!threeCalibersRule && angle >= ricochet) {
remainingPenetration *= 0.75;
shot.in.layers.push({
type: layer.type,
index,
thickness,
thicknessAngled: finalThickness,
point: intersection.point,
surfaceNormal,
status: 'ricochet',
angle,
});
break;
} else {
remainingPenetration -= finalThickness;
const blocked = remainingPenetration <= 0;
shot.in.layers.push({
type: layer.type,
index,
thickness,
thicknessAngled: finalThickness,
point: intersection.point,
surfaceNormal: intersection.face!.normal,
status:
shell.type === ShellType.HE && layer.type !== ArmorType.Core
? 'blocked'
: blocked
? 'blocked'
: 'penetration',
angle,
});
}
}
if (shell.type !== ShellType.HE && remainingPenetration <= 0) {
break;
}
index++;
}
const lastLayer = shot.in.layers.at(-1) as ShotLayerNonExternal | undefined;
const firstLayer = shot.in.layers[0] as ShotLayerBase | undefined;
if (!lastLayer || !firstLayer) {
return null;
} else {
if (shell.type === ShellType.HE) {
const totalSpacedArmorThickness = shot.in.layers.reduce(
(acc, layer) => {
if (layer.type === null || layer.type === ArmorType.Core) {
return acc;
}
if (layer.type === ArmorType.External) {
return acc + layer.thickness;
}
return acc + layer.thicknessAngled;
},
0,
);
const distanceFromSpacedArmor = firstLayer.point.distanceTo(
lastLayer.point,
);
const finalDamage = Math.max(
0,
0.5 *
shell.damage.armor *
(1 - distanceFromSpacedArmor / shell.explosionRadius!) -
1.1 *
(lastLayer.thicknessAngled +
Math.min(penetration, totalSpacedArmorThickness)),
);
/**
* Above is some peculiar math. We're multiplying 1.1 by the
* thickness of the core plate plus the total spaced armor
* thickness capped to the penetration itself. This is because
* the shader is normalized by the penetration value; hence,
* the total spaced armor thickness cannot ever be thicker
* than the penetration. This is a precision error on the
* shader-side and I do not intend on fixing it. Instead,
* for parity's sake, I am capping the value here.
*/
if (shot.in.layers.length > 1 || lastLayer.status === 'blocked') {
shot.in.status = finalDamage > 0 ? 'splash' : 'blocked';
shot.damage = finalDamage;
} else {
shot.in.status = 'penetration';
shot.damage = shell.damage.armor;
}
} else {
if (lastLayer.status === 'blocked') {
shot.in.status = 'blocked';
shot.damage = 0;
} else if (lastLayer.status === 'penetration') {
shot.in.status = 'penetration';
shot.damage = shell.damage.armor;
} else {
const caster = new Raycaster();
// https://math.stackexchange.com/a/13263/1222875
const shellNormal = cameraNormal.clone().multiplyScalar(-1);
const ricochetNormal = shellNormal
.clone()
.sub(
lastLayer.surfaceNormal
.clone()
.multiplyScalar(2 * shellNormal.dot(lastLayer.surfaceNormal)),
);
caster.set(lastLayer.point, ricochetNormal);
const ricochetIntersections = caster.intersectObjects(
scene.children,
true,
);
const outShot = await shoot(
lastLayer.point,
ricochetIntersections,
false,
remainingPenetration,
);
shot.in.status = 'ricochet';
if (outShot === null) {
shot.damage = 0;
shot.out = {
surfaceNormal: ricochetNormal,
layers: [],
status: 'ricochet',
};
} else {
shot.damage = outShot.damage;
shot.out = outShot.in;
shot.out.surfaceNormal = ricochetNormal;
}
}
}
}
return shot;
}
return (
<>
{props.type === ArmorType.Core &&
@@ -94,246 +364,11 @@ export function SpacedArmorSceneComponent({
async onClick(event) {
event.stopPropagation();
const duel = useDuel.getState();
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
duel.antagonist!.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
const shell = useDuel.getState().antagonist!.shell;
const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const penetration =
resolveNearPenetration(shell.penetration) *
(hasCalibratedShells
? isExplosive(shell.type)
? 1.1
: 1.05
: 1);
const shot: Shot = {
layers: [],
point: event.point,
}; // TODO: type this
let remainingPenetration = penetration;
const shellNormal = camera.position
.clone()
.sub(event.point)
.normalize();
pushLogs(
shellNormal,
filterIntersections(event.intersections),
0,
);
function filterIntersections(intersectionsRaw: Intersection[]) {
const showSpacedArmor =
useTankopediaPersistent.getState().model.visual
.showSpacedArmor;
const intersectionsTyped = intersectionsRaw.filter(
({ object }) => object.userData.type !== undefined,
) as ArmorMeshIntersection[];
const intersectionsFiltered = intersectionsTyped.filter(
({ object }) =>
showSpacedArmor
? true
: object.userData.type === ArmorType.Core,
);
const intersectionsWithDuplicates = intersectionsFiltered.slice(
0,
intersectionsFiltered.findIndex(
({ object }) => object.userData.type === ArmorType.Core,
) + 1,
);
const firstGunIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === ArmorType.External &&
object.userData.variant === 'gun',
);
const firstTrackIntersectionIndex =
intersectionsWithDuplicates.findIndex(
({ object }) =>
object.userData.type === ArmorType.External &&
object.userData.variant === 'track',
);
let intersections = intersectionsWithDuplicates;
if (firstGunIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === ArmorType.External &&
object.userData.variant === 'gun'
? index === firstGunIntersectionIndex
: true;
});
}
if (firstTrackIntersectionIndex !== -1) {
intersections = intersections.filter(({ object }, index) => {
return object.userData.type === ArmorType.External &&
object.userData.variant === 'track'
? index === firstTrackIntersectionIndex
: true;
});
}
return intersections;
}
function pushLogs(
shellNormal: Vector3,
intersections: ArmorMeshIntersection[],
startingIndex: number,
) {
let layerIndex = startingIndex;
let loopIndex = 0;
if (layerIndex !== 0 && intersections.length > 0) {
const distance = (
shot.layers.at(-1) as ShotLayerBase
).point.distanceTo(intersections[0].point);
if (explosive && !splashing)
remainingPenetration -=
0.5 * remainingPenetration * distance;
const blocked = remainingPenetration < 0;
shot.layers.push({
type: null,
status: blocked ? 'blocked' : 'penetration',
distance,
});
if (blocked) return;
}
for (const intersection of intersections) {
const surfaceNormal = intersection
.normal!.clone()
.applyQuaternion(
event.object.getWorldQuaternion(new Quaternion()),
);
const thickness =
thicknessCoefficient *
intersection.object.userData.thickness;
if (layerIndex !== 0 && startingIndex === 0) {
const previousIntersection = intersections[layerIndex - 1];
const distance =
intersection.distance - previousIntersection.distance;
if (explosive && !splashing)
remainingPenetration -=
0.5 * remainingPenetration * distance;
const wasted = remainingPenetration < 0;
shot.layers.push({
type: null,
status: wasted ? 'blocked' : 'penetration',
distance,
});
if (wasted && !splashing) return;
}
if (
intersection.object.userData.type === ArmorType.External
) {
remainingPenetration -= thickness;
const blocked = remainingPenetration < 0;
shot.layers.push({
type: ArmorType.External,
index: layerIndex,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness: intersection.object.userData.thickness,
status: blocked ? 'blocked' : 'penetration',
});
if (splashing) {
remainingPenetration = Math.max(0, remainingPenetration);
} else if (blocked) {
break;
}
} else {
const type = intersection.object.userData.type;
const angle = surfaceNormal.angleTo(shellNormal);
const ricochet = degToRad(shell.ricochet ?? 90);
const normalization = degToRad(shell.normalization ?? 0);
const threeCalibersRule =
shell.caliber > thickness * 3 || layerIndex > 0;
const twoCalibersRule = shell.caliber > thickness * 2;
const finalNormalization = twoCalibersRule
? (1.4 * normalization * shell.caliber) /
(2.0 * thickness)
: normalization;
const finalThickness =
thickness === 0
? 0
: thickness /
Math.cos(Math.max(0, angle - finalNormalization));
if (!threeCalibersRule && angle >= ricochet) {
shot.layers.push({
type,
index: layerIndex,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness,
thicknessAngled: finalThickness,
angle,
status: 'ricochet',
});
remainingPenetration *= 0.75;
const raycaster = new Raycaster();
const newShellNormal = shellNormal
.clone()
.reflect(surfaceNormal);
const newCameraNormal = newShellNormal
.clone()
.multiplyScalar(-1);
raycaster.set(intersection.point, newCameraNormal);
pushLogs(
newShellNormal,
filterIntersections(
raycaster.intersectObjects(scene.children, true),
),
layerIndex + 1,
);
break;
} else {
remainingPenetration -= finalThickness;
const blocked = remainingPenetration < 0;
shot.layers.push({
type,
index: layerIndex,
shellNormal,
surfaceNormal,
point: intersection.point,
thickness,
thicknessAngled: finalThickness,
angle,
status: blocked ? 'blocked' : 'penetration',
});
}
}
layerIndex++;
loopIndex++;
}
}
const shot = (await shoot(
event.point,
event.intersections,
true,
))!;
useTankopediaTemporary.setState({ shot });
},
@@ -69,6 +69,7 @@ export function Row({
>
{type === 'tank' && tankType !== undefined && (
<img
alt={tankType}
src={TREE_TYPE_ICONS[treeType ?? 'researchable'][tankType]}
style={{ width: 16, height: 16 }}
/>
@@ -48,6 +48,7 @@ export function RowStat({
>
{percentile === undefined && delta !== undefined && delta !== 0 && (
<img
alt={delta > 0 ? 'Increase' : 'Decrease'}
src={
(delta ?? 0) > 0
? 'https://i.imgur.com/qbjiXa1.png'
+9 -2
View File
@@ -24,7 +24,14 @@ export function ConsumablesManager({
hasHighEndConsumables,
}: ConsumablesManagerProps) {
return (
<Flex wrap="wrap">
<Flex
wrap="wrap"
style={{
maxWidth: 288,
borderRadius: 4,
overflow: 'hidden',
}}
>
{consumables.map((consumable, index) => {
const isSelected = selected.some((id) => id === consumable.id);
@@ -41,7 +48,7 @@ export function ConsumablesManager({
cooldown={
timers
? consumable.cooldown *
(hasConsumableDeliverySystem ? 0.85 : 1) *
(hasConsumableDeliverySystem ? 0.88 : 1) *
(1 - cooldownBooster! * 0.1)
: undefined
}
+1
View File
@@ -5,6 +5,7 @@ interface DeltaCaretProps {
export function DeltaCaret({ delta }: DeltaCaretProps) {
return (
<img
alt={delta > 0 ? 'Increase' : 'Decrease'}
style={{
width: 16,
height: 16,
+1 -1
View File
@@ -13,7 +13,7 @@ export function Flag({ nation }: FlagProps) {
overflow: 'hidden',
}}
>
<img src={asset(`flags/circle/${nation}.webp`)} alt={nation} />
<img alt={nation} src={asset(`flags/circle/${nation}.webp`)} />
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { style } from '@vanilla-extract/css';
export const containerInner = style({
justifyContent: 'space-between',
width: '100%',
maxWidth: 1024,
padding: '0 16px',
'@media': {
'screen and (max-width: 640px)': {
justifyContent: 'center',
},
},
});
+86 -53
View File
@@ -1,70 +1,103 @@
import { Card, Code, Flex, Link, Spinner, Text, Theme } from '@radix-ui/themes';
import { Suspense } from 'react';
import packageJSON from '../../../package.json';
import { useWideFormat } from '../../hooks/useWideFormat';
import { WoTBVersion } from './components/WoTBVersion';
'use client';
import { Flex, Heading, Text, Theme } from '@radix-ui/themes';
import packageJson from '../../../package.json';
import { Link } from '../Link';
export function Footer() {
const wideFormat = useWideFormat(640);
return (
<Theme radius="none">
<Card>
<Flex justify="center" align="center" gap="3">
<Flex
align="center"
justify={wideFormat ? 'between' : 'center'}
wrap="wrap"
style={{
width: '100%',
maxWidth: 800,
padding: '0 16px',
}}
>
<Flex gap="1" justify="center" align="center">
<Text color="gray" size="2">
BlitzKit
<Flex
justify="center"
gap="3"
p="6"
style={{
backgroundColor: 'var(--color-panel)',
}}
>
<Flex
align="center"
justify="between"
maxWidth="640px"
flexGrow="1"
gap="6"
position="relative"
direction={{
initial: 'column',
sm: 'row',
}}
>
<Flex direction="column">
<Heading>
BlitzKit{' '}
<Text color="gray" size="1" weight="regular">
{packageJson.version}
</Text>
</Heading>
<Code color="gray" size="2">
{packageJSON.version}
</Code>
<Text color="gray" size="2">
for WoTB
</Text>
<Suspense fallback={<Spinner />}>
<WoTBVersion />
</Suspense>
</Flex>
<Text color="gray">Everything World of Tanks Blitz</Text>
</Flex>
<Flex gap="6">
<Flex
justify="center"
align="center"
gap="2"
style={{
maxWidth: 800,
padding: '0 16px',
direction="column"
gap="1"
align={{
initial: 'end',
sm: 'start',
}}
>
<Link size="2" href="https://tresabhi.github.io/" target="_blank">
Made by TrèsAbhi
</Link>
<Text color="gray">•</Text>
<Link
size="2"
href="https://tresabhi.github.io/blitzkit/guide/credits.html"
target="_blank"
<Heading size="3">About</Heading>
<Flex
direction="column"
align={{
initial: 'end',
sm: 'start',
}}
>
Full credits
</Link>
<Link href="/docs/about/credits" color="gray">
Credits
</Link>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
color="gray"
>
Discord
</Link>
<Link
href="https://github.com/tresabhi/blitzkit"
target="_blank"
color="gray"
>
GitHub
</Link>
{/* patreon supporters? */}
</Flex>
</Flex>
<Flex direction="column" gap="1">
<Heading size="3">Legal</Heading>
<Flex direction="column">
<Link href="/docs/legal/privacy-policy" color="gray">
Privacy policy
</Link>
<Link href="/docs/legal/terms-of-service" color="gray">
Terms of service
</Link>
<Link
href="https://www.google.com/settings/ads"
target="_blank"
color="gray"
>
Ad settings
</Link>
</Flex>
</Flex>
</Flex>
</Flex>
</Card>
</Flex>
</Theme>
);
}
@@ -90,6 +90,7 @@ export function Item({
}}
>
<img
alt={deltaPosition > 0 ? 'Increase' : 'Decrease'}
style={{
width: 8,
height: 8,
@@ -139,6 +140,7 @@ export function Item({
)}
{deltaScore !== 0 && deltaScore !== undefined && (
<img
alt={deltaScore > 0 ? 'Increase' : 'Decrease'}
style={{
width: 8,
height: 8,
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { LinkProps, Link as RadixLink } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
export function Link({ href, onClick, ...props }: LinkProps) {
const router = useRouter();
return (
<RadixLink
href={href}
onClick={(event) => {
event.preventDefault();
if (href) router.push(href);
onClick?.(event);
}}
{...props}
/>
);
}
@@ -23,6 +23,11 @@ export function ConsumableButton({
return (
<GenericTankComponentButton
icon={asset(`icons/consumables/${consumable}.webp`)}
style={{
flex: 1,
minWidth: 'fit-content',
borderRadius: 0,
}}
{...props}
>
{(cooldown || duration) && (
@@ -25,6 +25,7 @@ export function GenericTankComponentButton({
}}
>
<img
alt={icon}
draggable={false}
src={icon}
style={{
+3 -3
View File
@@ -63,7 +63,7 @@ export function ModuleManager({
const draft = { ...modules };
draft.turret = turret;
draft.gun = draft.turret.guns.at(-1)!;
draft.shell = draft.gun.shells.at(-1)!;
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setTurretMenuOpen(false);
@@ -106,7 +106,7 @@ export function ModuleManager({
onClick={() => {
const draft = { ...modules };
draft.gun = gun;
draft.shell = draft.gun.shells.at(-1)!;
draft.shell = draft.gun.shells[0];
onChange?.(draft);
setGunMenuOpen(false);
@@ -115,7 +115,7 @@ export function ModuleManager({
<ModuleButton
variant="ghost"
module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>{' '}
{gun.name}
</DropdownMenu.RadioItem>
@@ -33,7 +33,7 @@ export function EverythingSearch({
width: '100%',
zIndex: 1,
}}
></Card>
/>
</TextField.Slot>
)}
</TextField.Root>
+54
View File
@@ -0,0 +1,54 @@
import { style, styleVariants } from '@vanilla-extract/css';
export const hamburgerButton = style({
'@media': {
'screen and (min-width: 592px)': {
display: 'none',
},
},
});
const navbarBase = style({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
zIndex: 2,
padding: '1rem',
backgroundColor: 'var(--color-panel-translucent)',
backdropFilter: 'blur(4rem)',
WebkitBackdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
transition: 'max-height 0.2s ease-in-out',
overflow: 'hidden',
});
export const navbar = styleVariants({
expanded: [
navbarBase,
{
maxHeight: '100%',
},
],
collapsed: [
navbarBase,
{
maxHeight: 65,
},
],
});
export const toolCard = style({
boxShadow: `inset 0 -200px 128px -128px var(--color-overlay)`,
':hover': {
boxShadow: `inset 0 -160px 128px -128px #ffffff00`,
},
});
export const toolTexts = style({
'@media': {
'screen and (max-width: 592px)': {
display: 'none',
},
},
});
+180 -185
View File
@@ -1,234 +1,229 @@
import {
Cross1Icon,
DiscordLogoIcon,
GearIcon,
HamburgerMenuIcon,
PersonIcon,
} from '@radix-ui/react-icons';
import {
Box,
Button,
Dialog,
Flex,
Heading,
IconButton,
Link as LinkRadix,
Popover,
Spinner,
Text,
} from '@radix-ui/themes';
import Link from 'next/link';
import { Suspense, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { Suspense, useState } from 'react';
import { REGIONS, UNLOCALIZED_REGION_NAMES } from '../../constants/regions';
import { TOOLS } from '../../constants/tools';
import { authURL } from '../../core/blitz/authURL';
import { extendAuth } from '../../core/blitz/extendAuth';
import { logout } from '../../core/blitz/logout';
import { useFullScreen } from '../../hooks/useFullScreen';
import { useWideFormat } from '../../hooks/useWideFormat';
import { BlitzkitWide } from '../../icons/BlitzkitWide';
import { PatreonIcon } from '../../icons/Patreon';
import { theme } from '../../stitches.config';
import { useApp } from '../../stores/app';
import { Link } from '../Link';
import { LoggedIn } from './components/LoggedIn';
import * as styles from './index.css';
export const NAVBAR_HEIGHT = 64;
export default function Navbar() {
const isFullScreen = useFullScreen();
const wideFormat = useWideFormat(480);
const login = useApp((state) => state.login);
useEffect(() => {
if (!login) return;
const expiresIn = login.expiresAt - Date.now() / 1000;
const expiresInDays = expiresIn / 60 / 60 / 24;
if (expiresInDays < 0) {
logout();
} else if (expiresInDays < 7) {
extendAuth();
}
});
if (isFullScreen) return null;
const plugs = (
<>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DiscordLogoIcon width={16} height={16} />
</Link>
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link>
</>
);
const [showHamburgerMenu, setShowHamburgerMenu] = useState(false);
const pathname = usePathname();
const login = useApp((state) => state.login!);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: 64 + 1, // extra pixel for frost bleeding
zIndex: 1,
padding: '1rem',
background: theme.colors.appBackground1_alpha,
borderBottom: theme.borderStyles.nonInteractive,
backdropFilter: 'blur(4rem)',
boxSizing: 'border-box',
}}
<Box
className={styles.navbar[showHamburgerMenu ? 'expanded' : 'collapsed']}
>
<Flex
justify="center"
style={{
height: '100%',
}}
>
<Flex direction="column" align="center" pt="2">
<Flex
justify="between"
align="center"
style={{ maxWidth: 800, width: '100%', padding: '0 16px' }}
gap="4"
style={{ maxWidth: 1024, width: '100%', padding: '0 16px' }}
gap="8"
>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<HamburgerMenuIcon />
</IconButton>
</Popover.Trigger>
<Flex align="center" gap="4">
<IconButton
className={styles.hamburgerButton}
size="4"
variant="ghost"
color="gray"
onClick={() => setShowHamburgerMenu((state) => !state)}
>
{showHamburgerMenu ? <Cross1Icon /> : <HamburgerMenuIcon />}
</IconButton>
<Popover.Content>
<Flex direction="column" gap="4">
{!wideFormat && <Flex gap="2">{plugs}</Flex>}
<Link
onClick={() => setShowHamburgerMenu(false)}
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BlitzkitWide />
</Link>
</Flex>
<Flex direction="column" gap="2">
<Heading size="4">Tools</Heading>
<Flex justify="center" gap="4" className={styles.toolTexts}>
{TOOLS.filter((tool) => !tool.href).map((tool) => {
const selected = pathname.startsWith(`/tools/${tool.id}`);
<Flex direction="column" gap="1" pl="4">
{TOOLS.map((tool, index) => {
if (tool.href || tool.disabled) return null;
return (
<Link
key={tool.id}
color="gray"
size="2"
highContrast={selected}
underline={selected ? 'always' : 'hover'}
href={`/tools/${tool.id}`}
>
{tool.title}
</Link>
);
})}
</Flex>
return (
<LinkRadix
key={tool.id}
tabIndex={-1}
href={`/tools/${tool.id}`}
onClick={(event) => event.preventDefault()}
>
<Link
href={`/tools/${tool.id}`}
key={index}
style={{
textDecoration: 'none',
color: 'inherit',
}}
>
{tool.title}
</Link>
</LinkRadix>
);
})}
</Flex>
</Flex>
</Flex>
</Popover.Content>
</Popover.Root>
<Flex align="center" gap="4">
<Link
onClick={() => setShowHamburgerMenu(false)}
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DiscordLogoIcon width={16} height={16} />
</Link>
<Link
href="/"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BlitzkitWide />
</Link>
<Link
onClick={() => setShowHamburgerMenu(false)}
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link>
<div style={{ flex: 1 }} />
{wideFormat && plugs}
<Link href="/settings">
<Flex style={{ width: '100%', height: '100%' }} justify="center">
<IconButton variant="ghost" color="gray">
<GearIcon />
</IconButton>
</Flex>
</Link>
{login && (
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost" color="gray">
<PersonIcon />
<Link href="/settings" onClick={() => setShowHamburgerMenu(false)}>
<Flex style={{ width: '100%', height: '100%' }} justify="center">
<IconButton size="4" variant="ghost" color="gray">
<GearIcon />
</IconButton>
</Popover.Trigger>
</Flex>
</Link>
<Popover.Content align="center">
<Flex direction="column" gap="2">
<Suspense fallback={<Spinner />}>
<LoggedIn />
</Suspense>
</Flex>
</Popover.Content>
</Popover.Root>
)}
{login && (
<Popover.Root>
<Popover.Trigger>
<IconButton size="4" variant="ghost" color="gray">
<PersonIcon />
</IconButton>
</Popover.Trigger>
{/* TODO: re-enable in the future */}
{!login && false && (
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">Log in</Button>
</Dialog.Trigger>
<Popover.Content align="center">
<Flex direction="column" gap="2">
<Suspense fallback={<Spinner />}>
<LoggedIn />
</Suspense>
</Flex>
</Popover.Content>
</Popover.Root>
)}
<Dialog.Content
{!login && false && (
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">Log in</Button>
</Dialog.Trigger>
<Dialog.Content
style={{
maxWidth: 360,
}}
>
<Flex direction="column" gap="2" align="center">
<Heading size="3">Choose your region</Heading>
<Flex gap="4" align="center" justify="center" wrap="wrap">
{REGIONS.map((region) => (
<Link
onClick={() => setShowHamburgerMenu(false)}
key={region}
href={authURL(
region,
typeof window === 'undefined'
? undefined
: location.href,
)}
>
{UNLOCALIZED_REGION_NAMES[region]}
</Link>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
</Flex>
<Flex mt="6" gap="3" justify="center" wrap="wrap">
{TOOLS.filter((tool) => !tool.href).map((tool) => (
<Link
key={tool.id}
href={`/tools/${tool.id}`}
className={styles.toolCard}
style={{
backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
cursor: tool.disabled ? 'default' : 'pointer',
opacity: tool.disabled ? 0.25 : 1,
height: 64,
minWidth: 176,
flex: 1,
borderRadius: 8,
textDecoration: 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: 8,
transition: `box-shadow ${theme.durations.regular}`,
}}
onClick={() => setShowHamburgerMenu(false)}
>
<Text
style={{
maxWidth: 360,
color: theme.colors.textHighContrast,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Flex direction="column" gap="2" align="center">
<Heading size="3">Choose your region</Heading>
<Flex gap="4" align="center" justify="center" wrap="wrap">
{REGIONS.map((region) => (
<LinkRadix
key={region}
href={authURL(
region,
typeof window === 'undefined'
? undefined
: location.href,
)}
>
{UNLOCALIZED_REGION_NAMES[region]}
</LinkRadix>
))}
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
)}
{tool.title}
</Text>
</Link>
))}
</Flex>
</Flex>
</div>
</Box>
);
}
+13 -3
View File
@@ -4,6 +4,10 @@ import { ComponentProps } from 'react';
type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor'];
size?: number | string;
noPadding?: boolean;
noMaxWidth?: boolean;
containerProps?: ComponentProps<typeof Theme>;
noFlex1?: boolean;
};
export default function PageWrapper({
@@ -11,25 +15,31 @@ export default function PageWrapper({
color,
size = 800,
children,
noPadding = false,
noMaxWidth = false,
noFlex1 = false,
containerProps,
...props
}: PageWrapperProps) {
return (
<Theme
accentColor={color}
style={{
flex: 1,
flex: noFlex1 ? undefined : 1,
display: 'flex',
flexDirection: 'column',
...containerProps?.style,
}}
{...containerProps}
>
<Flex
direction="column"
gap="4"
style={{
width: '100%',
maxWidth: size,
maxWidth: noMaxWidth ? undefined : size,
margin: 'auto',
padding: 16,
padding: noPadding ? 0 : 16,
boxSizing: 'border-box',
flex: 1,
...style,
+9 -7
View File
@@ -1,3 +1,5 @@
'use client';
import { useEffect } from 'react';
export function Party3() {
@@ -10,7 +12,7 @@ export function Party3() {
{/* analytics */}
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-F901X282X7"
src="https://www.googletagmanager.com/gtag/js?id=G-GL2JVHCGPQ"
/>
<script
dangerouslySetInnerHTML={{
@@ -19,24 +21,24 @@ export function Party3() {
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-D8DT6EWVFJ');
gtag('config', 'G-GL2JVHCGPQ');
`,
}}
/>
{/* adsense */}
<script
{/* <script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1289406790225052"
crossOrigin="anonymous"
/>
/> */}
{/* pub center */}
{/* <script
<script
async
src="https://adsdk.microsoft.com/pubcenter/sdk.js?siteId=364477&publisherId=253617120"
src="https://adsdk.microsoft.com/pubcenter/sdk.js?siteId=364600&publisherId=253617120"
crossOrigin="anonymous"
/> */}
/>
</>
);
}
+1
View File
@@ -18,6 +18,7 @@ export function SmallTankIcon({ id, size = HEIGHT }: SmallTankIconProps) {
}}
>
<img
alt={`Tank ${id}`}
src={asset(`icons/tanks/small/${id}.webp`)}
style={{
transform: `scale(${(size / HEIGHT) * 100}%)`,
@@ -43,6 +43,7 @@ export function Item({ tankType, name, treeType }: ItemProps) {
>
{tankType && (
<img
alt={tankType}
src={TREE_TYPE_ICONS[treeType][tankType]}
style={{ width: 14, height: 14 }}
/>
@@ -61,6 +62,7 @@ export function Item({ tankType, name, treeType }: ItemProps) {
{/* {image && (
// TODO: remove hardcoded dimensions when satori fixes non-width unloadable images
<img
alt={name}
src={image}
width={106}
height={32}
+1
View File
@@ -28,6 +28,7 @@ export default function TitleBar({ title, image, description }: TitleBarProps) {
>
{image && (
<img
alt={title}
style={{ width: 32, height: 32, objectFit: 'contain' }}
src={image}
/>
@@ -1,10 +1,19 @@
import { Button } from '@radix-ui/themes';
import { ComponentProps } from 'react';
interface Tool {
id: string;
image: string;
title: string;
description: string;
disabled?: boolean;
href?: string;
pageDescription?: string;
button: {
text: string;
color: ComponentProps<typeof Button>['color'];
};
significant?: boolean;
}
export const TOOLS: Tool[] = [
@@ -15,6 +24,7 @@ export const TOOLS: Tool[] = [
// pageDescription:
// 'Periodical player statistics and tanks played in World of Tanks Blitz',
// disabled: true,
// image: '8gJGkKP'
// },
{
id: 'tankopedia',
@@ -22,38 +32,70 @@ export const TOOLS: Tool[] = [
description: 'Blitz tank encyclopedia',
pageDescription:
'Statistics, armor, and more for all tanks in World of Tanks Blitz',
image: 'D8j6GZx',
button: {
text: 'Find your tank',
color: 'purple',
},
significant: true,
},
{
id: 'rating',
title: 'Rating leaderboard',
description: 'Full rating gamemode leaderboard',
title: 'Rating',
description: 'Full rating leaderboard',
pageDescription:
'Live and archived full rating leaderboard for World of Tanks Blitz',
},
{
id: 'session',
title: 'Session tracker',
description: 'Live session performance tracker',
pageDescription:
'Track your stats in real time as you play World of Tanks Blitz',
image: 'WEyMZH3',
button: {
text: 'View leaderboard',
color: 'orange',
},
},
{
id: 'compare',
title: 'Compare',
description: 'Compare tank statistics and loadouts',
description: 'Compare tank statistics',
pageDescription:
'Compare tanks statistics and loadouts in World of Tanks Blitz',
image: 'O6VNl6e',
button: {
text: 'Compare tanks',
color: 'crimson',
},
},
{
// TODO: rename to tracker
id: 'session',
title: 'Session',
description: 'Live session stats',
pageDescription:
'Track your stats in real time as you play World of Tanks Blitz',
image: 'HdG9sTf',
button: {
text: 'Start tracking',
color: 'blue',
},
},
{
id: 'discord',
title: 'Discord bot',
description: 'Stats and tools right in Discord',
description: 'Stats right in Discord',
href: 'https://discord.com/application-directory/1097673957865443370',
image: '0bWE5hC',
button: {
text: 'Install now',
color: 'indigo',
},
},
{
id: 'more',
title: 'More coming soon',
description: 'This site is being actively developed',
description: "What we're making",
href: 'https://discord.gg/nDt7AjGJQH',
image: '1nPm6VI',
button: {
text: 'Join Discord',
color: 'indigo',
},
},
];
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more