Compare commits

...
185 Commits
Author SHA1 Message Date
TrèsAbhi f119b06dd8 fix: grammar 2024-04-12 09:24:15 -05:00
TrèsAbhi e23a440508 feat: logs 2024-04-12 09:23:34 -05:00
TrèsAbhi 6a9fa08500 fix: /research tank icon alignment 2024-04-12 09:17:05 -05:00
TrèsAbhi 8e86e81e91 fix: max probisions 2024-04-10 19:35:28 -05:00
TrèsAbhi 8b359e220a feat: more icon gap 2024-04-10 17:04:33 -05:00
TrèsAbhi 690bc5dbb5 feat: wrapping controls 2024-04-10 16:57:38 -05:00
TrèsAbhi d6e96e17ad feat: spall liner 2024-04-10 16:54:16 -05:00
TrèsAbhi ab26b346e6 feat: lenient psa 2024-04-10 16:29:12 -05:00
TrèsAbhi f02db6ef0b fix: clipping hero stat description 2024-04-10 16:19:24 -05:00
TrèsAbhi 55ba2525d1 fix: query ignorance on filter change 2024-04-10 16:13:39 -05:00
TrèsAbhi ea07d2646d feat: name draggable 2024-04-10 16:10:34 -05:00
TrèsAbhi ca111510f6 feat: remove caret icons 2024-04-10 16:05:30 -05:00
TrèsAbhi 519558dd28 fix: drag on mobile 2024-04-10 16:04:50 -05:00
TrèsAbhi c4d1e305f6 feat: grabbing cursor 2024-04-10 09:41:55 -05:00
TrèsAbhi 59772afb04 feat: drag and drop 2024-04-10 09:40:52 -05:00
TrèsAbhi 8c9dcfa551 feat: change name to just compare 2024-04-10 09:03:56 -05:00
TrèsAbhi 8bbf03a128 feat: version 2024-04-09 16:55:09 -05:00
TrèsAbhi 519fba84dc feat: ordered asset building 2024-04-09 16:03:07 -05:00
TrèsAbhi e7e74f706e feat: more provisions 2024-04-09 10:51:12 -05:00
TrèsAbhi 12e2793c92 feat: dynamic armor 2024-04-09 10:39:26 -05:00
TrèsAbhi 69040dc453 feat: reactive armor 2024-04-09 10:35:32 -05:00
TrèsAbhi 511151e101 feat: shells 2024-04-09 10:23:10 -05:00
TrèsAbhi 7fc993c2eb feat: shells 2024-04-09 10:04:43 -05:00
TrèsAbhi 030ca744e9 fix: correct shell count 2024-04-09 10:03:30 -05:00
TrèsAbhi 74756ab7bf feat: crew actions 2024-04-09 09:56:31 -05:00
TrèsAbhi e9a61d579d fix: update stats with crew skills 2024-04-09 09:51:22 -05:00
TrèsAbhi 7c3cad2c7d feat: crew button and missing traverse stats 2024-04-09 09:47:57 -05:00
TrèsAbhi 5f39ba55f6 feat: skip bot definitions 2024-04-08 22:04:50 -05:00
TrèsAbhi 2bfd8286a4 feat: cleanup 2024-04-08 21:50:29 -05:00
TrèsAbhi f3421b6f70 fix: types 2024-04-08 21:44:52 -05:00
TrèsAbhi abbbf11b3d feat: adsense 2024-04-08 21:43:47 -05:00
TrèsAbhi 797f5a90d2 feat: watermark 2 2024-04-08 21:24:24 -05:00
TrèsAbhi 818d13fade feat: watermark 2024-04-08 21:17:59 -05:00
TrèsAbhi 129d7c9688 feat: skills title 2024-04-08 19:26:49 -05:00
TrèsAbhi 28407b0f05 feat: universal crew skills 2024-04-08 19:24:49 -05:00
TrèsAbhi 8a9d72d0e6 feat: order 2024-04-08 19:12:29 -05:00
TrèsAbhi f98a4c7caa fix: button popover order 2024-04-08 19:10:02 -05:00
TrèsAbhi 92b329a0fb feat: module quick buttons 2024-04-08 19:04:07 -05:00
TrèsAbhi f3d32a4e63 feat: module swapping 2024-04-08 19:01:31 -05:00
TrèsAbhi 0e73d79c9c feat: trimmed module icons 2024-04-08 09:23:26 -05:00
TrèsAbhi 526b5785b7 feat: modules button 2024-04-08 09:22:27 -05:00
TrèsAbhi 02129feb25 fix: NaN deltas 2024-04-08 09:13:14 -05:00
TrèsAbhi 0a38c164a4 fix: removed debug console logs 2024-04-06 22:01:52 -05:00
TrèsAbhi 2e0d52e30e feat: enter to select first tank 2024-04-06 22:00:40 -05:00
TrèsAbhi 6d48f9fe7f idk 2024-04-06 21:40:21 -05:00
TrèsAbhi aba52c9968 feat: consistent icons 2024-04-06 20:35:30 -05:00
TrèsAbhi b96b9fd574 fix: damage decimals 2024-04-06 20:29:36 -05:00
TrèsAbhi fe4ee8b611 feat: more compact 2024-04-06 19:40:40 -05:00
TrèsAbhi 94558393d9 feat: consumables 2024-04-06 19:37:57 -05:00
TrèsAbhi db1e4af20c feat: clear provisions button 2024-04-06 19:19:46 -05:00
TrèsAbhi be20173acd feat: missing provisions 2024-04-06 19:16:16 -05:00
TrèsAbhi d90a70ed2a fix: max selected 2024-04-06 19:09:37 -05:00
TrèsAbhi 85d699dbfc feat: provisions 2024-04-06 19:04:57 -05:00
TrèsAbhi 5d0fe8726b feat: provision buttons 2024-04-06 18:55:34 -05:00
TrèsAbhi 850308b2ac feat: provision assignment 2024-04-06 18:37:21 -05:00
TrèsAbhi 88a85ef152 feat: rename to absolute 2024-04-06 18:30:36 -05:00
TrèsAbhi 8160935081 feat: better control layout 2024-04-06 18:28:16 -05:00
TrèsAbhi c057e295d2 feat: discard sorting on mutate 2024-04-06 18:19:45 -05:00
TrèsAbhi 28d0c26268 feat: change to clear 2024-04-06 17:55:39 -05:00
TrèsAbhi 52cb73a575 feat: gray sort buttons 2024-04-06 17:54:50 -05:00
TrèsAbhi 9f244452da feat: max tank name size 2024-04-06 17:53:22 -05:00
TrèsAbhi d46d948c66 feat: sort 2024-04-06 17:46:30 -05:00
TrèsAbhi 11b99ef211 feat: better compact search chunking 2024-04-06 17:28:45 -05:00
TrèsAbhi 85d350a912 feat: less CLS loader 2024-04-06 17:21:38 -05:00
TrèsAbhi 145bc84fb1 feat: no player selected 2024-04-06 13:18:17 -05:00
TrèsAbhi b7486022eb feat: cleaner home page 2024-04-06 13:12:07 -05:00
TrèsAbhi eadb4bb7b1 fix: no padding in embeds 2024-04-06 13:06:48 -05:00
TrèsAbhi 79723157d2 feat: ghost equipment buttons 2024-04-06 13:03:36 -05:00
TrèsAbhi 4214ff0de5 feat: trash button functionality 2024-04-06 12:51:52 -05:00
TrèsAbhi f516c9275e feat: flex 1 pages 2024-04-06 12:49:38 -05:00
TrèsAbhi f0b905593c feat: better loading spinners 2024-04-06 12:43:54 -05:00
TrèsAbhi 11440bc654 feat: non nested spans 2024-04-06 12:37:50 -05:00
TrèsAbhi 2dab957a05 fix: scroll padding 2024-04-06 12:28:29 -05:00
TrèsAbhi 60f3951e7a feat: remove scrollbar 2 2024-04-06 12:23:08 -05:00
TrèsAbhi 4f03788f15 feat: remove scrollbar 2024-04-06 12:22:56 -05:00
TrèsAbhi 17f4435a30 feat: isolated footer 2024-04-06 12:10:54 -05:00
TrèsAbhi 04e42c929c feat: blitz version 2024-04-06 11:15:39 -05:00
TrèsAbhi 4ba45b8fd8 feat: game definitions 2024-04-06 11:14:07 -05:00
TrèsAbhi 0ece1f4b18 feat: no tanks selected message 2024-04-06 10:57:02 -05:00
TrèsAbhi 49d3e1d1c2 feat: select all option 2024-04-06 10:36:07 -05:00
TrèsAbhi 184ca522dd feat: proper keying 2024-04-06 10:17:48 -05:00
TrèsAbhi 9f07d910b8 feat: generalized equipment matrix manager 2024-04-06 10:06:59 -05:00
TrèsAbhi ac3f2ea93a feat: themed equipment preview 2024-04-06 09:52:28 -05:00
TrèsAbhi a77ff1a638 fix: pen 0 decimals 2024-04-06 09:51:16 -05:00
TrèsAbhi 96d802a09f feat: more compact icons 2024-04-05 17:55:07 -05:00
TrèsAbhi 1babd66649 feat: loop icon 2024-04-05 17:53:56 -05:00
TrèsAbhi 2f2996a40c feat: equipment popover 2024-04-05 17:52:41 -05:00
TrèsAbhi 9d1e059ea5 feat: solid backgrounds 2024-04-05 17:44:31 -05:00
TrèsAbhi 48603b918f feat: equipment preview 2024-04-05 17:40:14 -05:00
TrèsAbhi c1253e8121 feat: equipment matrix visual start 2024-04-05 17:28:58 -05:00
TrèsAbhi ecc029402e feat: percentage 2024-04-05 14:05:15 -05:00
TrèsAbhi 1b88773e28 feat: nominal deltas 2024-04-05 13:59:30 -05:00
TrèsAbhi cf419a9ebb feat: better titles 2024-04-05 13:40:18 -05:00
TrèsAbhi 8565d75747 feat: double gain 2024-04-05 09:51:22 -05:00
TrèsAbhi 186ea8b458 feat: round 2024-04-05 09:38:48 -05:00
TrèsAbhi 4292081df6 feat: stronger colors 2024-04-05 09:35:55 -05:00
TrèsAbhi 4b54b61c35 feat: gradient interpolation 2024-04-05 09:35:03 -05:00
TrèsAbhi 5d098c46fa feat: titles 2024-04-05 09:31:21 -05:00
TrèsAbhi 7575f4581c feat: decimal shorthand 2024-04-05 09:20:07 -05:00
TrèsAbhi 4669fa6ec5 feat: significant change colors 2024-04-05 09:13:13 -05:00
TrèsAbhi 49294d1906 feat: volume 2024-04-05 09:07:29 -05:00
TrèsAbhi 3eee946978 feat: split volume 2024-04-05 09:06:44 -05:00
TrèsAbhi 7fff1c9737 feat: volume 2024-04-04 22:23:06 -05:00
TrèsAbhi 5b4c3119dd feat: average shell reload 2024-04-04 22:17:36 -05:00
TrèsAbhi 0a41ee15ed feat: lower is better 2024-04-04 22:15:33 -05:00
TrèsAbhi ad57d284bb feat: more stats 3 2024-04-04 22:14:30 -05:00
TrèsAbhi b929e9449d feat: more stats 2 2024-04-04 21:56:00 -05:00
TrèsAbhi 948ea0bb8d fix: types 2024-04-04 21:17:40 -05:00
TrèsAbhi c1b1b8d304 feat: more stats 2024-04-04 19:14:22 -05:00
TrèsAbhi 3bc5a6d740 fix: some bugs 2024-04-04 18:56:51 -05:00
TrèsAbhi d25da724e1 feat: some button functionality 2024-04-04 16:14:01 -05:00
TrèsAbhi 753804b8cf feat: some more stats 2 2024-04-04 15:52:05 -05:00
TrèsAbhi 096122d43c feat: some more stats 2024-04-04 15:47:27 -05:00
TrèsAbhi 3448427de0 feat: reintroduced links 2024-04-04 15:35:27 -05:00
TrèsAbhi d8bbd02715 feat: center aline 2024-04-04 13:51:39 -05:00
TrèsAbhi f696c5f3c9 feat: some stats 2024-04-04 13:51:07 -05:00
TrèsAbhi bd412cdaa2 feat: generalized tank stats 2024-04-04 13:16:12 -05:00
TrèsAbhi 913411275b feat: better header alignemnt 2024-04-04 11:05:56 -05:00
TrèsAbhi 17ff8e0819 feat: better sizing 2024-04-04 11:04:27 -05:00
TrèsAbhi b5fdc4d5cf feat: add functionality 2024-04-04 10:46:48 -05:00
TrèsAbhi 59c5d95aff feat: compare page 2024-04-04 10:21:37 -05:00
TrèsAbhi 16babbad90 feat: og tool icon 2024-04-04 10:18:04 -05:00
TrèsAbhi a9dc9e7ebd feat: cleaner tool shadows 2024-04-04 10:16:47 -05:00
TrèsAbhi 52e8df560e feat: added tank compare link 2024-04-04 10:15:01 -05:00
TrèsAbhi 87259ad515 feat: robots 2024-04-03 22:57:28 -05:00
TrèsAbhi c96655a6c0 feat: xml 2024-04-03 22:47:10 -05:00
TrèsAbhi 43894567b8 / 2024-04-03 22:37:38 -05:00
TrèsAbhi 76a952a8e6 fix: change to tests 2024-04-03 22:28:12 -05:00
TrèsAbhi e2efe428a2 feat: use require 2024-04-03 22:24:01 -05:00
TrèsAbhi 887cec2dd4 feat: use fs 2024-04-03 22:22:13 -05:00
TrèsAbhi 04ad48bd33 feat: use proper write file 2024-04-03 22:20:30 -05:00
TrèsAbhi 3613f5d13b feat: log 2024-04-03 22:18:41 -05:00
TrèsAbhi 3362e3b091 feat: js next config 2024-04-03 22:16:16 -05:00
TrèsAbhi 3322fa97d4 feat: ts next config 2024-04-03 22:14:55 -05:00
TrèsAbhi 1dc7748a04 feat: use script 2024-04-03 22:08:22 -05:00
TrèsAbhi 6176b3b0d8 feat: use sync 2024-04-03 22:04:36 -05:00
TrèsAbhi 81c2af08c6 fix: build sitemap before build 2024-04-03 22:02:02 -05:00
TrèsAbhi 2d83519f93 Merge branch 'dev' of https://github.com/tresabhi/blitzkrieg into dev 2024-04-03 21:58:49 -05:00
TrèsAbhi 869c1f0def feat: sitemap 2024-04-03 21:58:38 -05:00
TrèsAbhi 7a64fb8176 feat: preview image 2024-04-03 21:58:38 -05:00
TrèsAbhi 9256244286 fix: remove title from svg 2024-04-03 21:58:38 -05:00
TrèsAbhi 85e54e4a07 fix: build db 2 2024-04-03 21:58:38 -05:00
TrèsAbhi 8075dc8d9f fix: build db 2024-04-03 21:58:38 -05:00
TrèsAbhi 57dced109a feat: page descriptions 2024-04-03 21:58:38 -05:00
TrèsAbhi dd19d5b979 feat: gen prisma (#105) 2024-04-03 21:57:39 -05:00
TrèsAbhi 68d3fe6805 feat: sitemap 2024-04-03 21:56:20 -05:00
TrèsAbhi 70f7698466 feat: preview image 2024-04-03 19:48:28 -05:00
TrèsAbhi 26a5fdea0e fix: remove title from svg 2024-04-03 19:34:32 -05:00
TrèsAbhi 367bd1386b fix: build db 2 2024-04-03 19:31:10 -05:00
TrèsAbhi fac773a8f8 fix: build db 2024-04-03 19:28:25 -05:00
TrèsAbhi 6bd8b7d42f feat: page descriptions 2024-04-03 19:27:24 -05:00
TrèsAbhi 7093662130 feat: gen prisma 2024-04-03 15:34:39 -05:00
TrèsAbhi 4062e56b4c feat: migrated to new db 2024-04-03 15:19:48 -05:00
TrèsAbhi 0885e7e68a feat: logs 2024-04-02 10:37:39 -05:00
TrèsAbhi 3cfa837682 Merge branch 'main' of https://github.com/tresabhi/blitzkrieg into dev 2024-04-02 10:26:31 -05:00
TrèsAbhi 1982604878 fix: some more floating component removals 2024-04-02 10:21:51 -05:00
TrèsAbhi 9ef6eea4a9 fix: remove useless tooltips 2024-04-02 10:10:15 -05:00
TrèsAbhi 78e6b2696c fix: word tolerant pitch and yaw 2024-04-02 10:04:54 -05:00
TrèsAbhi 40b6f30092 fix: synced armor states 2 2024-04-02 10:02:21 -05:00
TrèsAbhi de2d9a1a3e fix: synced armor states 2024-04-02 09:52:46 -05:00
TrèsAbhi b7ad12c698 Create ads.txt 2024-04-02 08:24:37 -05:00
TrèsAbhi 886c2aa1d1 fix: access document on client 2 2024-04-01 22:02:15 -05:00
TrèsAbhi 9818c9868a fix: access document on client 2024-04-01 22:00:38 -05:00
TrèsAbhi 31055f1d66 fix: use link for big tank search 2024-04-01 21:57:54 -05:00
TrèsAbhi 6cef753053 feat: donation icons 2024-04-01 21:46:17 -05:00
TrèsAbhi 5b889f8483 feat: more accessible full screen mode 2024-04-01 21:33:47 -05:00
TrèsAbhi d0d905ccfb feat: better antagonist bar 2024-04-01 19:30:06 -05:00
TrèsAbhi 9a91a8bb13 fix: excessive antagonist bar gap 2024-04-01 19:17:59 -05:00
TrèsAbhi c12cc9b8c5 feat: length sorting 2024-04-01 19:12:59 -05:00
TrèsAbhi fdbfd0bc10 feat: rename size to volume 2024-04-01 19:10:42 -05:00
TrèsAbhi 286b178218 feat: some cleanup 2024-04-01 19:09:21 -05:00
TrèsAbhi 54e012f736 feat: dropdown modules 2024-04-01 19:05:55 -05:00
TrèsAbhi cc6de50de6 feat: upgrade to radix 3.0 2024-04-01 18:36:14 -05:00
TrèsAbhi 194e5fceca feat: crew training info 2024-04-01 09:31:02 -05:00
TrèsAbhi 879a5d534c feat: consumable info 2024-04-01 09:27:54 -05:00
TrèsAbhi 29aaf60da0 feat: cleaner info 2024-04-01 09:24:12 -05:00
TrèsAbhi 3c8f8b49e4 feat: condensed 2 2024-03-31 22:32:43 -05:00
TrèsAbhi 0b88f71f26 feat: condensed 2024-03-31 22:27:11 -05:00
TrèsAbhi 65e239a22b feat: hide module actions on no upgrades 2024-03-31 22:16:29 -05:00
TrèsAbhi 7727e64931 feat: quick actions 2024-03-31 22:14:14 -05:00
TrèsAbhi 7b0465322d feat: special skills 2024-03-31 19:27:37 -05:00
TrèsAbhi 099e5eb228 feat: skill stats 2024-03-31 19:17:30 -05:00
TrèsAbhi eaff26c8d9 feat: crew skills ui 2024-03-31 18:49:17 -05:00
TrèsAbhi d79129df91 feat: skill definitions 2024-03-31 18:24:19 -05:00
TrèsAbhi b363c76639 feat: crew skill icons 2024-03-31 18:17:48 -05:00
117 changed files with 5431 additions and 1843 deletions

No files matched your search

+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}" $env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
yarn setup:patches --depot yarn setup:patches --depot
@@ -61,5 +61,5 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }} yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
+1 -1
View File
@@ -33,4 +33,4 @@ jobs:
unzip bot.zip unzip bot.zip
- name: Run bot - name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} node --enable-source-maps index.cjs
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=asia --target=midnight --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=eu --target=midnight --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate all leaderboards - name: Generate all leaderboards
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingleaderboard --region=na --target=midnight --production run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na --target=midnight --production
+3
View File
@@ -33,6 +33,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Build Prisma
run: yarn prisma generate
- name: Build - name: Build
run: yarn build:bot --production run: yarn build:bot --production
+1 -2
View File
@@ -18,5 +18,4 @@ temp/
/.VSCodeCounter /.VSCodeCounter
/.yarn /.yarn
depots depots
public/sitemap*.xml public/sitemap*
public/robots.txt
+6
View File
@@ -3,6 +3,7 @@
"AABBOX", "AABBOX",
"Abhi", "Abhi",
"accountinfo", "accountinfo",
"adsense",
"aeson", "aeson",
"alphatest", "alphatest",
"APAC", "APAC",
@@ -72,6 +73,7 @@
"leaderboards", "leaderboards",
"lerp", "lerp",
"LINEARSIZE", "LINEARSIZE",
"loadouts",
"loddist", "loddist",
"lods", "lods",
"metallicness", "metallicness",
@@ -106,6 +108,7 @@
"Resvg", "Resvg",
"roboto", "roboto",
"SCPG", "SCPG",
"scroller",
"searchclans", "searchclans",
"searchplayers", "searchplayers",
"searchtanks", "searchtanks",
@@ -125,6 +128,7 @@
"Synx", "Synx",
"tankaverages", "tankaverages",
"Tankerch", "Tankerch",
"tankmen",
"tankopedia", "tankopedia",
"tankopedias", "tankopedias",
"tankstats", "tankstats",
@@ -143,8 +147,10 @@
"Vovko", "Vovko",
"wargaming", "wargaming",
"webp", "webp",
"whitespaces",
"winrate", "winrate",
"wireframe", "wireframe",
"Woah",
"WOTB", "WOTB",
"wotblitz", "wotblitz",
"xffn", "xffn",
+29
View File
@@ -0,0 +1,29 @@
# Blitzkrieg 1.18.3
Making Tankopedia more pleasing to use. This update only affects the website.
## Features
- Added crew skills
- Added quick actions
- For example, crew skills have the "clear" and "maximize" actions and equipment simply has a "clear" action
- Added info buttons
- Beside the titles of a few sections, a new info buttons have been added for extended descriptions
- Tank configurations have been condensed thanks to a new layout that works well on most screen sizes
- The Tankopedia now takes advantage of wider screens than before; hence, getting rid of the useless whitespaces on the sides
- Tank modules are now neatly wrapped into dropdown menus
- Sorting tanks by "size" has been renamed to "volume" and length has been added as a new option
- The antagonist (the shooting tank) bar has been shrunk to only fit the its contents
- Fullscreen mode now features all the missing dropdowns and options that were previously hidden
- Added icons for donations on Patreon and Ko-fi in the navigation bar
- Tank search now uses regular links allowing for things like opening in new tabs, copying links, etc.
## Fixes
- Armor highlighting once again works properly even after switching around tank modules
- Inputting invalid numbers into the pitch and yaw fields is now handled correctly
- Removed unnecessary tooltips that didn't show up anyway due to Radix UI 3
## Technical Changes
- Upgraded to Radix UI 3
+23
View File
@@ -0,0 +1,23 @@
# 1.19.0
Introducing the Blitzkrieg Compare tool!
## Features
- Added Compare
- Allows you to compare multiple tanks
- Shows deltas in the form of differences between values or percentages
- All stats from the Tankopedia are available here
- [CLS](https://web.dev/articles/cls) was reduced due to the new loading screens
- Clicking enter when searching for tanks now selects the first result
- The first tank name is also displayed in the search input
## Changes
- Removed the donation banner instead for a little note in the footer
- The footer now shows the version of Blitzkrieg and the version of WoTB its running on
- Removed frosted glass effect from most components apart from the navigation bar for performance reasons (I am looking at you, Apple!)
## Fixes
- Tank search is now not ignored when changing filters
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || 'https://blitz-krieg.vercel.app',
generateRobotsTxt: true,
sourceDir: 'dist/website',
};
+7 -7
View File
@@ -1,17 +1,19 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.18.2", "version": "1.19.0",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:bot": "vite-node --script scripts/build", "build:db": "prisma generate",
"build:website": "next build && next-sitemap --config next-sitemap.config.cjs", "build:bot": "vite-node --script scripts/build --production",
"build:website": "yarn build:db && yarn build:sitemap && next build",
"build:sitemap": "vite-node --script scripts/sitemap",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard", "build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index", "build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
"build:test": "vite-node --script scripts/buildTest", "build:test": "vite-node --script scripts/buildTest",
"dev:bot": "vite-node --script scripts/build --production && yarn start:bot", "dev:bot": "vite-node --script scripts/build && yarn start:bot",
"dev:docs": "vitepress dev docs", "dev:docs": "vitepress dev docs",
"dev:website": "next dev", "dev:website": "next dev",
"start:bot": "node --enable-source-maps dist/bot/index.cjs", "start:bot": "node --enable-source-maps dist/bot/index.cjs",
@@ -36,7 +38,7 @@
"@prisma/client": "^5.4.1", "@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0", "@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^2.0.1", "@radix-ui/themes": "^3.0.1",
"@react-three/drei": "^9.93.0", "@react-three/drei": "^9.93.0",
"@react-three/fiber": "^8.15.14", "@react-three/fiber": "^8.15.14",
"@resvg/resvg-js": "^2.4.1", "@resvg/resvg-js": "^2.4.1",
@@ -56,7 +58,6 @@
"lz4js": "^0.2.0", "lz4js": "^0.2.0",
"markdown-escape": "^2.0.0", "markdown-escape": "^2.0.0",
"next": "^14.1.0", "next": "^14.1.0",
"next-sitemap": "^4.2.3",
"parse-dds": "^1.2.1", "parse-dds": "^1.2.1",
"postprocessing": "^6.34.1", "postprocessing": "^6.34.1",
"react": "^18.2.0", "react": "^18.2.0",
@@ -64,7 +65,6 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fuzzysort": "^1.0.1", "react-fuzzysort": "^1.0.1",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-spinners": "^0.13.8",
"satori": "^0.10.13", "satori": "^0.10.13",
"sharp": "0.33.1", "sharp": "0.33.1",
"three": "0.159.0", "three": "0.159.0",
@@ -1,8 +0,0 @@
-- CreateTable
CREATE TABLE "discord_blitz" (
"discord" INT8 NOT NULL,
"region" STRING NOT NULL,
"blitz" INT4 NOT NULL,
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
);
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "cockroachdb"
+7 -3
View File
@@ -4,11 +4,15 @@ generator client {
datasource db { datasource db {
provider = "cockroachdb" provider = "cockroachdb"
url = env("DISCORD_BLITZ_DB") url = env("DATABASE_URL")
} }
model discord_blitz { model Users {
blitz Int @id
last_used DateTime? @default(now())
}
model DiscordUsers {
discord BigInt @id discord BigInt @id
region String
blitz Int blitz Int
} }
+1
View File
@@ -0,0 +1 @@
google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+7
View File
@@ -0,0 +1,7 @@
User-agent: *
Allow: /
Host: https://blitz-krieg.vercel.app
Sitemap: https://blitz-krieg.vercel.app/sitemap.txt
Sitemap: https://blitz-krieg.vercel.app/sitemap.xml
+37 -5
View File
@@ -1,8 +1,9 @@
import { readdir, writeFile } from 'fs/promises'; import { readdir } from 'fs/promises';
import { parse as parsePath } from 'path'; import { parse as parsePath } from 'path';
import { Vector3Tuple } from 'three'; import { Vector3Tuple } from 'three';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import { TankClass } from '../../src/components/Tanks'; import { TankClass } from '../../src/components/Tanks';
import { readStringDVPL } from '../../src/core/blitz/readStringDVPL';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL'; import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId'; import { toUniqueId } from '../../src/core/blitz/toUniqueId';
@@ -16,11 +17,13 @@ import {
EquipmentPreset, EquipmentPreset,
EquipmentRow, EquipmentRow,
} from '../../src/core/blitzkrieg/equipmentDefinitions'; } from '../../src/core/blitzkrieg/equipmentDefinitions';
import { GameDefinitions } from '../../src/core/blitzkrieg/gameDefinitions';
import { import {
ModelArmor, ModelArmor,
ModelDefinitions, ModelDefinitions,
} from '../../src/core/blitzkrieg/modelDefinitions'; } from '../../src/core/blitzkrieg/modelDefinitions';
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions'; import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
import { SkillDefinitions } from '../../src/core/blitzkrieg/skillDefinitions';
import { superCompress } from '../../src/core/blitzkrieg/superCompress'; import { superCompress } from '../../src/core/blitzkrieg/superCompress';
import { import {
Crew, Crew,
@@ -32,6 +35,7 @@ import {
Tier, Tier,
} from '../../src/core/blitzkrieg/tankDefinitions'; } from '../../src/core/blitzkrieg/tankDefinitions';
import { DATA, POI } from './constants'; import { DATA, POI } from './constants';
import { Avatar } from './skillIcons';
import { TankParameters } from './tankIcons'; import { TankParameters } from './tankIcons';
interface Strings { interface Strings {
@@ -116,6 +120,7 @@ interface VehicleDefinitions {
weight: number; weight: number;
terrainResistance: string; terrainResistance: string;
rotationSpeed: number; rotationSpeed: number;
userString: string;
shotDispersionFactors: { shotDispersionFactors: {
vehicleMovement: number; vehicleMovement: number;
vehicleRotation: number; vehicleRotation: number;
@@ -329,10 +334,18 @@ const blitzShellKindToBLitzkrieg: Record<ShellKind, ShellType> = {
const missingStrings: Record<string, string> = { const missingStrings: Record<string, string> = {
'#artefacts:tungstentip/name': 'Tungsten Shells', '#artefacts:tungstentip/name': 'Tungsten Shells',
}; };
const botPattern = /^.+((tutorial_bot(\d+)?)|(TU(R?)))$/;
export async function definitions(production: boolean) { export async function definitions(production: boolean) {
console.log('Building definitions...'); console.log('Building definitions...');
const gameDefinitions: GameDefinitions = {
version: (await readStringDVPL(`${DATA}/version.txt.dvpl`))
.split(' ')[0]
.split('.')
.slice(0, 3)
.join('.'),
};
const tankDefinitions: TankDefinitions = {}; const tankDefinitions: TankDefinitions = {};
const modelDefinitions: ModelDefinitions = {}; const modelDefinitions: ModelDefinitions = {};
const equipmentDefinitions: EquipmentDefinitions = { const equipmentDefinitions: EquipmentDefinitions = {
@@ -341,6 +354,9 @@ export async function definitions(production: boolean) {
}; };
const consumableDefinitions: ConsumableDefinitions = {}; const consumableDefinitions: ConsumableDefinitions = {};
const provisionDefinitions: ProvisionDefinitions = {}; const provisionDefinitions: ProvisionDefinitions = {};
const skillDefinitions: SkillDefinitions = {
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
};
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
@@ -367,6 +383,9 @@ export async function definitions(production: boolean) {
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>( const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
`${DATA}/${POI.provisionsCommon}.dvpl`, `${DATA}/${POI.provisionsCommon}.dvpl`,
); );
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const tankXps = new Map<number, number>(); const tankXps = new Map<number, number>();
@@ -402,6 +421,8 @@ export async function definitions(production: boolean) {
); );
for (const tankKey in tankList.root) { for (const tankKey in tankList.root) {
if (botPattern.test(tankKey)) continue;
const gunXps = new Map<number, number>(); const gunXps = new Map<number, number>();
const turretXps = new Map<number, number>(); const turretXps = new Map<number, number>();
const engineXps = new Map<number, number>(); const engineXps = new Map<number, number>();
@@ -558,6 +579,7 @@ export async function definitions(production: boolean) {
tankDefinitions[tankId].tracks.push({ tankDefinitions[tankId].tracks.push({
id: trackId, id: trackId,
weight: track.weight, weight: track.weight,
name: strings[track.userString],
traverseSpeed: track.rotationSpeed, traverseSpeed: track.rotationSpeed,
dispersion: { dispersion: {
move: track.shotDispersionFactors.vehicleMovement, move: track.shotDispersionFactors.vehicleMovement,
@@ -1084,13 +1106,18 @@ export async function definitions(production: boolean) {
} }
}); });
writeFile( Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
'test.tanks.cdon.lz4', skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
Buffer.from(superCompress(tankDefinitions), 'base64'), });
);
await commitAssets( await commitAssets(
'definitions', 'definitions',
[ [
{
content: superCompress(gameDefinitions),
encoding: 'base64',
path: 'definitions/game.cdon.lz4',
},
{ {
content: superCompress(tankDefinitions), content: superCompress(tankDefinitions),
encoding: 'base64', encoding: 'base64',
@@ -1116,6 +1143,11 @@ export async function definitions(production: boolean) {
encoding: 'base64', encoding: 'base64',
path: 'definitions/provisions.cdon.lz4', path: 'definitions/provisions.cdon.lz4',
}, },
{
content: superCompress(skillDefinitions),
encoding: 'base64',
path: 'definitions/skills.cdon.lz4',
},
], ],
production, production,
); );
+4 -2
View File
@@ -20,6 +20,7 @@ import { equipmentIcons } from './equipmentIcons';
import { moduleIcons } from './moduleIcons'; import { moduleIcons } from './moduleIcons';
import { scratchedFlags } from './scratchedFlags'; import { scratchedFlags } from './scratchedFlags';
import { shellIcons } from './shellIcons'; import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors'; import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons'; import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels'; import { tankModels } from './tankModels';
@@ -28,8 +29,9 @@ if (!targets && !allTargets) throw new Error('No target(s) specified');
const methods = [ const methods = [
definitions, definitions,
tankIcons,
scratchedFlags, scratchedFlags,
currencies,
skillIcons,
circleFlags, circleFlags,
shellIcons, shellIcons,
moduleIcons, moduleIcons,
@@ -37,9 +39,9 @@ const methods = [
consumableProvisionIcons, consumableProvisionIcons,
camouflageIcons, camouflageIcons,
boosterIcons, boosterIcons,
tankIcons,
tankArmors, tankArmors,
tankModels, tankModels,
currencies,
]; ];
for (const method of methods) { for (const method of methods) {
+7 -4
View File
@@ -1,5 +1,6 @@
import { readdir } from 'fs/promises'; import { readdir } from 'fs/promises';
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL'; import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets'; import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles'; import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
import { DATA, POI } from './constants'; import { DATA, POI } from './constants';
@@ -16,9 +17,11 @@ export async function moduleIcons(production: boolean) {
.map(async (file) => { .map(async (file) => {
const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1]; const nameRaw = file.match(/vehicle(.+)\.packed\.webp\.dvpl/)![1];
const name = nameRaw[0].toLowerCase() + nameRaw.slice(1); const name = nameRaw[0].toLowerCase() + nameRaw.slice(1);
const content = await readBase64DVPL( const content = (
`${DATA}/${POI.moduleIcons}/${file}`, await sharp(await readDVPLFile(`${DATA}/${POI.moduleIcons}/${file}`))
); .trim()
.toBuffer()
).toString('base64');
return { return {
content, content,
+51
View File
@@ -0,0 +1,51 @@
import sharp from 'sharp';
import { TankClass } from '../../src/components/Tanks';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
import { DATA } from './constants';
interface SkillIcon {
name: string;
state: number;
}
export interface Avatar {
roles: unknown;
skillsByClasses: Record<TankClass, string>;
skills: {
[name: string]: {
userString: string;
effectDescription: string;
tipDescription: string;
icon: SkillIcon | SkillIcon[];
type: 'continuous' | 'trigger';
};
};
}
export async function skillIcons(production: boolean) {
console.log('Building skill icons...');
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const changes = await Promise.all(
Object.values(avatar.root.skills).map(async (skill) => {
const icon = Array.isArray(skill.icon) ? skill.icon[0] : skill.icon;
const name = icon.name.split('/').at(-1)!.replace(/_\d$/, '');
const path = `${DATA}${icon.name.replace('~res:', '')}.packed.webp.dvpl`;
const image = sharp(await readDVPLFile(path)).trim();
const content = (await image.toBuffer()).toString('base64');
return {
content,
encoding: 'base64',
path: `icons/skills/${name}.webp`,
} satisfies FileChange;
}),
);
commitAssets('skill icons', changes, production);
}
+12 -5
View File
@@ -1,7 +1,8 @@
import { readdir } from 'fs/promises'; import { readdir } from 'fs/promises';
import sharp from 'sharp';
import { Vector3Tuple } from 'three'; import { Vector3Tuple } from 'three';
import { NATION_IDS } from '../../src/constants/nations'; import { NATION_IDS } from '../../src/constants/nations';
import { readBase64DVPL } from '../../src/core/blitz/readBase64DVPL'; import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL'; import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL'; import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets'; import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
@@ -50,23 +51,29 @@ export async function tankIcons(production: boolean) {
const parameters = await readYAMLDVPL<TankParameters>( const parameters = await readYAMLDVPL<TankParameters>(
`${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`, `${DATA}/${POI.tankParameters}/${nation}/${tankKey}.yaml.dvpl`,
); );
const small = `${DATA}/${parameters.resourcesPath.smallIconPath const smallPath = `${DATA}/${parameters.resourcesPath.smallIconPath
.replace(/~res:\//, '') .replace(/~res:\//, '')
.replace(/\..+/, '')}.packed.webp.dvpl`; .replace(/\..+/, '')}.packed.webp.dvpl`;
const big = `${DATA}/${parameters.resourcesPath.bigIconPath const bigPath = `${DATA}/${parameters.resourcesPath.bigIconPath
.replace(/~res:\//, '') .replace(/~res:\//, '')
.replace(/\..+/, '')}.packed.webp.dvpl`; .replace(/\..+/, '')}.packed.webp.dvpl`;
const big = await sharp(await readDVPLFile(bigPath))
.trim()
.toBuffer();
const small = await sharp(await readDVPLFile(smallPath))
.trim()
.toBuffer();
if (big) { if (big) {
changes.push({ changes.push({
content: await readBase64DVPL(big), content: big.toString('base64'),
encoding: 'base64', encoding: 'base64',
path: `icons/tanks/big/${id}.webp`, path: `icons/tanks/big/${id}.webp`,
}); });
} }
if (small) { if (small) {
changes.push({ changes.push({
content: await readBase64DVPL(small), content: small.toString('base64'),
encoding: 'base64', encoding: 'base64',
path: `icons/tanks/small/${id}.webp`, path: `icons/tanks/small/${id}.webp`,
}); });
+26
View File
@@ -0,0 +1,26 @@
import { tankDefinitions } from '../src/core/blitzkrieg/tankDefinitions';
// random vercel bug forces require import
const { writeFileSync } = require('fs');
console.log('Building sitemap...');
const awaitedTankDefinitions = await tankDefinitions;
const values = Object.values(awaitedTankDefinitions);
const txt = values
.map((tank) => `https://blitz-krieg.vercel.app/tools/tankopedia/${tank.id}/`)
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
${values
.map(
(tank) =>
`<url><loc>https://blitz-krieg.vercel.app/tools/tankopedia/${tank.id}/</loc></url>`,
)
.join('\n')}
</urlset>`;
writeFileSync('public/sitemap.txt', txt);
writeFileSync('public/sitemap.xml', xml);
console.log(`Built sitemap for ${values.length} tanks`);
+42 -92
View File
@@ -1,20 +1,18 @@
'use client'; 'use client';
import { CaretRightIcon } from '@radix-ui/react-icons'; import { AlertDialog, Button, Flex, Theme } from '@radix-ui/themes';
import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css'; import '@radix-ui/themes/styles.css';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { Roboto_Flex } from 'next/font/google'; import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { SkyLine } from '../assets/art/SkyLine'; import { Google } from '../components/Google';
import { Analytics } from '../components/Analytics'; import { Footer } from '../components/Footer';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import PageWrapper from '../components/PageWrapper';
import isDev from '../core/blitzkrieg/isDev'; import isDev from '../core/blitzkrieg/isDev';
import { isLocalhost } from '../core/blitzkrieg/isLocalhost'; import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
import { useFullScreen } from '../hooks/useFullScreen';
import { useApp } from '../stores/app'; import { useApp } from '../stores/app';
import { mintTheme } from '../themes/mint';
config(); config();
@@ -33,6 +31,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
const pathname = usePathname(); const pathname = usePathname();
const isEmbed = pathname.split('/')[1] === 'embeds'; const isEmbed = pathname.split('/')[1] === 'embeds';
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false); const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const isFullScreen = useFullScreen();
useEffect(() => { useEffect(() => {
setShowDevBuildAlert( setShowDevBuildAlert(
@@ -51,110 +50,61 @@ export default function RootLayout({ children }: RootLayoutProps) {
name="description" name="description"
content="🎉 Tools for everything World of Tanks Blitz" content="🎉 Tools for everything World of Tanks Blitz"
/> />
<Analytics />
<Google />
</head> </head>
<body <body
style={{ style={{
margin: 0, margin: 0,
paddingTop: isEmbed ? 0 : '3.25rem',
backgroundColor: isEmbed ? 'transparent' : undefined, backgroundColor: isEmbed ? 'transparent' : undefined,
}} }}
> >
<Theme <Theme
appearance="dark" appearance="dark"
panelBackground="translucent" panelBackground="solid"
radius="full" radius="full"
suppressHydrationWarning suppressHydrationWarning
suppressContentEditableWarning suppressContentEditableWarning
> >
{!isEmbed && <Navbar />} <Flex
<AlertDialog.Root open={showDevBuildAlert}> direction="column"
<AlertDialog.Content> style={{ minHeight: '100vh', paddingTop: isEmbed ? undefined : 52 }}
<AlertDialog.Title>Experimental version!</AlertDialog.Title> >
<AlertDialog.Description> {!isEmbed && <Navbar />}
This version may have a lot of issues. Report issues to{' '} <AlertDialog.Root open={showDevBuildAlert}>
<a href="https://discord.gg/nDt7AjGJQH" target="_blank"> <AlertDialog.Content>
the official Discord server <AlertDialog.Title>Experimental version!</AlertDialog.Title>
</a> <AlertDialog.Description>
. Also consider using{' '} This version may have a lot of issues. Report issues to{' '}
<a href="https://blitz-krieg.vercel.app/"> <a href="https://discord.gg/nDt7AjGJQH" target="_blank">
the more stable release version the official Discord server
</a> </a>
. You will be asked again in 8 days. . Also consider using{' '}
</AlertDialog.Description> <a href="https://blitz-krieg.vercel.app/">
the more stable release version
</a>
. You will be asked again in 8 days.
</AlertDialog.Description>
<Flex justify="end"> <Flex justify="end">
<Button <Button
variant="solid" variant="solid"
onClick={() => { onClick={() => {
setShowDevBuildAlert(false); setShowDevBuildAlert(false);
useApp.setState({ devBuildAgreementTime: Date.now() }); useApp.setState({ devBuildAgreementTime: Date.now() });
}}
>
Continue
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<Flex direction="column">{children}</Flex>
{!isEmbed && (
<PageWrapper>
<a
target="_blank"
href="https://ko-fi.com/tresabhi"
style={{ textDecoration: 'none' }}
>
<Flex
align="center"
style={{
padding: 16,
borderRadius: 8,
backgroundColor: mintTheme.colors.componentInteractive,
position: 'relative',
overflow: 'hidden',
}}
>
<Flex
direction="column"
gap="1"
style={{
flex: 1,
zIndex: 1,
}} }}
> >
<Text size="4" color="mint"> Continue
Blitzkrieg. Free forever. For everyone. </Button>
</Text>
<Text color="mint">
<b>Consider supporting today 🍀</b>
</Text>
</Flex>
<Text
color="mint"
style={{
zIndex: 1,
}}
>
<Flex>
<CaretRightIcon width={32} height={32} />
</Flex>
</Text>
<SkyLine
style={{
position: 'absolute',
right: 'min(8vw, 15%)',
top: '5%',
}}
/>
</Flex> </Flex>
</a> </AlertDialog.Content>
</PageWrapper> </AlertDialog.Root>
)}
{children}
{!isEmbed && !isFullScreen && <Footer />}
</Flex>
</Theme> </Theme>
</body> </body>
</html> </html>
+1 -1
View File
@@ -3,7 +3,7 @@ import { theme } from '../stitches.config';
export const tool = styleVariants({ export const tool = styleVariants({
enabled: { enabled: {
boxShadow: `inset 0 -256px 512px -128px ${theme.colors.appBackground1}`, boxShadow: `inset 0 -200px 128px -128px ${theme.colors.appBackground1}`,
':hover': { ':hover': {
boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`, boxShadow: `inset 0 -160px 128px -128px ${theme.colors.appBackground1}`,
+63 -79
View File
@@ -1,95 +1,79 @@
'use client'; 'use client';
import { CaretRightIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { CaretRightIcon } from '@radix-ui/react-icons';
import { Flex, Text, TextField } from '@radix-ui/themes'; import { Flex } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { useRef } from 'react';
import { Search, SearchItem } from 'react-fuzzysort';
import PageWrapper from '../components/PageWrapper'; import PageWrapper from '../components/PageWrapper';
import { TOOLS } from '../constants/tools'; import { TOOLS } from '../constants/tools';
import { theme } from '../stitches.config'; import { theme } from '../stitches.config';
import * as styles from './page.css'; import * as styles from './page.css';
export default function Page() { export default function Page() {
const input = useRef<HTMLInputElement>(null);
return ( return (
<PageWrapper> <PageWrapper>
<TextField.Root> <Flex direction="column" gap="4" justify="center" style={{ flex: 1 }}>
<TextField.Slot> {/* <TextField.Root placeholder="Search anything...">
<MagnifyingGlassIcon height="16" width="16" /> <TextField.Slot>
</TextField.Slot> <MagnifyingGlassIcon height="16" width="16" />
<TextField.Input ref={input} placeholder="Search tools..." /> </TextField.Slot>
</TextField.Root> </TextField.Root> */}
<Flex gap="3" wrap="wrap" align="center" justify="center"> <Flex gap="3" wrap="wrap" align="center" justify="center">
<Search {TOOLS.map((tool) => (
input={input} <Link
list={TOOLS.map( href={tool.href ?? `/tools/${tool.id}`}
(tool) => target={tool.href ? '_blank' : undefined}
({ className={
node: ( tool.disabled ? styles.tool.disabled : styles.tool.enabled
<Link }
href={tool.href ?? `/tools/${tool.id}`} style={{
target={tool.href ? '_blank' : undefined} backgroundImage: `url(/assets/banners/${tool.id}.webp)`,
className={ cursor: tool.disabled ? 'default' : 'pointer',
tool.disabled ? styles.tool.disabled : styles.tool.enabled opacity: tool.disabled ? 0.25 : 1,
} height: 128,
style={{ minWidth: 256,
backgroundImage: `url(/assets/banners/${tool.id}.webp)`, flex: 1,
cursor: tool.disabled ? 'default' : 'pointer', borderRadius: 8,
opacity: tool.disabled ? 0.25 : 1, textDecoration: 'none',
height: 128, backgroundSize: 'cover',
minWidth: 256, backgroundPosition: 'center',
flex: 1, overflow: 'hidden',
borderRadius: 8, display: 'flex',
textDecoration: 'none', flexDirection: 'column',
backgroundSize: 'cover', alignItems: 'flex-start',
backgroundPosition: 'center', justifyContent: 'flex-end',
overflow: 'hidden', padding: 16,
display: 'flex', gap: 4,
flexDirection: 'column', transition: `box-shadow ${theme.durations.regular}`,
alignItems: 'flex-start', }}
justifyContent: 'flex-end', onClick={(event) => {
padding: 16, if (tool.disabled) event.preventDefault();
gap: 4, }}
transition: `box-shadow ${theme.durations.regular}`, >
}} <span
onClick={(event) => { style={{
if (tool.disabled) event.preventDefault(); fontSize: 32,
}} color: theme.colors.textHighContrast,
> display: 'flex',
<span alignItems: 'center',
style={{ justifyContent: 'center',
fontSize: 32, }}
color: theme.colors.textHighContrast, >
display: 'flex', {tool.title}
alignItems: 'center', <CaretRightIcon style={{ width: '1em', height: '1em' }} />
justifyContent: 'center', </span>
}} <span
> style={{
{tool.title} color: theme.colors.textLowContrast,
<CaretRightIcon style={{ width: '1em', height: '1em' }} /> fontSize: 16,
</span> }}
<span >
style={{ {tool.disabled ? 'Coming soon!' : tool.description}
color: theme.colors.textLowContrast, </span>
fontSize: 16, </Link>
}} ))}
> </Flex>
{tool.disabled ? 'Coming soon!' : tool.description}
</span>
</Link>
),
query: `${tool.title} ${tool.description}`,
}) satisfies SearchItem,
)}
/>
</Flex> </Flex>
<Text size="1" color="gray" align="center">
Images courtesy of Wargaming Art Department
</Text>
</PageWrapper> </PageWrapper>
); );
} }
@@ -0,0 +1,67 @@
import { LoopIcon, TrashIcon } from '@radix-ui/react-icons';
import { Dialog, Flex, IconButton } from '@radix-ui/themes';
import { use, useState } from 'react';
import { provisionDefinitions } from '../../../../core/blitzkrieg/provisionDefinitions';
import { tankToCompareMember } from '../../../../core/blitzkrieg/tankToCompareMember';
import { mutateCompareTemporary } from '../../../../stores/compare';
import { TankSearch } from '../../tankopedia/components/TankSearch';
interface TankControlProps {
index: number;
}
export function TankControl({ index }: TankControlProps) {
const awaitedProvisionDefinitions = use(provisionDefinitions);
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
return (
<Flex gap="3" justify="center" style={{ width: '100%' }}>
<IconButton
variant="ghost"
onClick={() => {
mutateCompareTemporary((draft) => {
draft.members.splice(index, 1);
draft.sorting = undefined;
});
}}
>
<TrashIcon />
</IconButton>
<Dialog.Root
open={switchTankDialogOpen}
onOpenChange={setSwitchTankDialogOpen}
>
<Dialog.Trigger>
<IconButton variant="ghost">
<LoopIcon />
</IconButton>
</Dialog.Trigger>
<Dialog.Content>
<Flex gap="4" direction="column">
<Flex
direction="column"
gap="4"
style={{ flex: 1 }}
justify="center"
>
<TankSearch
compact
onSelect={(tank) => {
mutateCompareTemporary((draft) => {
draft.members[index] = tankToCompareMember(
tank,
awaitedProvisionDefinitions,
);
draft.sorting = undefined;
});
setSwitchTankDialogOpen(false);
}}
/>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
);
}
File diff suppressed because it is too large. Load diff
+6 -17
View File
@@ -1,33 +1,22 @@
import { blackA } from '@radix-ui/colors'; import { blackA } from '@radix-ui/colors';
import { Flex, Text } from '@radix-ui/themes'; import { Flex, Spinner, Text } from '@radix-ui/themes';
import { PuffLoader } from 'react-spinners';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
const NAV_HEIGHT = 52.65; const NAV_HEIGHT = 52.65;
interface LoaderProps { interface LoaderProps {
naked?: boolean; naked?: boolean;
color?: string;
progress?: number; progress?: number;
} }
export function Loader({ naked, color, progress }: LoaderProps) { export function Loader({ naked, progress }: LoaderProps) {
const animation = ( const animation = (
<div style={{ position: 'relative' }}> <Flex align="center" gap="2">
<Spinner />
{progress !== undefined && ( {progress !== undefined && (
<Text <Text color="gray">{Math.round(progress * 100)}%</Text>
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(calc(-50% + 5px), calc(-50% + 5px))',
}}
>
{Math.round(progress * 100)}%
</Text>
)} )}
<PuffLoader color={color ?? theme.colors.solidBackground} /> </Flex>
</div>
); );
if (naked) return animation; if (naked) return animation;
+22 -2
View File
@@ -19,7 +19,19 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
return ( return (
<> <>
{tool && <title>{tool.title}</title>} {tool && (
<>
<title>{tool.title}</title>
{tool.pageDescription !== undefined && (
<meta name="description" content={tool.pageDescription} />
)}
<meta
property="og:image"
content={`/assets/banners/${toolId}.webp`}
/>
</>
)}
{pathSplit.length === 3 && ( {pathSplit.length === 3 && (
<div <div
@@ -73,7 +85,15 @@ export default function ToolsLayout({ children }: ToolsLayoutProps) {
</div> </div>
)} )}
<Suspense fallback={<Loader />}>{children}</Suspense> <Suspense
fallback={
<div style={{ flex: 1 }}>
<Loader />
</div>
}
>
{children}
</Suspense>
</> </>
); );
} }
@@ -53,29 +53,26 @@ export function PageTurner({
<CaretLeftIcon /> <CaretLeftIcon />
)} )}
</Button> </Button>
<TextField.Root>
<TextField.Root
type="number"
ref={pageInput}
style={{ textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot> <TextField.Slot>Page</TextField.Slot>
<TextField.Input
type="number"
ref={pageInput}
style={{ width: 64, textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(
0,
Math.min(pages - 1, event.target.valueAsNumber - 1),
),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot>out of {pages}</TextField.Slot> <TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root> </TextField.Root>
<Button <Button
+93 -98
View File
@@ -605,7 +605,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={scoreInput} ref={scoreInput}
type="number" type="number"
placeholder="Type a score..." placeholder="Type a score..."
@@ -638,7 +638,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={positionInput} ref={positionInput}
type="number" type="number"
placeholder="Type a position..." placeholder="Type a position..."
@@ -759,105 +759,100 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" direction="column"> <Flex gap="4" direction="column">
<TextField.Root> <TextField.Root
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList = await searchCurrentRatingPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter((player) => !Number.isNaN(player.number)),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList =
await searchCurrentRatingPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter(
(player) => !Number.isNaN(player.number),
),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
/>
</TextField.Root> </TextField.Root>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
+12 -13
View File
@@ -37,19 +37,18 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
<Heading size="4">Edit title</Heading> <Heading size="4">Edit title</Heading>
<Flex gap="2" style={{ width: '100%' }}> <Flex gap="2" style={{ width: '100%' }}>
<TextField.Root style={{ flex: 1 }}> <TextField.Root
<TextField.Input style={{ flex: 1 }}
ref={titleInput} ref={titleInput}
defaultValue={session.title} defaultValue={session.title}
placeholder="Enter title..." placeholder="Enter title..."
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && titleInput.current) { if (event.key === 'Enter' && titleInput.current) {
setShowTitleEditor(false); setShowTitleEditor(false);
useSession.setState({ title: titleInput.current!.value }); useSession.setState({ title: titleInput.current!.value });
} }
}} }}
/> />
</TextField.Root>
<Dialog.Close> <Dialog.Close>
<Button <Button
+42 -29
View File
@@ -12,6 +12,7 @@ import {
Checkbox, Checkbox,
DropdownMenu, DropdownMenu,
Flex, Flex,
Heading,
Text, Text,
TextField, TextField,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
@@ -56,38 +57,36 @@ export default function Page() {
<PageWrapper color="blue"> <PageWrapper color="blue">
<div className={styles.toolBar}> <div className={styles.toolBar}>
<div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}> <div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}>
<TextField.Root> <TextField.Root
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 (https://github.com/radix-ui/primitives/issues/1969) is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
>
<TextField.Slot> <TextField.Slot>
<PersonIcon height="16" width="16" /> <PersonIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
/>
</TextField.Root> </TextField.Root>
<DropdownMenu.Root open={showSearchResults} modal={false}> <DropdownMenu.Root open={showSearchResults} modal={false}>
@@ -251,6 +250,20 @@ export default function Page() {
</div> </div>
<SessionPage naked={false} /> <SessionPage naked={false} />
{!session.isTracking && (
<Flex
align="center"
justify="center"
direction="column"
style={{ flex: 1 }}
>
<Heading color="gray">No player selected</Heading>
<Text color="gray">
Search for a <PersonIcon /> Player to get started
</Text>
</Flex>
)}
</PageWrapper> </PageWrapper>
); );
} }
@@ -1,15 +1,5 @@
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons'; import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import { import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
Button,
Card,
Dialog,
Flex,
Heading,
Tabs,
Text,
Tooltip,
} from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton'; import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
@@ -37,58 +27,70 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
const [tab, setTab] = useState('search'); const [tab, setTab] = useState('search');
const [antagonistSelectorOpen, setAntagonistSelectorVisible] = const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
useState(false); useState(false);
const router = useRouter();
if (mode !== 'armor') return null; if (mode !== 'armor') return null;
return ( return (
<Card <Flex justify="center">
style={ <Card
floating style={
? { floating
position: 'absolute', ? {
bottom: 8, position: 'absolute',
left: '50%', bottom: 8,
transform: 'translateX(-50%)', left: '50%',
} transform: 'translateX(-50%)',
: undefined }
} : undefined
> }
<Flex align="center" justify="between" gap="6"> >
<Flex> <Flex
{antagonist.gun.shells.map((shell, index) => { align="center"
return ( justify="center"
<ShellButton wrap="wrap"
selected={antagonist.shell.id === shell.id} style={{
shell={shell.icon} gap: 'min(5vw, 32px)',
rowChild }}
first={index === 0} >
key={shell.id} <Flex>
last={index === antagonist.gun.shells.length - 1} {antagonist.gun.shells.map((shell, index) => {
onClick={() => { return (
mutateDuel((draft) => { <ShellButton
draft.antagonist!.shell = shell; selected={antagonist.shell.id === shell.id}
mutateTankopediaTemporary((draft) => { shell={shell.icon}
draft.shot = undefined; rowChild
first={index === 0}
key={shell.id}
last={index === antagonist.gun.shells.length - 1}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.shell = shell;
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}); });
}); }}
}} />
/> );
); })}
})} </Flex>
</Flex>
<Flex align="center" gap="4"> <Button
{!floating && <Text>Versus</Text>} variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
{floating && ( <Flex align="center" gap="4">
<Flex gap="2" align="center">
Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} />
</Flex>
)}
{!floating && (
<Dialog.Root <Dialog.Root
open={antagonistSelectorOpen} open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorVisible} onOpenChange={setAntagonistSelectorVisible}
@@ -96,9 +98,8 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Dialog.Trigger> <Dialog.Trigger>
<Button variant="ghost"> <Button variant="ghost">
<Flex gap="2" align="center"> <Flex gap="2" align="center">
{antagonist.tank.name} Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} /> <SmallTankIcon id={antagonist.tank.id} size={16} />
<CaretRightIcon />
</Flex> </Flex>
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
@@ -160,61 +161,51 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Flex gap="2" wrap="wrap"> <Flex gap="2" wrap="wrap">
<Flex> <Flex>
{antagonist.tank.turrets.map((turret, index) => ( {antagonist.tank.turrets.map((turret, index) => (
<Tooltip content={turret.name} key={turret.id}> <ModuleButton
<ModuleButton rowChild
rowChild first={index === 0}
first={index === 0} last={
last={ index === antagonist.tank.turrets.length - 1
index === }
antagonist.tank.turrets.length - 1 key={turret.id}
} onClick={() => {
key={turret.id} mutateDuel((draft) => {
onClick={() => { draft.antagonist!.turret = turret;
mutateDuel((draft) => { draft.antagonist!.gun =
draft.antagonist!.turret = turret; turret.guns.at(-1)!;
draft.antagonist!.gun = draft.antagonist!.shell =
turret.guns.at(-1)!; draft.antagonist!.gun.shells[0];
draft.antagonist!.shell = });
draft.antagonist!.gun.shells[0]; mutateTankopediaTemporary((draft) => {
}); draft.shot = undefined;
mutateTankopediaTemporary((draft) => { });
draft.shot = undefined; }}
}); selected={antagonist.turret.id === turret.id}
}} discriminator={
selected={ TIER_ROMAN_NUMERALS[turret.tier]
antagonist.turret.id === turret.id }
} module="turret"
discriminator={ />
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
</Tooltip>
))} ))}
</Flex> </Flex>
<Flex> <Flex>
{antagonist.turret.guns.map((gun, index) => ( {antagonist.turret.guns.map((gun, index) => (
<Tooltip content={gun.name} key={gun.id}> <ModuleButton
<ModuleButton rowChild
rowChild first={index === 0}
first={index === 0} last={
last={ index === antagonist.turret.guns.length - 1
index === }
antagonist.turret.guns.length - 1 onClick={() => {
} mutateDuel((draft) => {
onClick={() => { draft.antagonist!.gun = gun;
mutateDuel((draft) => { draft.antagonist!.shell = gun.shells[0];
draft.antagonist!.gun = gun; });
draft.antagonist!.shell = gun.shells[0]; }}
}); selected={antagonist.gun.id === gun.id}
}} discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
selected={antagonist.gun.id === gun.id} module="gun"
discriminator={ />
TIER_ROMAN_NUMERALS[gun.tier]
}
module="gun"
/>
</Tooltip>
))} ))}
</Flex> </Flex>
</Flex> </Flex>
@@ -251,24 +242,9 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
</Tabs.Root> </Tabs.Root>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
)} </Flex>
</Flex> </Flex>
</Card>
<Button </Flex>
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
</Flex>
</Card>
); );
} }
@@ -1,5 +1,5 @@
import { Flex } from '@radix-ui/themes';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import * as styles from '../../../../page.css';
interface ConfigurationChildWrapperProps { interface ConfigurationChildWrapperProps {
children: ReactNode; children: ReactNode;
@@ -8,5 +8,9 @@ interface ConfigurationChildWrapperProps {
export function ConfigurationChildWrapper({ export function ConfigurationChildWrapper({
children, children,
}: ConfigurationChildWrapperProps) { }: ConfigurationChildWrapperProps) {
return <div className={styles.configurationChild}>{children}</div>; return (
<Flex gap="2" direction="column">
{children}
</Flex>
);
} }
@@ -1,6 +1,14 @@
import { Flex, Heading } from '@radix-ui/themes'; import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton'; import { ConsumablesManager } from '../../../../../../../components/ConsumablesManager';
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity'; import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
import { consumableDefinitions } from '../../../../../../../core/blitzkrieg/consumableDefinitions'; import { consumableDefinitions } from '../../../../../../../core/blitzkrieg/consumableDefinitions';
import { useEquipment } from '../../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../../hooks/useEquipment';
@@ -27,49 +35,53 @@ export function Consumables() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Consumables</Heading> <Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Consumables</Heading>
<Flex wrap="wrap"> <Popover.Root>
{consumablesList.map((consumable, index) => { <Popover.Trigger>
const selected = consumables.includes(consumable.id); <IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
return ( <Popover.Content>
<ConsumableButton <Text>
duration={ <Text color="amber">Yellow consumables</Text> do not affect the
typeof consumable.duration === 'number' statistics.
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1) </Text>
: undefined </Popover.Content>
} </Popover.Root>
cooldown={ </Flex>
consumable.cooldown *
(hasConsumableDeliverySystem ? 0.85 : 1) * <Button
(1 - cooldownBooster * 0.1) variant="ghost"
} color="red"
key={consumable.id} onClick={() => {
first={index === 0} mutateDuel((draft) => {
last={index === consumablesList.length - 1} draft.protagonist!.consumables = [];
rowChild });
disabled={ }}
protagonist.tank.consumables === consumables.length && !selected >
} Clear
consumable={consumable.id} </Button>
selected={selected}
onClick={() => {
mutateDuel((draft) => {
if (selected) {
draft.protagonist!.consumables =
draft.protagonist!.consumables.filter(
(id) => id !== consumable.id,
);
} else {
draft.protagonist!.consumables.push(consumable.id);
}
});
}}
/>
);
})}
</Flex> </Flex>
<ConsumablesManager
consumables={consumablesList}
selected={consumables}
cooldownBooster={cooldownBooster}
disabled={protagonist.tank.consumables === consumables.length}
hasConsumableDeliverySystem={hasConsumableDeliverySystem}
hasHighEndConsumables={hasHighEndConsumables}
timers
onChange={(consumables) => {
mutateDuel((draft) => {
draft.protagonist!.consumables = consumables;
});
}}
/>
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
} }
@@ -0,0 +1,49 @@
import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react';
import { EquipmentManager } from '../../../../../../../components/EquipmentManager';
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Equipment() {
const protagonist = useDuel((state) => state.protagonist!);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const equipmentPreset =
awaitedEquipmentDefinitions.presets[protagonist.tank.equipment];
const equipmentMatrix = useDuel(
(state) => state.protagonist!.equipmentMatrix,
);
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Heading size="4">Equipment</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.equipmentMatrix.forEach((row) => {
row.forEach((_, index) => {
row[index] = 0;
});
});
});
}}
>
Clear
</Button>
</Flex>
<EquipmentManager
matrix={equipmentMatrix}
preset={equipmentPreset}
onChange={(matrix) => {
mutateDuel((draft) => {
draft.protagonist!.equipmentMatrix = matrix;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -1,62 +0,0 @@
import { Flex, Heading } from '@radix-ui/themes';
import { use } from 'react';
import { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton';
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Equipments() {
const protagonist = useDuel((state) => state.protagonist!);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const equipmentRows =
awaitedEquipmentDefinitions.presets[protagonist.tank.equipment];
const equipmentMatrix = useDuel((state) => state.protagonist!.equipment);
return (
<ConfigurationChildWrapper>
<Heading size="4">Equipments</Heading>
<Flex direction="column" gap="2">
{equipmentRows.map((equipmentRow, rowIndex) => (
<Flex gap="2" key={rowIndex}>
{equipmentRow.map((equipment, columnIndex) => (
<Flex key={columnIndex}>
<EquipmentButton
equipment={equipment[0]}
first
rowChild
selected={equipmentMatrix[rowIndex][columnIndex] === -1}
onClick={() => {
mutateDuel((draft) => {
if (equipmentMatrix[rowIndex][columnIndex] === -1) {
draft.protagonist!.equipment[rowIndex][columnIndex] = 0;
} else {
draft.protagonist!.equipment[rowIndex][columnIndex] =
-1;
}
});
}}
/>
<EquipmentButton
equipment={equipment[1]}
last
rowChild
selected={equipmentMatrix[rowIndex][columnIndex] === 1}
onClick={() => {
mutateDuel((draft) => {
if (equipmentMatrix[rowIndex][columnIndex] === 1) {
draft.protagonist!.equipment[rowIndex][columnIndex] = 0;
} else {
draft.protagonist!.equipment[rowIndex][columnIndex] = 1;
}
});
}}
/>
</Flex>
))}
</Flex>
))}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -87,36 +87,35 @@ export function Miscellaneous() {
direction="column" direction="column"
gap="1" gap="1"
justify="end" justify="end"
style={{ width: 180 }} style={{ width: 120 }}
> >
<TextField.Root> <TextField.Root
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist!.crewMastery = clamp(
newValue / 100,
0.5,
1,
);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
>
<TextField.Slot>Crew</TextField.Slot> <TextField.Slot>Crew</TextField.Slot>
<TextField.Input
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist!.crewMastery = clamp(
newValue / 100,
0.5,
1,
);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
/>
<TextField.Slot>%</TextField.Slot> <TextField.Slot>%</TextField.Slot>
</TextField.Root> </TextField.Root>
@@ -1,108 +1,79 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, Flex, Heading } from '@radix-ui/themes';
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton'; import { ModuleManager } from '../../../../../../../components/ModuleManager';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia'; import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper'; import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Modules() { export function Modules() {
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const hasUpgrades =
protagonist.tank.turrets.length > 1 ||
protagonist.tank.turrets[0].guns.length > 1 ||
protagonist.tank.engines.length > 1 ||
protagonist.tank.tracks.length > 1;
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Modules</Heading> <Flex gap="4" align="center">
<Heading size="4">Modules</Heading>
<Flex gap="2" wrap="wrap"> {hasUpgrades && (
<Flex> <>
{protagonist.tank.turrets.map((turret, index) => { <Button
return ( variant="ghost"
<ModuleButton color="red"
key={turret.id} onClick={() => {
selected={protagonist.turret.id === turret.id} mutateDuel((draft) => {
module="turret" draft.protagonist!.turret =
discriminator={TIER_ROMAN_NUMERALS[turret.tier]} draft.protagonist!.tank.turrets[0];
first={index === 0} draft.protagonist!.gun = draft.protagonist!.turret.guns[0];
last={index === protagonist.tank.turrets.length - 1} draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
rowChild draft.protagonist!.engine =
onClick={() => { draft.protagonist!.tank.engines[0];
mutateDuel((draft) => { draft.protagonist!.track = draft.protagonist!.tank.tracks[0];
draft.protagonist!.turret = turret; });
draft.protagonist!.gun = turret.guns.at(-1)!; }}
draft.protagonist!.shell = turret.guns.at(-1)!.shells[0]; >
}); Stock
mutateTankopediaTemporary((draft) => { </Button>
draft.shot = undefined; <Button
}); variant="ghost"
}} onClick={() => {
/> mutateDuel((draft) => {
); draft.protagonist!.turret =
})} draft.protagonist!.tank.turrets.at(-1)!;
</Flex> draft.protagonist!.gun =
draft.protagonist!.turret.guns.at(-1)!;
<Flex> draft.protagonist!.shell =
{protagonist.turret.guns.map((gun, index) => { draft.protagonist!.gun.shells.at(-1)!;
return ( draft.protagonist!.engine =
<ModuleButton draft.protagonist!.tank.engines.at(-1)!;
key={gun.id} draft.protagonist!.track =
module="gun" draft.protagonist!.tank.tracks.at(-1)!;
discriminator={TIER_ROMAN_NUMERALS[gun.tier]} });
selected={protagonist.gun.id === gun.id} }}
first={index === 0} >
last={index === protagonist.turret.guns.length - 1} Upgrade
rowChild </Button>
onClick={() => { </>
mutateDuel((draft) => { )}
draft.protagonist!.gun = gun;
draft.protagonist!.shell = gun.shells[0];
});
}}
/>
);
})}
</Flex>
<Flex>
{protagonist.tank.engines.map((engine, index) => {
return (
<ModuleButton
key={engine.id}
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
selected={protagonist.engine.id === engine.id}
first={index === 0}
last={index === protagonist.tank.engines.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.engine = engine;
});
}}
/>
);
})}
</Flex>
<Flex>
{protagonist.tank.tracks.map((track, index) => {
return (
<ModuleButton
key={track.id}
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
selected={protagonist.track.id === track.id}
first={index === 0}
last={index === protagonist.tank.tracks.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.track = track;
});
}}
/>
);
})}
</Flex>
</Flex> </Flex>
<ModuleManager
tank={protagonist.tank}
turret={protagonist.turret}
gun={protagonist.gun}
shell={protagonist.shell}
engine={protagonist.engine}
track={protagonist.track}
onChange={(modules) => {
mutateDuel((draft) => {
draft.protagonist = { ...draft.protagonist!, ...modules };
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
/>
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
} }
@@ -1,6 +1,6 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton'; import { ProvisionsManager } from '../../../../../../../components/ProvisionsManager';
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions'; import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
import { provisionDefinitions } from '../../../../../../../core/blitzkrieg/provisionDefinitions'; import { provisionDefinitions } from '../../../../../../../core/blitzkrieg/provisionDefinitions';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
@@ -18,37 +18,31 @@ export function Provisions() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Provisions</Heading> <Flex gap="4" align="center">
<Heading size="4">Provisions</Heading>
<Flex wrap="wrap"> <Button
{provisionsList.map((provision, index) => { variant="ghost"
const selected = provisions.includes(provision.id); color="red"
onClick={() => {
return ( mutateDuel((draft) => {
<ProvisionButton draft.protagonist!.provisions = [];
key={provision.id} });
first={index === 0} }}
last={index === provisionsList.length - 1} >
rowChild Clear
disabled={tank.provisions === provisions.length && !selected} </Button>
provision={provision.id}
selected={selected}
onClick={() => {
mutateDuel((draft) => {
if (selected) {
draft.protagonist!.provisions =
draft.protagonist!.provisions.filter(
(id) => id !== provision.id,
);
} else {
draft.protagonist!.provisions.push(provision.id);
}
});
}}
/>
);
})}
</Flex> </Flex>
<ProvisionsManager
provisions={provisionsList.map((provision) => provision.id)}
selected={provisions}
disabled={tank.provisions === provisions.length}
onChange={(provisions) => {
mutateDuel((draft) => {
draft.protagonist!.provisions = provisions;
});
}}
/>
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
} }
@@ -0,0 +1,8 @@
export const permanentSkills = [
'smooth_turn',
'soft_recoil',
'smooth_driving',
'smooth_turret',
'virtuoso',
'camouflage',
];
@@ -0,0 +1,98 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Kbd,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { CrewSkillManager } from '../../../../../../../../components/CrewSkillManager';
import { createDefaultSkills } from '../../../../../../../../core/blitzkrieg/createDefaultSkills';
import { skillDefinitions } from '../../../../../../../../core/blitzkrieg/skillDefinitions';
import {
mutateTankopediaTemporary,
useTankopediaTemporary,
} from '../../../../../../../../stores/tankopedia';
import { ConfigurationChildWrapper } from '../ConfigurationChildWrapper';
export function Skills() {
const awaitedSkillDefinitions = use(skillDefinitions);
const skillLevels = useTankopediaTemporary((state) => state.skills);
if (Object.keys(skillLevels).length === 0) {
mutateTankopediaTemporary((draft) => {
draft.skills = createDefaultSkills(awaitedSkillDefinitions);
});
return null;
}
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Crew skills</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Text>
<Text color="amber">Yellow skills</Text> only apply under
special circumstances.
</Text>
<Text>
Hold <Kbd>Shift</Kbd> to quickly toggle between level 0 and 7.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 0;
});
});
}}
>
Clear
</Button>
<Button
variant="ghost"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 7;
});
});
}}
>
Maximize
</Button>
</Flex>
<CrewSkillManager
skillLevels={skillLevels}
onChange={(skills) => {
mutateTankopediaTemporary((draft) => {
draft.skills = skills;
});
}}
/>
</ConfigurationChildWrapper>
);
}
@@ -1,49 +1,66 @@
import { AccessibilityIcon } from '@radix-ui/react-icons'; import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes'; import {
Flex,
Heading,
IconButton,
Popover,
Slider,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { use, useEffect, useRef, useState } from 'react'; import { use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils'; import { lerp } from 'three/src/math/MathUtils';
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton'; import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits'; import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive'; import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { coefficient } from '../../../../../../core/blitzkrieg/coefficient'; import { coefficient } from '../../../../../../core/blitzkrieg/coefficient';
import { degressiveStat } from '../../../../../../core/blitzkrieg/degressiveStat'; import { equipmentDefinitions } from '../../../../../../core/blitzkrieg/equipmentDefinitions';
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions'; import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
import { normalizeBoundingBox } from '../../../../../../core/blitzkrieg/normalizeBoundingBox'; import { provisionDefinitions } from '../../../../../../core/blitzkrieg/provisionDefinitions';
import { progressiveStat } from '../../../../../../core/blitzkrieg/progressiveStat'; import { tankCharacteristics } from '../../../../../../core/blitzkrieg/tankCharacteristics';
import { resolveDpm } from '../../../../../../core/blitzkrieg/resolveDpm';
import { sum } from '../../../../../../core/blitzkrieg/sum';
import { import {
CREW_MEMBER_NAMES, CREW_MEMBER_NAMES,
GUN_TYPE_NAMES, GUN_TYPE_NAMES,
SHELL_NAMES, SHELL_NAMES,
} from '../../../../../../core/blitzkrieg/tankDefinitions/constants'; } from '../../../../../../core/blitzkrieg/tankDefinitions/constants';
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
import { useConsumable } from '../../../../../../hooks/useConsumable';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useProvision } from '../../../../../../hooks/useProvision'; import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useProvisions } from '../../../../../../hooks/useProvisions';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { Info } from './components/Info'; import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta'; import { InfoWithDelta } from './components/InfoWithDelta';
export function Characteristics() { export function Characteristics() {
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const { tank, turret, gun, engine, track, shell } = useDuel( const awaitedProvisionDefinitions = use(provisionDefinitions);
(state) => state.protagonist!, const crewSkills = useTankopediaTemporary((state) => state.skills);
); const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const isFullScreen = useFullScreen();
const hasImprovedVentilation = useEquipment(102); const hasImprovedVentilation = useEquipment(102);
const provisions = useProvisions();
const crewMastery = useDuel((state) => state.protagonist!.crewMastery); const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
const [penetrationDistance, setPenetrationDistance] = useState(250);
const provisions = useDuel((state) => state.protagonist!.provisions);
const provisionCrewBonus = const provisionCrewBonus =
provisions.reduce( provisions.reduce(
(total, provision) => (total, provision) =>
provision.crew ? total + provision.crew / 100 : total, awaitedProvisionDefinitions[provision].crew
? total + awaitedProvisionDefinitions[provision].crew! / 100
: total,
0, 0,
) + (hasImprovedVentilation ? 0.05 : 0); ) + (hasImprovedVentilation ? 0.05 : 0);
const commanderMastery = crewMastery + provisionCrewBonus; const commanderMastery = crewMastery + provisionCrewBonus;
const crewMemberMastery = commanderMastery * 1.1; const crewMemberMastery = commanderMastery * 1.1;
const consumables = useDuel((state) => state.protagonist!.consumables);
const camouflage = useDuel((state) => state.protagonist!.camouflage);
const equipmentMatrix = useDuel(
(state) => state.protagonist!.equipmentMatrix,
);
const { tank, turret, gun, engine, track, shell } = useDuel(
(state) => state.protagonist!,
);
const stockEngine = tank.engines[0]; const stockEngine = tank.engines[0];
const stockTrack = tank.tracks[0]; const stockTrack = tank.tracks[0];
const stockTurret = tank.turrets[0]; const stockTurret = tank.turrets[0];
@@ -51,169 +68,46 @@ export function Characteristics() {
const tankModelDefinition = awaitedModelDefinitions[tank.id]; const tankModelDefinition = awaitedModelDefinitions[tank.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id]; const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id]; const gunModelDefinition = turretModelDefinition.guns[gun.id];
const size = normalizeBoundingBox( const stats = tankCharacteristics(
unionBoundingBox( {
tankModelDefinition.boundingBox, tank,
turretModelDefinition.boundingBox, camouflage,
), consumables,
crewMastery,
crewSkills,
engine,
equipmentMatrix,
gun,
provisions,
shell,
turret,
track,
stockEngine,
stockGun,
stockTrack,
stockTurret,
applyReactive: false,
applyDynamicArmor: false,
applySpallLiner: false,
},
{
equipmentDefinitions: awaitedEquipmentDefinitions,
modelDefinitions: awaitedModelDefinitions,
provisionDefinitions: awaitedProvisionDefinitions,
},
); );
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const weight =
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
const weightTons = weight / 1000;
const stockWeight =
tank.weight +
stockEngine.weight +
stockTrack.weight +
stockTurret.weight +
stockGun.weight;
const [penetrationDistance, setPenetrationDistance] = useState(250);
const camouflage = useDuel((state) => state.protagonist!.camouflage);
const hasRammer = useEquipment(100);
const hasCalibratedShells = useEquipment(103);
const hasEnhancedGunLayingDrive = useEquipment(104);
const hasSupercharger = useEquipment(107);
const hasVerticalStabilizer = useEquipment(105);
const hasRefinedGun = useEquipment(106);
const hasImprovedAssembly = useEquipment(111);
const hasImprovedOptics = useEquipment(114);
const hasImprovedControl = useEquipment(116);
const hasEngineAccelerator = useEquipment(117);
const hasCamouflageNet = useEquipment(115);
const hasImprovedVerticalStabilizer = useEquipment(122);
const hasImprovedSuspension = useEquipment(123);
const penetrationDistanceInput = useRef<HTMLInputElement>(null);
const hasEnginePowerBoost = useConsumable(14);
const hasImprovedEnginePowerBoost = useConsumable(27);
const hasAdrenaline = useConsumable(18);
const hasTungsten = useConsumable(45);
const hasReticleCalibration = useConsumable(28);
const hasShellReloadBoost = useConsumable(29);
const hasStandardFuel = useProvision(18);
const hasImprovedFuel = useProvision(19);
const hasProtectiveKit = useProvision(22);
const hasSandbagArmor = useProvision(28);
const hasEnhancedSandbagArmor = useProvision(29);
const hasImprovedGunPowder = useProvision(46);
const hasGearOil = useProvision(44);
const hasImprovedGearOil = useProvision(45);
const healthCoefficient = coefficient( const hasSupercharger = useEquipment(107);
[hasSandbagArmor, 0.03], const hasCalibratedShells = useEquipment(103);
[hasEnhancedSandbagArmor, 0.06], const hasImprovedVerticalStabilizer = useEquipment(122);
[hasImprovedAssembly, 0.06],
);
const reloadCoefficient =
(coefficient([hasRammer, -0.07]) *
coefficient([true, degressiveStat(crewMemberMastery)])) /
coefficient([hasAdrenaline && gun.type === 'regular', 0.2]);
const penetrationCoefficient = coefficient([ const penetrationCoefficient = coefficient([
hasCalibratedShells, hasCalibratedShells,
isExplosive(shell.type) ? 0.1 : 0.05, isExplosive(shell.type) ? 0.1 : 0.05,
]); ]);
const damageCoefficient = coefficient(
// TODO: add max rolls
[hasTungsten, 0.15],
);
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
const shellVelocityCoefficient = coefficient(
[hasSupercharger, 0.3],
[hasImprovedGunPowder, 0.3],
);
const penetrationLossOverDistanceCoefficient = coefficient([ const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger, hasSupercharger,
-0.5, -0.5,
]); ]);
const aimTimeCoefficient =
coefficient(
[hasEnhancedGunLayingDrive, -0.1],
[hasReticleCalibration, -0.4],
) * coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionMovingCoefficient = coefficient([
hasVerticalStabilizer,
-0.15,
]);
const enginePowerCoefficient = coefficient(
[
hasEngineAccelerator &&
(tank.class === 'lightTank' || tank.class === 'mediumTank'),
0.05,
],
[
hasEngineAccelerator &&
(tank.class === 'heavyTank' || tank.class === 'AT-SPG'),
0.07,
],
[hasEnginePowerBoost, 0.2],
[hasImprovedEnginePowerBoost, 0.4],
[hasStandardFuel, 0.03],
[hasImprovedFuel, 0.1],
[hasGearOil, 0.03],
[hasImprovedGearOil, 0.06],
);
const turretTraverseCoefficient =
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
coefficient([true, progressiveStat(crewMemberMastery)]);
const hullTraverseCoefficient =
coefficient(
[hasImprovedControl, 0.1],
[hasImprovedEnginePowerBoost, 0.05],
) * coefficient([true, progressiveStat(crewMemberMastery)]);
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
const viewRangeCoefficient =
coefficient([
hasImprovedOptics,
tank.class === 'AT-SPG' ? 0.05 : tank.class === 'heavyTank' ? 0.07 : 0.1,
]) * coefficient([true, progressiveStat(commanderMastery)]);
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
const speedForwardsSum = sum(
[hasImprovedEnginePowerBoost, 5],
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const speedBackwardsSum = sum(
[hasImprovedEnginePowerBoost, 10],
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const camouflageSumMoving = sum(
[
hasCamouflageNet,
tank.class === 'heavyTank' ? 0.03 : tank.class === 'AT-SPG' ? 0.07 : 0.05,
],
[
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
);
const camouflageSumStill = sum(
[
hasCamouflageNet,
2 *
(tank.class === 'heavyTank'
? 0.03
: tank.class === 'AT-SPG'
? 0.07
: 0.05),
],
[
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
);
const resolvedEnginePower = engine.power * enginePowerCoefficient;
const dpm = resolveDpm(
gun,
shell,
damageCoefficient,
reloadCoefficient,
intraClipCoefficient,
);
useEffect(() => { useEffect(() => {
if (penetrationDistanceInput.current) { if (penetrationDistanceInput.current) {
@@ -232,6 +126,8 @@ export function Characteristics() {
}); });
}, [hasImprovedVerticalStabilizer]); }, [hasImprovedVerticalStabilizer]);
if (isFullScreen) return null;
return ( return (
<Flex direction="column" gap="8" style={{ width: '100%' }}> <Flex direction="column" gap="8" style={{ width: '100%' }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
@@ -262,19 +158,12 @@ export function Characteristics() {
<Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info> <Info name="Gun type">{GUN_TYPE_NAMES[gun.type]}</Info>
<InfoWithDelta name="DPM" decimals={0} unit="hp / min"> <InfoWithDelta name="DPM" decimals={0} unit="hp / min">
{dpm} {stats.dpm}
</InfoWithDelta> </InfoWithDelta>
{gun.type === 'autoReloader' && ( {gun.type === 'autoReloader' && (
<> <>
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min"> <InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
{gun.reload.at(-1)! < gun.reload.at(-2)! {stats.dpmMaximum!}
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
60
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient)) *
60}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={0} decimals={0}
@@ -282,27 +171,7 @@ export function Characteristics() {
name="Effective at 60s" name="Effective at 60s"
unit="hp / min" unit="hp / min"
> >
{gun.reload.at(-1)! < gun.reload.at(-2)! {stats.dpmEffective!}
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(0, -1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient *
shell.damage.armor *
gun.reload.slice(0, -1).length
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient *
shell.damage.armor *
gun.reload.slice(1).length}
</InfoWithDelta> </InfoWithDelta>
<Info <Info
indent indent
@@ -317,14 +186,17 @@ export function Characteristics() {
/> />
</> </>
)} )}
{gun.type !== 'regular' && (
<InfoWithDelta name="Shells">{stats.shells}</InfoWithDelta>
)}
{gun.type === 'autoReloader' ? ( {gun.type === 'autoReloader' ? (
<> <>
<InfoWithDelta name="Shells">{gun.reload.length}</InfoWithDelta>
<Info indent name="Most optimal"> <Info indent name="Most optimal">
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1} {stats.mostOptimalShellIndex}
</Info> </Info>
<Info name="Shell reloads" unit="s" /> <Info name="Shell reloads" unit="s" />
{gun.reload.map((reload, index) => ( {stats.shellReloads!.map((reload, index) => (
<InfoWithDelta <InfoWithDelta
key={index} key={index}
indent indent
@@ -332,7 +204,7 @@ export function Characteristics() {
decimals={2} decimals={2}
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{reload * reloadCoefficient} {reload}
</InfoWithDelta> </InfoWithDelta>
))} ))}
</> </>
@@ -343,7 +215,7 @@ export function Characteristics() {
unit="s" unit="s"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.reload * reloadCoefficient} {stats.shellReload!}
</InfoWithDelta> </InfoWithDelta>
)} )}
{gun.type === 'autoLoader' || {gun.type === 'autoLoader' ||
@@ -355,14 +227,14 @@ export function Characteristics() {
unit="s" unit="s"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.intraClip * intraClipCoefficient} {stats.intraClip!}
</InfoWithDelta> </InfoWithDelta>
))} ))}
<InfoWithDelta name="Caliber" decimals={0} unit="mm"> <InfoWithDelta name="Caliber" decimals={0} unit="mm">
{shell.caliber} {stats.caliber}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={0} name="Penetration" unit="mm"> <InfoWithDelta decimals={0} name="Penetration" unit="mm">
{resolveNearPenetration(shell.penetration) * penetrationCoefficient} {stats.penetration}
</InfoWithDelta> </InfoWithDelta>
{typeof shell.penetration !== 'number' && ( {typeof shell.penetration !== 'number' && (
<> <>
@@ -390,42 +262,42 @@ export function Characteristics() {
setPenetrationDistance(value); setPenetrationDistance(value);
}, 500)} }, 500)}
/> />
<TextField.Root style={{ width: 64 }}> <TextField.Root
<TextField.Input style={{ width: 64 }}
ref={penetrationDistanceInput} ref={penetrationDistanceInput}
defaultValue={penetrationDistance} defaultValue={penetrationDistance}
onBlur={() => { onBlur={() => {
setPenetrationDistance( setPenetrationDistance(
Math.min( Math.min(
parseInt(penetrationDistanceInput.current!.value), parseInt(penetrationDistanceInput.current!.value),
500, 500,
), ),
); );
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
/> >
<TextField.Slot>m</TextField.Slot> <TextField.Slot side="right">m</TextField.Slot>
</TextField.Root> </TextField.Root>
</Flex> </Flex>
</> </>
)} )}
<InfoWithDelta name="Damage" unit="hp" decimals={0}> <InfoWithDelta name="Damage" unit="hp" decimals={0}>
{shell.damage.armor * damageCoefficient} {stats.damage}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="Module damage" unit="hp" decimals={0}> <InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{shell.damage.module * damageCoefficient} {stats.moduleDamage}
</InfoWithDelta> </InfoWithDelta>
{isExplosive(shell.type) && ( {isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}> <InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{shell.explosionRadius!} {stats.explosionRadius!}
</InfoWithDelta> </InfoWithDelta>
)} )}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}> <InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{shell.speed * shellVelocityCoefficient} {stats.shellVelocity}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={2} decimals={2}
@@ -433,7 +305,7 @@ export function Characteristics() {
unit="s" unit="s"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.aimTime * aimTimeCoefficient} {stats.aimTime}
</InfoWithDelta> </InfoWithDelta>
<Info name="Dispersion at 100m" /> <Info name="Dispersion at 100m" />
<InfoWithDelta <InfoWithDelta
@@ -443,7 +315,7 @@ export function Characteristics() {
unit="m" unit="m"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.base * dispersionStillCoefficient} {stats.dispersion}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
prefix="+ " prefix="+ "
@@ -452,7 +324,7 @@ export function Characteristics() {
name="Moving" name="Moving"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{track.dispersion.move * dispersionMovingCoefficient} {stats.dispersionMoving}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -461,7 +333,7 @@ export function Characteristics() {
name="Hull traversing" name="Hull traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{track.dispersion.traverse * dispersionMovingCoefficient} {stats.dispersionHullTraversing}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -470,7 +342,7 @@ export function Characteristics() {
name="Turret traversing" name="Turret traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.traverse * dispersionMovingCoefficient} {stats.dispersionTurretTraversing}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -479,7 +351,7 @@ export function Characteristics() {
name="After shooting" name="After shooting"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.shot * dispersionMovingCoefficient} {stats.dispersionShooting}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={1} decimals={1}
@@ -489,46 +361,42 @@ export function Characteristics() {
unit="scalar" unit="scalar"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.damaged * dispersionMovingCoefficient} {stats.dispersionGunDamaged}
</InfoWithDelta> </InfoWithDelta>
<Info name="Gun flexibility" unit="°" /> <Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression"> <InfoWithDelta decimals={1} indent name="Depression">
{gunModelDefinition.pitch.max + {stats.gunDepression}
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation"> <InfoWithDelta decimals={1} indent name="Elevation">
{-gunModelDefinition.pitch.min - {stats.gunElevation}
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0)}
</InfoWithDelta> </InfoWithDelta>
{gunModelDefinition.pitch.front && ( {gunModelDefinition.pitch.front && (
<> <>
<InfoWithDelta decimals={1} indent name="Frontal depression"> <InfoWithDelta decimals={1} indent name="Frontal depression">
{gunModelDefinition.pitch.front.max} {stats.gunFrontalDepression!}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Frontal elevation"> <InfoWithDelta decimals={1} indent name="Frontal elevation">
{-gunModelDefinition.pitch.front.min} {stats.gunFrontalElevation!}
</InfoWithDelta> </InfoWithDelta>
</> </>
)} )}
{gunModelDefinition.pitch.back && ( {gunModelDefinition.pitch.back && (
<> <>
<InfoWithDelta decimals={1} indent name="Rear depression"> <InfoWithDelta decimals={1} indent name="Rear depression">
{gunModelDefinition.pitch.back.max} {stats.gunRearDepression!}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Rear elevation"> <InfoWithDelta decimals={1} indent name="Rear elevation">
{-gunModelDefinition.pitch.back.min} {stats.gunRearElevation!}
</InfoWithDelta> </InfoWithDelta>
</> </>
)} )}
{turretModelDefinition.yaw && ( {turretModelDefinition.yaw && (
<> <>
<InfoWithDelta decimals={1} indent name="Azimuth left"> <InfoWithDelta decimals={1} indent name="Azimuth left">
{-turretModelDefinition.yaw.min} {stats.azimuthLeft!}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Azimuth right"> <InfoWithDelta decimals={1} indent name="Azimuth right">
{turretModelDefinition.yaw.max} {stats.azimuthRight!}
</InfoWithDelta> </InfoWithDelta>
</> </>
)} )}
@@ -538,29 +406,13 @@ export function Characteristics() {
<Heading size="5">Maneuverability</Heading> <Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="km/hr" /> <Info name="Speed" unit="km/hr" />
<InfoWithDelta decimals={0} indent name="Forwards"> <InfoWithDelta decimals={0} indent name="Forwards">
{tank.speed.forwards + speedForwardsSum} {stats.speedForwards}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={0} indent name="Backwards"> <InfoWithDelta decimals={0} indent name="Backwards">
{tank.speed.backwards + speedBackwardsSum} {stats.speedBackwards}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={0} name="Power" unit="hp"> <InfoWithDelta decimals={0} name="Engine power" unit="hp">
{resolvedEnginePower} {stats.enginePower}
</InfoWithDelta>
<Info name="Power to weight ratio" unit="hp/tn" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{resolvedEnginePower /
weightTons /
(track.resistance.hard * resistanceCoefficient)}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{resolvedEnginePower /
weightTons /
(track.resistance.medium * resistanceCoefficient)}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{resolvedEnginePower /
weightTons /
(track.resistance.soft * resistanceCoefficient)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={1} decimals={1}
@@ -568,76 +420,98 @@ export function Characteristics() {
unit="tn" unit="tn"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{weightTons} {stats.weight}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={0}> <Info name="Power to weight ratio" unit="hp/tn" />
{turret.traverseSpeed * turretTraverseCoefficient}
</InfoWithDelta>
<Info name="Effective traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain"> <InfoWithDelta decimals={1} indent name="On hard terrain">
{(resolvedEnginePower / stockEngine.power) * {stats.powerToWeightRatioHardTerrain}
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard /
(track.resistance.hard * resistanceCoefficient)) *
(stockWeight / weight)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain"> <InfoWithDelta decimals={1} indent name="On medium terrain">
{(resolvedEnginePower / stockEngine.power) * {stats.powerToWeightRatioMediumTerrain}
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard /
(track.resistance.medium * resistanceCoefficient)) *
(stockWeight / weight)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain"> <InfoWithDelta decimals={1} indent name="On soft terrain">
{(resolvedEnginePower / stockEngine.power) * {stats.powerToWeightRatioSoftTerrain}
track.traverseSpeed * </InfoWithDelta>
hullTraverseCoefficient * <InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
(track.resistance.hard / {stats.turretTraverseSpeed}
(track.resistance.soft * resistanceCoefficient)) * </InfoWithDelta>
(stockWeight / weight)} <Info name="Hull traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.hullTraverseHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.hullTraverseMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.hullTraverseSoftTerrain}
</InfoWithDelta> </InfoWithDelta>
</Flex> </Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading> <Heading size="5">Survivability</Heading>
<InfoWithDelta name="Health" unit="hp" decimals={0}> <InfoWithDelta name="Health" unit="hp" decimals={0}>
{(tank.health + turret.health) * healthCoefficient} {stats.health}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="Fire chance" unit="%" deltaType="lowerIsBetter"> <InfoWithDelta
{Math.round(engine.fireChance * fireChanceCoefficient * 100)} name="Fire chance"
unit="%"
deltaType="lowerIsBetter"
decimals={0}
>
{stats.fireChance * 100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="View range" unit="m" decimals={0}> <InfoWithDelta name="View range" unit="m" decimals={0}>
{turret.viewRange * viewRangeCoefficient} {stats.viewRange}
</InfoWithDelta> </InfoWithDelta>
<Info name="Camouflage" unit="%" /> <Info name="Camouflage" unit="%" />
<InfoWithDelta indent name="Still" decimals={2}> <InfoWithDelta indent name="Still" decimals={0}>
{(tank.camouflage.still + camouflageSumStill) * 100} {stats.camouflageStill * 100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Moving" decimals={2}> <InfoWithDelta indent name="Moving" decimals={0}>
{(tank.camouflage.moving + camouflageSumMoving) * 100} {stats.camouflageMoving * 100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Shooting still" decimals={2}> <InfoWithDelta indent name="Shooting still" decimals={0}>
{(tank.camouflage.still * gun.camouflageLoss + camouflageSumStill) * {stats.camouflageShootingStill * 100}
100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Shooting on move" decimals={2}> <InfoWithDelta indent name="Shooting moving" decimals={0}>
{(tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving) * {stats.camouflageShootingMoving * 100}
100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Caught on fire" decimals={2}> <InfoWithDelta indent name="Caught on fire" decimals={0}>
{(tank.camouflage.onFire * tank.camouflage.still + {stats.camouflageCaughtOnFire * 100}
camouflageSumStill) *
100}
</InfoWithDelta> </InfoWithDelta>
<Info name="Size" unit="m"> <Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
{size[0].toFixed(2)} x {size[2].toFixed(2)} x {size[1].toFixed(2)} {stats.width}
</Info>
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.height}
</Info>
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.length}
</Info>
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.volume}
</Info> </Info>
</Flex> </Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Heading size="5">Crew training</Heading> <Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>represents the other roles a crew member can take.</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => { {tank.crew.map((member) => {
const count = member.count ?? 1; const count = member.count ?? 1;
@@ -69,7 +69,12 @@ export function Controls() {
); );
async function handlePoseEvent(event: Pose) { async function handlePoseEvent(event: Pose) {
const hasImprovedVerticalStabilizer = await hasEquipment(122); const duel = useDuel.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
switch (event) { switch (event) {
case Pose.HullDown: { case Pose.HullDown: {
@@ -1,5 +1,5 @@
import { LightningBoltIcon } from '@radix-ui/react-icons'; import { LightningBoltIcon } from '@radix-ui/react-icons';
import { Button, Card, Flex, Tabs, Theme } from '@radix-ui/themes'; import { Button, Card, Flex, Tabs, Text, Theme } from '@radix-ui/themes';
import { PerspectiveCamera } from '@react-three/drei'; import { PerspectiveCamera } from '@react-three/drei';
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { Suspense, use, useEffect, useRef, useState } from 'react'; import { Suspense, use, useEffect, useRef, useState } from 'react';
@@ -12,7 +12,9 @@ import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTran
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose'; import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon'; import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat'; import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { BlitzkriegWormWide } from '../../../../../../icons/BlitzkriegWormWide';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import mutateTankopediaPersistent, { import mutateTankopediaPersistent, {
TankopediaMode, TankopediaMode,
@@ -33,7 +35,6 @@ export function TankSandbox() {
const hasImprovedVerticalStabilizer = useEquipment(122); const hasImprovedVerticalStabilizer = useEquipment(122);
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const canvasWrapper = useRef<HTMLDivElement>(null); const canvasWrapper = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const mode = useTankopediaPersistent((state) => state.mode); const mode = useTankopediaPersistent((state) => state.mode);
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id]; const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
@@ -43,6 +44,7 @@ export function TankSandbox() {
const wideFormat = useWideFormat(); const wideFormat = useWideFormat();
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile); const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
const duel = useDuel(); const duel = useDuel();
const isFullScreen = useFullScreen();
function handlePointerDown() { function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
@@ -59,10 +61,6 @@ export function TankSandbox() {
} }
useEffect(() => { useEffect(() => {
function handleFullScreenChange() {
setIsFullScreen(document.fullscreenElement !== null);
}
function handlePoseEvent(pose: Pose) { function handlePoseEvent(pose: Pose) {
switch (pose) { switch (pose) {
case Pose.HullDown: { case Pose.HullDown: {
@@ -121,10 +119,8 @@ export function TankSandbox() {
} }
poseEvent.on(handlePoseEvent); poseEvent.on(handlePoseEvent);
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => { return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
poseEvent.off(handlePoseEvent); poseEvent.off(handlePoseEvent);
}; };
}); });
@@ -145,117 +141,127 @@ export function TankSandbox() {
<Theme radius={isFullScreen ? 'none' : undefined}> <Theme radius={isFullScreen ? 'none' : undefined}>
<Card <Card
style={{ style={{
position: 'relative', position: isFullScreen ? 'fixed' : 'relative',
border: isFullScreen ? 'none' : 'unset', border: isFullScreen ? 'none' : 'unset',
width: isFullScreen ? '100vw' : 'unset',
height: isFullScreen ? 'calc(100vh)' : 'unset',
top: isFullScreen ? 0 : 'unset',
left: isFullScreen ? 0 : 'unset',
zIndex: isFullScreen ? 2 : 'unset',
}} }}
ref={canvasWrapper} ref={canvasWrapper}
> >
<Theme radius="full" style={{ height: '100%' }}> <Flex
<Flex style={{
style={{ height: isFullScreen ? '100%' : '75vh',
height: isFullScreen ? '100%' : '75vh', maxHeight: isFullScreen ? 'unset' : 576,
maxHeight: isFullScreen ? 'unset' : 576, }}
direction="column"
gap="2"
>
<Tabs.Root
value={mode}
onValueChange={(mode) => {
mutateTankopediaPersistent((draft) => {
draft.mode = mode as TankopediaMode;
});
}} }}
direction="column"
gap="2"
> >
<Tabs.Root <Tabs.List>
value={mode} <Tabs.Trigger value="model">
onValueChange={(mode) => { <Flex gap="2" align="center">
mutateTankopediaPersistent((draft) => { Model
draft.mode = mode as TankopediaMode;
});
}}
>
<Tabs.List>
<Tabs.Trigger value="model">
<Flex gap="2" align="center">
Model
</Flex>
</Tabs.Trigger>
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div
style={{
height: 'calc(100% - 64px)',
position: 'absolute',
width: '100%',
bottom: 0,
}}
>
{loadModel ? (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
<SceneProps />
<Suspense fallback={<ModelLoader />}>
{/* TODO: add rotation cube to the top right */}
<Lighting />
<TankModel />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
</div>
) : (
<Flex
align="center"
justify="center"
style={{ height: '100%', position: 'relative' }}
>
<Button
style={{
zIndex: 1,
}}
onClick={() => {
setLoadModel(true);
}}
>
<LightningBoltIcon /> Load model
</Button>
<Flex
style={{
filter: 'blur(16px)',
width: 256,
height: 128,
display: 'flex',
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
transform: 'translate(25%, -25%) scale(300%)',
}}
>
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
</Flex>
</Flex> </Flex>
)} </Tabs.Trigger>
</div> <Tabs.Trigger value="armor">Armor</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<Options <Text
canvasWrapper={canvasWrapper} color="gray"
isFullScreen={isFullScreen} style={{
/> position: 'absolute',
left: '50%',
top: 11,
}}
>
<BlitzkriegWormWide height={32} />
</Text>
{isFullScreen && <AntagonistBar floating />} <div
style={{
height: 'calc(100% - 64px)',
position: 'absolute',
width: '100%',
bottom: 0,
}}
>
{loadModel ? (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
<SceneProps />
{loadModel && <RotationInputs isFullScreen={isFullScreen} />} <Suspense fallback={<ModelLoader />}>
</Flex> <Lighting />
</Theme> <TankModel />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
</div>
) : (
<Flex
align="center"
justify="center"
style={{ height: '100%', position: 'relative' }}
>
<Button
style={{
zIndex: 1,
}}
onClick={() => {
setLoadModel(true);
}}
>
<LightningBoltIcon /> Load model
</Button>
<Flex
style={{
filter: 'blur(16px)',
width: 256,
height: 128,
display: 'flex',
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
transform: 'translate(25%, -25%) scale(300%)',
}}
>
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
</Flex>
</Flex>
)}
</div>
<Options canvasWrapper={canvasWrapper} isFullScreen={isFullScreen} />
{isFullScreen && <AntagonistBar floating />}
{loadModel && <RotationInputs isFullScreen={isFullScreen} />}
</Flex>
</Card> </Card>
</Theme> </Theme>
); );
@@ -1,5 +1,4 @@
import { Html, useProgress } from '@react-three/drei'; import { Html, useProgress } from '@react-three/drei';
import { theme } from '../../../../../../../stitches.config';
import { Loader } from '../../../../../components/Loader'; import { Loader } from '../../../../../components/Loader';
export function ModelLoader() { export function ModelLoader() {
@@ -7,11 +6,7 @@ export function ModelLoader() {
return ( return (
<Html center position={[0, 1.5, 0]}> <Html center position={[0, 1.5, 0]}>
<Loader <Loader naked progress={progress / 100} />
naked
color={theme.colors.textHighContrast_purple}
progress={progress / 100}
/>
</Html> </Html>
); );
} }
@@ -18,7 +18,7 @@ interface OptionsProps {
canvasWrapper: RefObject<HTMLElement>; canvasWrapper: RefObject<HTMLElement>;
} }
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { export function Options({ isFullScreen }: OptionsProps) {
const showGrid = useTankopediaPersistent( const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid, (state) => state.model.visual.showGrid,
); );
@@ -50,136 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
right: 18, right: 18,
}} }}
> >
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <EyeOpenIcon />
<EyeOpenIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down Hull down
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug Face hug
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default Default
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <GearIcon />
<GearIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label> <DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showSpacedArmor} checked={showSpacedArmor}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showSpacedArmor = checked; draft.model.visual.showSpacedArmor = checked;
}); });
mutateTankopediaTemporary((draft) => { mutateTankopediaTemporary((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
}} }}
> >
Spaced armor Spaced armor
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={greenPenetration} checked={greenPenetration}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked; draft.model.visual.greenPenetration = checked;
}); });
}} }}
> >
Green penetration Green penetration
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={wireframe} checked={wireframe}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked; draft.model.visual.wireframe = checked;
}); });
}} }}
> >
Wireframe Wireframe
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={opaque} checked={opaque}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked; draft.model.visual.opaque = checked;
}); });
}} }}
> >
Opaque Opaque
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Environment</DropdownMenu.Label> <DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showGrid} checked={showGrid}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked; draft.model.visual.showGrid = checked;
}); });
}} }}
> >
Show grid Show grid
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showEnvironment} checked={showEnvironment}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked; draft.model.visual.showEnvironment = checked;
}); });
}} }}
> >
View environment View environment
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger> <DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent> <DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}> <DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => ( {ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem <DropdownMenu.RadioItem
key={environment} key={environment}
value={environment} value={environment}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment; draft.model.visual.environment = environment;
}); });
}} }}
> >
{environment[0].toUpperCase() + environment.slice(1)} {environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem> </DropdownMenu.RadioItem>
))} ))}
</DropdownMenu.RadioGroup> </DropdownMenu.RadioGroup>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{fullScreenAvailable && ( {fullScreenAvailable && (
<Button <Button
@@ -187,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
onClick={() => { onClick={() => {
if (isFullScreen) { if (isFullScreen) {
document.exitFullscreen(); document.exitFullscreen();
} else canvasWrapper.current?.requestFullscreen(); } else document.body.requestFullscreen();
}} }}
> >
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />} {isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
@@ -162,7 +162,12 @@ export const TankModel = memo(() => {
} }
} }
async function handlePointerMove(event: PointerEvent) { async function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = await hasEquipment(122); const duel = useDuel.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
[pitch, yaw] = applyPitchYawLimits( [pitch, yaw] = applyPitchYawLimits(
pitch, pitch,
yaw + event.movementX * (Math.PI / canvas.width), yaw + event.movementX * (Math.PI / canvas.width),
@@ -223,7 +228,12 @@ export const TankModel = memo(() => {
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { async function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = await hasEquipment(122); const duel = useDuel.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
[pitch, yaw] = applyPitchYawLimits( [pitch, yaw] = applyPitchYawLimits(
pitch - event.movementY * (Math.PI / canvas.height), pitch - event.movementY * (Math.PI / canvas.height),
yaw + event.movementX * (Math.PI / canvas.width), yaw + event.movementX * (Math.PI / canvas.width),
@@ -52,9 +52,42 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
<Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}> <Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
<TextField.Root <TextField.Root
radius="full"
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(turretYawInput.current!.value);
if (isNaN(value)) {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -63,40 +96,46 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Yaw Yaw
</TextField.Slot> </TextField.Slot>
<TextField.Input
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(Number(turretYawInput.current!.value)),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot> <TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
</TextField.Root> </TextField.Root>
<TextField.Root <TextField.Root
radius="full"
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(gunPitchInput.current!.value);
if (isNaN(value)) {
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -105,35 +144,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Pitch Pitch
</TextField.Slot> </TextField.Slot>
<TextField.Input
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(
-Number(gunPitchInput.current!.value) + initialGunPitch,
),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot <TextField.Slot
style={{ style={{
userSelect: 'none', userSelect: 'none',
@@ -151,7 +161,9 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
active={hasCalibratedShells} active={hasCalibratedShells}
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.antagonist!.equipment[0][0] = hasCalibratedShells ? 0 : 1; draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
}); });
}} }}
/> />
@@ -160,7 +172,9 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
active={hasEnhancedArmor} active={hasEnhancedArmor}
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.protagonist!.equipment[1][1] = hasEnhancedArmor ? 0 : -1; draft.protagonist!.equipmentMatrix[1][1] = hasEnhancedArmor
? 0
: -1;
}); });
}} }}
/> />
@@ -13,8 +13,6 @@ export function Title() {
return ( return (
<> <>
<title>{protagonist.tank.name}</title>
<Flex gap="6" justify="center" align="center" style={{ padding: 16 }}> <Flex gap="6" justify="center" align="center" style={{ padding: 16 }}>
<Flex gap="2" align="center"> <Flex gap="2" align="center">
<Heading <Heading
+30
View File
@@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import { tankDefinitions } from '../../../../core/blitzkrieg/tankDefinitions';
import { tankIcon } from '../../../../core/blitzkrieg/tankIcon';
interface TankopediaLayoutProps {
children: ReactNode;
params: { id: string };
}
export default async function TankopediaLayout({
children,
params,
}: TankopediaLayoutProps) {
const id = Number(params.id);
const awaitedTankDefinitions = await tankDefinitions;
const tank = awaitedTankDefinitions[id];
return (
<>
<title>{tank.name}</title>
<meta
name="description"
content={`Statistics, armor profiles, equipment, and more about ${tank.name}`}
/>
<meta property="og:image" content={tankIcon(id)} />
{children}
</>
);
}
+16 -10
View File
@@ -10,10 +10,11 @@ import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
import { AntagonistBar } from './components/AntagonistBar'; import { AntagonistBar } from './components/AntagonistBar';
import { Characteristics } from './components/Characteristics'; import { Characteristics } from './components/Characteristics';
import { Consumables } from './components/Characteristics/components/Consumables'; import { Consumables } from './components/Characteristics/components/Consumables';
import { Equipments } from './components/Characteristics/components/Equipments'; import { Equipment } from './components/Characteristics/components/Equipment';
import { Miscellaneous } from './components/Characteristics/components/Miscellaneous'; import { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
import { Modules } from './components/Characteristics/components/Modules'; import { Modules } from './components/Characteristics/components/Modules';
import { Provisions } from './components/Characteristics/components/Provisions'; import { Provisions } from './components/Characteristics/components/Provisions';
import { Skills } from './components/Characteristics/components/Skills';
import { TankSandbox } from './components/Model/TankSandbox'; import { TankSandbox } from './components/Model/TankSandbox';
import { Title } from './components/Title'; import { Title } from './components/Title';
@@ -63,10 +64,8 @@ export default function Page({ params }: { params: { id: string } }) {
}; };
}, [initialId, assigned]); }, [initialId, assigned]);
return ( return (
<PageWrapper color="purple" size="double"> <PageWrapper color="purple" size={1600}>
{assigned && ( {assigned && (
<> <>
<Title /> <Title />
@@ -79,7 +78,7 @@ export default function Page({ params }: { params: { id: string } }) {
justify="center" justify="center"
> >
<Flex <Flex
gap="4" gap="8"
direction="column" direction="column"
style={{ style={{
flex: 1, flex: 1,
@@ -88,15 +87,22 @@ export default function Page({ params }: { params: { id: string } }) {
position: wideFormat ? 'sticky' : undefined, position: wideFormat ? 'sticky' : undefined,
}} }}
> >
<TankSandbox /> <Flex gap="4" direction="column">
<AntagonistBar /> <TankSandbox />
<AntagonistBar />
</Flex>
<Flex direction="column" gap="4"> <Flex gap="5" wrap="wrap">
<Modules /> <Modules />
<Provisions /> <Provisions />
<Equipments />
<Miscellaneous />
<Consumables /> <Consumables />
<Flex gap="5" wrap="wrap">
<Skills />
<Equipment />
</Flex>
<Miscellaneous />
</Flex> </Flex>
</Flex> </Flex>
@@ -32,32 +32,31 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
> >
<CaretLeftIcon /> <CaretLeftIcon />
</Button> </Button>
<TextField.Root> <TextField.Root
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot> <TextField.Slot>Page</TextField.Slot>
<TextField.Input
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot> <TextField.Slot>
out of {Math.floor(searchedList.length / tanksPerPage) + 1} out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot> </TextField.Slot>
@@ -1,10 +1,12 @@
import { slateDark } from '@radix-ui/colors'; import { slateDark } from '@radix-ui/colors';
import { import {
CaretDownIcon, CaretDownIcon,
CaretRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
TrashIcon, TrashIcon,
} from '@radix-ui/react-icons'; } from '@radix-ui/react-icons';
import { import {
AlertDialog,
Button, Button,
Card, Card,
DropdownMenu, DropdownMenu,
@@ -14,6 +16,7 @@ import {
TextField, TextField,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import Link from 'next/link';
import { use, useEffect, useMemo, useRef, useState } from 'react'; import { use, useEffect, useMemo, useRef, useState } from 'react';
import { TreeType } from '../../../../../components/Tanks'; import { TreeType } from '../../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants'; import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
@@ -50,11 +53,16 @@ import { Options } from './components/Options';
interface TankSearchProps { interface TankSearchProps {
compact?: boolean; compact?: boolean;
onSelect?: (tank: TankDefinition) => void; onSelect?: (tank: TankDefinition) => void;
onSelectAll?: (tanks: TankDefinition[]) => void;
} }
const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector']; const treeTypeOrder: TreeType[] = ['researchable', 'premium', 'collector'];
export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) { export function TankSearch({
compact,
onSelect = () => {},
onSelectAll,
}: TankSearchProps) {
const tanksPerPage = compact ? 16 : 96; const tanksPerPage = compact ? 16 : 96;
const nations = use(NATIONS); const nations = use(NATIONS);
const filters = useTankopediaPersistent((state) => state.filters); const filters = useTankopediaPersistent((state) => state.filters);
@@ -151,7 +159,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
); );
break; break;
case 'survivability.size': case 'survivability.volume':
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id]; const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id]; const bTankModelDefinition = awaitedModelDefinitions[b.id];
@@ -178,6 +186,33 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
}); });
break; break;
case 'survivability.length':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aLength = Math.max(aSize[0], aSize[1], aSize[2]);
const bLength = Math.max(bSize[0], bSize[1], bSize[2]);
return aLength - bLength;
});
break;
case 'fire.dpm': case 'fire.dpm':
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
@@ -365,20 +400,28 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
.filter(([name]) => name !== 'page') .filter(([name]) => name !== 'page')
.map(([, value]) => value), .map(([, value]) => value),
]); ]);
const [searchResults, setSearchedList] = useState(searchableTanks); const [searchQuery, setSearchQuery] = useState('');
const searchResults = useMemo(
() =>
go(searchQuery, searchableTanks, {
keys: ['name', 'nameFull', 'id'] satisfies (keyof TankDefinition)[],
all: true,
}).map(({ obj }) => obj),
[searchQuery, searchableTanks],
);
const page = useTankopediaPersistent((state) => state.filters.page); const page = useTankopediaPersistent((state) => state.filters.page);
const wideFormat = typeof window !== 'undefined' && window.innerWidth > 512;
const chunkSize = wideFormat ? tanksPerPage / 2 : Infinity;
const searchResultsPageSlice = searchResults.slice( const searchResultsPageSlice = searchResults.slice(
page * tanksPerPage, page * tanksPerPage,
(page + 1) * tanksPerPage, (page + 1) * tanksPerPage,
); );
const firstChunk = searchResultsPageSlice.slice(0, chunkSize); const firstChunk = searchResultsPageSlice.slice(
const secondChunk = searchResultsPageSlice.slice(chunkSize); 0,
Math.ceil(searchResultsPageSlice.length / 2),
);
const secondChunk = searchResultsPageSlice.slice(
Math.ceil(searchResultsPageSlice.length / 2),
);
useEffect(() => {
setSearchedList(searchableTanks);
}, [searchableTanks]);
useEffect(() => { useEffect(() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.min( draft.filters.page = Math.min(
@@ -392,27 +435,41 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<> <>
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<Flex gap="2"> <Flex gap="2">
<TextField.Root style={{ flex: 1 }}> <TextField.Root
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
setSearchQuery(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (
searchResults.length > 0 &&
input.current &&
input.current.value.length > 0
) {
onSelect(searchResults[0]);
}
}
}}
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input {searchResults.length > 0 &&
ref={input} input.current &&
placeholder="Search tanks..." input.current.value.length > 0 && (
onChange={(event) => { <TextField.Slot>
const results = go(event.target.value, searchableTanks, { <Button
keys: [ variant="ghost"
'name', onClick={() => onSelect(searchResults[0])}
'nameFull', >
'id', {searchResults[0].name} <CaretRightIcon />
] satisfies (keyof TankDefinition)[], </Button>
all: true, </TextField.Slot>
}).map(({ obj }) => obj); )}
setSearchedList(results);
}}
/>
</TextField.Root> </TextField.Root>
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -699,14 +756,24 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
Camouflage shooting Camouflage shooting
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.size'} checked={sort.by === 'survivability.volume'}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.size'; draft.sort.by = 'survivability.volume';
}); });
}} }}
> >
Size Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
@@ -759,132 +826,185 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<Options /> <Options />
</Flex> </Flex>
<Flex justify="center"> <Flex justify="center" gap="4" align="center">
<Text color="gray"> <Text color="gray">
Sorting: {SORT_NAMES[sort.by]}, {sort.direction} Sorting: {SORT_NAMES[sort.by]}, {sort.direction}
</Text> </Text>
{onSelectAll && searchResults.length <= 8 && (
<Button variant="ghost" onClick={() => onSelectAll(searchResults)}>
Select all
</Button>
)}
{onSelectAll && searchResults.length > 8 && (
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button variant="ghost">Select all</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>
Woah! That's a lot of tanks.
</AlertDialog.Title>
<AlertDialog.Description>
Would you like to select{' '}
<Text color="red">all {searchResults.length} tanks</Text>?
</AlertDialog.Description>
<Flex gap="2" justify="end">
<AlertDialog.Cancel>
<Button variant="soft" color="gray">
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action onClick={() => onSelectAll(searchResults)}>
<Button variant="solid" color="red">
Select {searchResults.length} tanks
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)}
</Flex> </Flex>
{!compact && ( {!compact && (
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} /> <PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)} )}
<Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}>
<Flex
wrap="wrap"
gap="2"
style={{
justifyContent:
compact && firstChunk.length > 0 && secondChunk.length > 0
? 'space-around'
: 'center',
}}
>
{!compact && {!compact &&
searchResultsPageSlice.map((tank) => ( searchResultsPageSlice.map((tank) => (
<Card <Link
key={tank.id} href={`/tools/tankopedia/${tank.id}`}
onClick={() => onSelect(tank)}
className={styles.listing}
style={{ style={{
flex: 1, flex: 1,
cursor: 'pointer', minWidth: 256,
height: 64,
}} }}
> >
<Inset <Card
key={tank.id} key={tank.id}
className={styles.listing}
style={{ style={{
minWidth: 256, cursor: 'pointer',
minHeight: 64, height: 64,
position: 'relative', width: '100%',
display: 'flex',
}} }}
> >
<img <Inset
className={styles.flag} key={tank.id}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{ style={{
width: '100%', minHeight: 64,
height: '100%', position: 'relative',
objectFit: 'contain', display: 'flex',
position: 'absolute',
}} }}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
> >
<img <img
src={tankIcon(tank.id)} className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{ style={{
width: '100%',
height: '100%',
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'left center', position: 'absolute',
}} }}
/> />
</div>
<Flex <div
align="center" className={styles.listingShadow}
justify="between" style={{
className={styles.listingLabel} width: '100%',
style={{ height: '100%',
position: 'absolute', position: 'absolute',
bottom: 8, transition: `box-shadow ${theme.durations.regular}`,
padding: '0 12px', }}
width: '100%', />
boxSizing: 'border-box',
}} <div
> style={{
<Flex align="center" justify="center" gap="1"> width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img <img
src={ src={tankIcon(tank.id)}
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{ style={{
color: objectFit: 'contain',
tank.treeType === 'researchable' objectPosition: 'left center',
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}} }}
> />
{tank.name} </div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text> </Text>
</Flex> </Flex>
</Inset>
<Text size="4" color="gray" highContrast={false}> </Card>
{TIER_ROMAN_NUMERALS[tank.tier]} </Link>
</Text>
</Flex>
</Inset>
</Card>
))} ))}
{compact && ( {compact && firstChunk.length > 0 && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} /> <CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)} )}
@@ -892,6 +1012,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} /> <CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)} )}
</Flex> </Flex>
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} /> <PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
</> </>
); );
-6
View File
@@ -74,12 +74,6 @@ export const characteristics = style({
}, },
}); });
export const configurationChild = style({
display: 'flex',
gap: 8,
flexDirection: 'column',
});
export const configuration = style({ export const configuration = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
+1 -1
View File
@@ -8,7 +8,7 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
return ( return (
<PageWrapper size="double" color="purple"> <PageWrapper size={1200} color="purple">
<TankSearch <TankSearch
onSelect={(tank) => { onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`); router.push(`./tankopedia/${tank.id}`);
+3 -3
View File
@@ -6,7 +6,7 @@ import { Glow } from '../components/AllStatsOverview/components/HeroStat/compone
import CommandWrapper from '../components/CommandWrapper'; import CommandWrapper from '../components/CommandWrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitzLink';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
async handler(interaction) { async handler(interaction) {
const { t, translate } = translator(interaction.locale); const { t, translate } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction); const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = parseInt(interaction.user.id); const discordId = BigInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']); const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
await linkBlitzAndDiscord(discordId, region, id); await linkBlitzAndDiscord(discordId, id);
if (interaction.guildId === discord.sklld_guild_id) { if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
+8 -17
View File
@@ -385,26 +385,17 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
right: '5%', right: '5%',
}} }}
> >
<div <img
src={await tankIconPng(id)}
style={{ style={{
height: 120, height: 100,
width: 240, width: 100,
display: 'flex', objectFit: 'contain',
alignItems: 'center', position: 'absolute',
justifyContent: 'flex-start',
left: '25%', left: '25%',
top: '5%', top: 0,
}} }}
> />
<img
src={await tankIconPng(id)}
style={{
height: '100%',
position: 'absolute',
objectFit: 'contain',
}}
/>
</div>
<div <div
style={{ style={{
@@ -53,6 +53,7 @@ export function HeroStat({ stats, locale }: WN8DisplayProps) {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: -4,
}} }}
> >
<span <span
@@ -20,6 +20,7 @@ export const CoreArmorScene = memo(() => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -70,8 +70,8 @@ export function CoreArmorSceneComponent({
material.uniforms.explosionRadius.value = shell.explosionRadius; material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = useDuel.getState(); const duel = useDuel.getState();
await handleProtagonistEquipmentChange(duel.protagonist!.equipment); await handleProtagonistEquipmentChange(duel.protagonist!.equipmentMatrix);
await handleAntagonistEquipmentChange(duel.antagonist!.equipment); await handleAntagonistEquipmentChange(duel.antagonist!.equipmentMatrix);
} }
function handleVisualChange( function handleVisualChange(
visual: TankopediaPersistent['model']['visual'], visual: TankopediaPersistent['model']['visual'],
@@ -84,25 +84,40 @@ export function CoreArmorSceneComponent({
async function handleProtagonistEquipmentChange( async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
) { ) {
const hasEnhancedArmor = await hasEquipment(110, false, equipment); const duel = useDuel.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04 ? thickness * 1.04
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const shell = useDuel.getState().antagonist!.shell; const duel = useDuel.getState();
const penetration = resolveNearPenetration(shell.penetration); const penetration = resolveNearPenetration(
const hasCalibratedShells = await hasEquipment(103, true, equipment); duel.antagonist!.shell.penetration,
);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05) ? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
: penetration; : penetration;
} }
handleShellChange(useDuel.getState().antagonist!.shell); handleShellChange(useDuel.getState().antagonist!.shell);
handleVisualChange(useTankopediaPersistent.getState().model.visual); handleVisualChange(useTankopediaPersistent.getState().model.visual);
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment); handleProtagonistEquipmentChange(
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment); useDuel.getState().protagonist!.equipmentMatrix,
);
handleAntagonistEquipmentChange(
useDuel.getState().antagonist!.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange), useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
@@ -111,11 +126,11 @@ export function CoreArmorSceneComponent({
handleVisualChange, handleVisualChange,
), ),
useDuel.subscribe( useDuel.subscribe(
(state) => state.protagonist!.equipment, (state) => state.protagonist!.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange,
), ),
useDuel.subscribe( useDuel.subscribe(
(state) => state.antagonist!.equipment, (state) => state.antagonist!.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange,
), ),
]; ];
@@ -123,7 +138,7 @@ export function CoreArmorSceneComponent({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
useFrame(({ gl, camera }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
@@ -31,6 +31,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -99,7 +100,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -50,32 +50,47 @@ export function SpacedArmorSubExternal({
async function handleProtagonistEquipmentChange( async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
) { ) {
const hasEnhancedArmor = await hasEquipment(110, false, equipment); const duel = useDuel.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04 ? thickness * 1.04
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const shell = useDuel.getState().antagonist!.shell; const duel = useDuel.getState();
const penetration = resolveNearPenetration(shell.penetration); const penetration = resolveNearPenetration(
const hasCalibratedShells = await hasEquipment(103, true, equipment); duel.antagonist!.shell.penetration,
);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05) ? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
: penetration; : penetration;
} }
handleShellChange(useDuel.getState().antagonist!.shell); handleShellChange(useDuel.getState().antagonist!.shell);
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment); handleProtagonistEquipmentChange(
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment); useDuel.getState().protagonist!.equipmentMatrix,
);
handleAntagonistEquipmentChange(
useDuel.getState().antagonist!.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange), useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
useDuel.subscribe( useDuel.subscribe(
(state) => state.protagonist!.equipment, (state) => state.protagonist!.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange,
), ),
useDuel.subscribe( useDuel.subscribe(
(state) => state.antagonist!.equipment, (state) => state.antagonist!.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange,
), ),
]; ];
@@ -83,7 +98,7 @@ export function SpacedArmorSubExternal({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
return ( return (
<> <>
@@ -62,32 +62,46 @@ export function SpacedArmorSubSpaced({
async function handleProtagonistEquipmentChange( async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
) { ) {
const hasEnhancedArmor = await hasEquipment(110, false, equipment); const duel = useDuel.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04 ? thickness * 1.04
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const duel = useDuel.getState();
const shell = useDuel.getState().antagonist!.shell; const shell = useDuel.getState().antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration); const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(103, true, equipment); const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05) ? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
: penetration; : penetration;
} }
handleShellChange(useDuel.getState().antagonist!.shell); handleShellChange(useDuel.getState().antagonist!.shell);
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment); handleProtagonistEquipmentChange(
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment); useDuel.getState().protagonist!.equipmentMatrix,
);
handleAntagonistEquipmentChange(
useDuel.getState().antagonist!.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange), useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
useDuel.subscribe( useDuel.subscribe(
(state) => state.protagonist!.equipment, (state) => state.protagonist!.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange,
), ),
useDuel.subscribe( useDuel.subscribe(
(state) => state.antagonist!.equipment, (state) => state.antagonist!.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange,
), ),
]; ];
@@ -95,7 +109,7 @@ export function SpacedArmorSubSpaced({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
return ( return (
<> <>
@@ -89,8 +89,17 @@ export function SpacedArmorSceneComponent({
async onClick(event) { async onClick(event) {
event.stopPropagation(); event.stopPropagation();
const hasCalibratedShells = await hasEquipment(103, true); const duel = useDuel.getState();
const hasEnhancedArmor = await hasEquipment(110); const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
duel.antagonist!.equipmentMatrix,
);
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1; const thicknessCoefficient = hasEnhancedArmor ? 1.04 : 1;
const shell = useDuel.getState().antagonist!.shell; const shell = useDuel.getState().antagonist!.shell;
const explosive = isExplosive(shell.type); const explosive = isExplosive(shell.type);
+73
View File
@@ -0,0 +1,73 @@
import { Flex } from '@radix-ui/themes';
import { ConsumableEntry } from '../core/blitzkrieg/consumableDefinitions';
import { ConsumableButton } from './ModuleButtons/ConsumableButton';
interface ConsumablesManagerProps {
consumables: ConsumableEntry[];
selected: number[];
onChange?: (consumables: number[]) => void;
disabled?: boolean;
timers?: boolean;
hasHighEndConsumables?: boolean;
hasConsumableDeliverySystem?: boolean;
cooldownBooster?: number;
}
export function ConsumablesManager({
consumables,
selected,
onChange,
disabled,
timers,
hasConsumableDeliverySystem,
cooldownBooster,
hasHighEndConsumables,
}: ConsumablesManagerProps) {
return (
<Flex wrap="wrap">
{consumables.map((consumable, index) => {
const isSelected = selected.some((id) => id === consumable.id);
return (
<ConsumableButton
special={consumable.duration === undefined}
duration={
timers
? consumable.duration === undefined
? undefined
: consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
: undefined
}
cooldown={
timers
? consumable.cooldown *
(hasConsumableDeliverySystem ? 0.85 : 1) *
(1 - cooldownBooster! * 0.1)
: undefined
}
key={consumable.id}
first={index === 0}
last={index === consumables.length - 1}
rowChild
disabled={disabled && !isSelected}
consumable={consumable.id}
selected={isSelected}
onClick={() => {
if (!onChange) return;
const draft = [...selected];
if (draft.some((id) => id === consumable.id)) {
draft.splice(draft.indexOf(consumable.id), 1);
} else {
draft.push(consumable.id);
}
onChange(draft);
}}
/>
);
})}
</Flex>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { Flex } from '@radix-ui/themes';
import { use } from 'react';
import { permanentSkills } from '../app/tools/tankopedia/[id]/components/Characteristics/components/Skills/constants';
import { asset } from '../core/blitzkrieg/asset';
import { skillDefinitions } from '../core/blitzkrieg/skillDefinitions';
import { Tier } from '../core/blitzkrieg/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../core/blitzkrieg/tankDefinitions/constants';
import { GenericTankComponentButton } from './ModuleButtons/GenericTankComponentButton';
interface CrewSkillManagerProps {
skillLevels: Record<string, number>;
onChange?: (skillLevels: Record<string, number>) => void;
}
export function CrewSkillManager({
skillLevels,
onChange,
}: CrewSkillManagerProps) {
const awaitedSkillDefinitions = use(skillDefinitions);
return (
<Flex direction="column" gap="2">
{Object.entries(awaitedSkillDefinitions.classes).map(
([tankClass, skills]) => (
<Flex gap="2" key={tankClass}>
{skills.map((skill) => {
const level = skillLevels[skill];
return (
<GenericTankComponentButton
special={!permanentSkills.includes(skill)}
first
last
selected={level > 0}
discriminator={
level === 0 ? undefined : TIER_ROMAN_NUMERALS[level as Tier]
}
icon={asset(`icons/skills/${skill}.webp`)}
onClick={(event) => {
if (!onChange) return;
const draft = { ...skillLevels };
if (event.shiftKey) {
draft[skill] = draft[skill] === 0 ? 7 : 0;
} else {
draft[skill] = level - 1;
if (draft[skill] < 0) draft[skill] = 7;
}
onChange(draft);
}}
/>
);
})}
</Flex>
),
)}
</Flex>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { Flex } from '@radix-ui/themes';
import { cloneDeep } from 'lodash';
import { EquipmentPreset } from '../core/blitzkrieg/equipmentDefinitions';
import { EquipmentMatrix } from '../stores/duel';
import { EquipmentButton } from './ModuleButtons/EquipmentButton';
interface EquipmentManagerProps {
preset: EquipmentPreset;
matrix: EquipmentMatrix;
onChange: (matrix: EquipmentMatrix) => void;
}
export function EquipmentManager({
preset,
matrix,
onChange,
}: EquipmentManagerProps) {
return (
<Flex direction="column" gap="2">
{preset.map((equipmentRow, rowIndex) => (
<Flex gap="2" key={rowIndex}>
{equipmentRow.map((equipment, columnIndex) => (
<Flex key={columnIndex}>
<EquipmentButton
equipment={equipment[0]}
first
rowChild
selected={matrix[rowIndex][columnIndex] === -1}
onClick={() => {
const draft = cloneDeep(matrix);
if (draft[rowIndex][columnIndex] === -1) {
draft[rowIndex][columnIndex] = 0;
} else {
draft[rowIndex][columnIndex] = -1;
}
onChange(draft);
}}
/>
<EquipmentButton
equipment={equipment[1]}
last
rowChild
selected={matrix[rowIndex][columnIndex] === 1}
onClick={() => {
const draft = cloneDeep(matrix);
if (draft[rowIndex][columnIndex] === 1) {
draft[rowIndex][columnIndex] = 0;
} else {
draft[rowIndex][columnIndex] = 1;
}
onChange(draft);
}}
/>
</Flex>
))}
</Flex>
))}
</Flex>
);
}
@@ -0,0 +1,13 @@
import { Code } from '@radix-ui/themes';
import { use } from 'react';
import { gameDefinitions } from '../../../core/blitzkrieg/gameDefinitions';
export function WoTBVersion() {
const awaitedGameDefinitions = use(gameDefinitions);
return (
<Code color="gray" size="2">
{awaitedGameDefinitions.version}
</Code>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Card, Code, Flex, Link, Spinner, Text, Theme } from '@radix-ui/themes';
import { Suspense } from 'react';
import packageJSON from '../../../package.json';
import { useWideFormat } from '../../hooks/useWideFormat';
import { WoTBVersion } from './components/WoTBVersion';
export function Footer() {
const wideFormat = useWideFormat();
return (
<Theme radius="none">
<Card>
<Flex justify="center">
<Flex
justify={wideFormat ? 'between' : 'center'}
style={{
flex: 1,
maxWidth: 800,
padding: '0 16px',
}}
>
<Flex gap="1">
<Text color="gray" size="2">
Blitzkrieg
</Text>
<Code color="gray" size="2">
{packageJSON.version}
</Code>
<Text color="gray" size="2">
for WoTB
</Text>
<Suspense fallback={<Spinner />}>
<WoTBVersion />
</Suspense>
</Flex>
{wideFormat && (
<Link
size="2"
href="https://www.patreon.com/tresabhi"
target="_blank"
>
Consider donating on Patreon
</Link>
)}
</Flex>
</Flex>
</Card>
</Theme>
);
}
@@ -1,6 +1,7 @@
export function Analytics() { export function Google() {
return ( return (
<> <>
{/* analytics */}
<script <script
async async
src="https://www.googletagmanager.com/gtag/js?id=G-98NTMRHRP9" src="https://www.googletagmanager.com/gtag/js?id=G-98NTMRHRP9"
@@ -16,6 +17,13 @@ export function Analytics() {
`, `,
}} }}
/> />
{/* adsense */}
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-1289406790225052"
crossOrigin="anonymous"
/>
</> </>
); );
} }
@@ -8,7 +8,7 @@ import { TankComponentButtonProps } from './TankComponentButton';
interface ConsumableButtonProps extends TankComponentButtonProps { interface ConsumableButtonProps extends TankComponentButtonProps {
consumable: number; consumable: number;
duration?: number; duration?: number;
cooldown: number; cooldown?: number;
} }
export function ConsumableButton({ export function ConsumableButton({
@@ -17,7 +17,7 @@ export function ConsumableButton({
duration, duration,
...props ...props
}: ConsumableButtonProps) { }: ConsumableButtonProps) {
const cooldownDelta = useDelta(cooldown); const cooldownDelta = useDelta(cooldown ?? 0);
const durationDelta = useDelta(duration ?? 0); const durationDelta = useDelta(duration ?? 0);
return ( return (
@@ -25,38 +25,42 @@ export function ConsumableButton({
icon={asset(`icons/consumables/${consumable}.webp`)} icon={asset(`icons/consumables/${consumable}.webp`)}
{...props} {...props}
> >
<Flex direction="column" style={{ transform: 'translateX(-10px)' }}> {(cooldown || duration) && (
{duration && ( <Flex direction="column" style={{ transform: 'translateX(-10px)' }}>
<Text {duration && (
color={ <Text
durationDelta < 0 color={
? 'green' durationDelta < 0
: durationDelta > 0 ? 'green'
? 'tomato' : durationDelta > 0
: undefined ? 'tomato'
} : undefined
> }
<Flex align="center" gap="1" style={{ marginBottom: -4 }}> >
<ClockIcon width={12} height={12} /> <Flex align="center" gap="1" style={{ marginBottom: -4 }}>
{Math.round(duration)}s <ClockIcon width={12} height={12} />
</Flex> {Math.round(duration)}s
</Text> </Flex>
)} </Text>
<Text )}
color={ {cooldown && (
cooldownDelta < 0 <Text
? 'green' color={
: cooldownDelta > 0 cooldownDelta < 0
? 'tomato' ? 'green'
: undefined : cooldownDelta > 0
} ? 'tomato'
> : undefined
<Flex align="center" gap="1"> }
<ReloadIcon width={12} height={12} /> >
{Math.round(cooldown)}s <Flex align="center" gap="1">
</Flex> <ReloadIcon width={12} height={12} />
</Text> {Math.round(cooldown)}s
</Flex> </Flex>
</Text>
)}
</Flex>
)}
</GenericTankComponentButton> </GenericTankComponentButton>
); );
} }
@@ -11,23 +11,28 @@ export interface TankComponentButtonProps
discriminator?: ReactNode; discriminator?: ReactNode;
disabled?: boolean; disabled?: boolean;
banner?: string; banner?: string;
special?: boolean;
} }
export function TankComponentButton({ export function TankComponentButton({
variant,
selected, selected,
first = false, first = false,
last = false, last = false,
rowChild, rowChild,
discriminator, discriminator,
special,
children, children,
banner, banner,
disabled,
style,
...props ...props
}: TankComponentButtonProps) { }: TankComponentButtonProps) {
return ( return (
<Button <Button
radius="medium" radius="medium"
color={selected ? undefined : 'gray'} color={selected ? (special ? 'amber' : undefined) : 'gray'}
variant={selected ? 'surface' : 'soft'} variant={variant ?? (selected ? 'surface' : 'soft')}
style={{ style={{
padding: 0, padding: 0,
minWidth: 48, minWidth: 48,
@@ -39,7 +44,11 @@ export function TankComponentButton({
borderBottomRightRadius: last ? undefined : 0, borderBottomRightRadius: last ? undefined : 0,
margin: rowChild ? -0.5 : 'unset', margin: rowChild ? -0.5 : 'unset',
overflow: 'hidden', overflow: 'hidden',
cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined,
...style,
}} }}
disabled={variant === 'ghost' || disabled}
{...props} {...props}
> >
{banner !== undefined && ( {banner !== undefined && (
@@ -56,23 +65,23 @@ export function TankComponentButton({
/> />
)} )}
{children}
{discriminator !== undefined && ( {discriminator !== undefined && (
<Text <Text
size="1" size="1"
style={{ style={{
textShadow: '0 0 4px black', textShadow: '0 0 4px black',
zIndex: 1,
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
right: 8, right: 8,
textAlign: 'right', textAlign: 'right',
}} }}
color={variant === 'ghost' ? 'gray' : undefined}
> >
{discriminator} {discriminator}
</Text> </Text>
)} )}
{children}
</Button> </Button>
); );
} }
+213
View File
@@ -0,0 +1,213 @@
import { DropdownMenu, Flex } from '@radix-ui/themes';
import { useState } from 'react';
import {
EngineDefinition,
GunDefinition,
ShellDefinition,
TankDefinition,
TrackDefinition,
TurretDefinition,
} from '../core/blitzkrieg/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../core/blitzkrieg/tankDefinitions/constants';
import { ModuleButton } from './ModuleButtons/ModuleButton';
interface ModuleManagerProps {
tank: TankDefinition;
turret: TurretDefinition;
gun: GunDefinition;
shell: ShellDefinition;
engine: EngineDefinition;
track: TrackDefinition;
onChange?: (modules: {
turret: TurretDefinition;
gun: GunDefinition;
engine: EngineDefinition;
track: TrackDefinition;
}) => void;
}
export function ModuleManager({
tank,
onChange,
...modules
}: ModuleManagerProps) {
const [turretMenuOpen, setTurretMenuOpen] = useState(false);
const [gunMenuOpen, setGunMenuOpen] = useState(false);
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
return (
<Flex gap="1" wrap="wrap">
<ModuleButton
module="turret"
discriminator={TIER_ROMAN_NUMERALS[modules.turret.tier]}
first
last
rowChild
onClick={() => setTurretMenuOpen(true)}
/>
<DropdownMenu.Root open={turretMenuOpen} onOpenChange={setTurretMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.turret.id}`}>
{[...tank.turrets].reverse().map((turret) => {
return (
<DropdownMenu.RadioItem
key={turret.id}
value={`${turret.id}`}
onClick={() => {
const draft = { ...modules };
draft.turret = turret;
draft.gun = draft.turret.guns.at(-1)!;
draft.shell = draft.gun.shells.at(-1)!;
onChange?.(draft);
setTurretMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
/>{' '}
{turret.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
first
last
rowChild
onClick={() => setGunMenuOpen(true)}
/>
<DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.gun.id}`}>
{[...modules.turret.guns].reverse().map((gun) => {
return (
<DropdownMenu.RadioItem
key={gun.id}
value={`${gun.id}`}
onClick={() => {
const draft = { ...modules };
draft.gun = gun;
draft.shell = draft.gun.shells.at(-1)!;
onChange?.(draft);
setGunMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="gun"
discriminator={TIER_ROMAN_NUMERALS[modules.gun.tier]}
/>{' '}
{gun.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="engine"
discriminator={TIER_ROMAN_NUMERALS[modules.engine.tier]}
first
last
rowChild
onClick={() => setEngineMenuOpen(true)}
/>
<DropdownMenu.Root open={engineMenuOpen} onOpenChange={setEngineMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.engine.id}`}>
{[...tank.engines].reverse().map((engine) => {
return (
<DropdownMenu.RadioItem
key={engine.id}
value={`${engine.id}`}
onClick={() => {
const draft = { ...modules };
draft.engine = engine;
onChange?.(draft);
setEngineMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
/>{' '}
{engine.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[modules.track.tier]}
first
last
rowChild
onClick={() => setTrackMenuOpen(true)}
/>
<DropdownMenu.Root open={trackMenuOpen} onOpenChange={setTrackMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${modules.track.id}`}>
{[...tank.tracks].reverse().map((track) => {
return (
<DropdownMenu.RadioItem
key={track.id}
value={`${track.id}`}
onClick={() => {
const draft = { ...modules };
draft.track = track;
onChange?.(draft);
setTrackMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
/>{' '}
{track.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
);
}
+42 -2
View File
@@ -1,10 +1,16 @@
import { DiscordLogoIcon } from '@radix-ui/react-icons'; import { DiscordLogoIcon } from '@radix-ui/react-icons';
import { Flex, Text, Theme } from '@radix-ui/themes'; import { Flex, Text, Theme } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { useFullScreen } from '../../hooks/useFullScreen';
import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide'; import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide';
import { PatreonIcon } from '../../icons/Patreon';
import { theme } from '../../stitches.config'; import { theme } from '../../stitches.config';
export default function Navbar() { export default function Navbar() {
const isFullScreen = useFullScreen();
if (isFullScreen) return null;
return ( return (
<Theme radius="none"> <Theme radius="none">
<div <div
@@ -51,9 +57,10 @@ export default function Navbar() {
</Text> </Text>
</Flex> </Flex>
<Flex justify="center" align="center" gap="2"> <Flex justify="center" align="center" gap="4">
<Link <Link
href="https://discord.gg/nDt7AjGJQH" href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{ style={{
color: 'inherit', color: 'inherit',
display: 'flex', display: 'flex',
@@ -61,7 +68,40 @@ export default function Navbar() {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<DiscordLogoIcon /> <DiscordLogoIcon width={16} height={16} />
</Link>
<Link
href="https://ko-fi.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src="https://i.imgur.com/QdqgdP2.png"
style={{
width: 20,
height: 20,
objectFit: 'contain',
}}
/>
</Link>
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link> </Link>
</Flex> </Flex>
</Flex> </Flex>
+15 -7
View File
@@ -1,28 +1,36 @@
import { Flex, Theme } from '@radix-ui/themes'; import { Flex, FlexProps, Theme } from '@radix-ui/themes';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
interface PageWrapperProps extends ComponentProps<typeof Flex> { type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor']; color?: ComponentProps<typeof Theme>['accentColor'];
size?: 'regular' | 'wide' | 'double'; size?: number | string;
} };
export default function PageWrapper({ export default function PageWrapper({
style, style,
color, color,
size = 'regular', size = 800,
...props ...props
}: PageWrapperProps) { }: PageWrapperProps) {
return ( return (
<Theme accentColor={color}> <Theme
accentColor={color}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<Flex <Flex
direction="column" direction="column"
gap="4" gap="4"
style={{ style={{
width: '100%', width: '100%',
maxWidth: size === 'regular' ? 800 : size === 'double' ? 1200 : 960, maxWidth: size,
margin: 'auto', margin: 'auto',
padding: 16, padding: 16,
boxSizing: 'border-box', boxSizing: 'border-box',
flex: 1,
...style, ...style,
}} }}
+49
View File
@@ -0,0 +1,49 @@
import { Flex } from '@radix-ui/themes';
import { ProvisionButton } from './ModuleButtons/ProvisionButton';
interface ProvisionsManagerProps {
provisions: number[];
selected: number[];
disabled?: boolean;
onChange?: (provisions: number[]) => void;
}
export function ProvisionsManager({
provisions,
selected,
onChange,
disabled,
}: ProvisionsManagerProps) {
return (
<Flex wrap="wrap">
{provisions.map((provision, index) => {
const isSelected = selected.includes(provision);
return (
<ProvisionButton
key={provision}
first={index === 0}
last={index === provisions.length - 1}
rowChild
disabled={disabled && !isSelected}
provision={provision}
selected={isSelected}
onClick={() => {
if (!onChange) return;
const draft = [...selected];
if (isSelected) {
draft.splice(draft.indexOf(provision), 1);
} else {
draft.push(provision);
}
onChange(draft);
}}
/>
);
})}
</Flex>
);
}
+14 -12
View File
@@ -4,6 +4,7 @@ interface Tool {
description: string; description: string;
disabled?: boolean; disabled?: boolean;
href?: string; href?: string;
pageDescription?: string;
} }
export const TOOLS: Tool[] = [ export const TOOLS: Tool[] = [
@@ -11,16 +12,29 @@ export const TOOLS: Tool[] = [
id: 'session', id: 'session',
title: 'Session tracker', title: 'Session tracker',
description: 'Live session performance tracker', description: 'Live session performance tracker',
pageDescription:
'Track your stats in real time as you play World of Tanks Blitz',
}, },
{ {
id: 'tankopedia', id: 'tankopedia',
title: 'Tankopedia', title: 'Tankopedia',
description: 'Blitz tank encyclopedia', description: 'Blitz tank encyclopedia',
pageDescription:
'Statistics, armor, and more for all tanks in World of Tanks Blitz',
}, },
{ {
id: 'rating', id: 'rating',
title: 'Rating leaderboard', title: 'Rating leaderboard',
description: 'Full rating gamemode leaderboard', description: 'Full rating gamemode leaderboard',
pageDescription:
'Live and archived full rating leaderboard for World of Tanks Blitz',
},
{
id: 'compare',
title: 'Compare',
description: 'Compare tank statistics and loadouts',
pageDescription:
'Compare tanks statistics and loadouts in World of Tanks Blitz',
}, },
{ {
id: 'discord', id: 'discord',
@@ -28,18 +42,6 @@ export const TOOLS: Tool[] = [
description: 'Stats and tools right in Discord', description: 'Stats and tools right in Discord',
href: 'https://discord.com/application-directory/1097673957865443370', href: 'https://discord.com/application-directory/1097673957865443370',
}, },
// {
// id: 'inactive',
// title: 'Inactivity tracker',
// description: 'Find inactive members of a clan',
// disabled: true,
// },
// {
// id: 'profile',
// title: 'Player profile',
// description: "A player's basic non-statistical info",
// disabled: true,
// },
{ {
id: 'more', id: 'more',
title: 'More coming soon', title: 'More coming soon',
-9
View File
@@ -161,15 +161,6 @@ export async function extractModel(data: string, path: string) {
const rotation = new Quaternion(); const rotation = new Quaternion();
const scale = new Vector3(); const scale = new Vector3();
if (
!component['tc.worldTranslation'] ||
!component['tc.worldScale'] ||
!component['tc.localTranslation'] ||
!component['tc.localScale']
) {
console.log(component);
}
new Matrix4() new Matrix4()
.multiplyMatrices( .multiplyMatrices(
new Matrix4().compose( new Matrix4().compose(
+19
View File
@@ -0,0 +1,19 @@
import { Region } from '../../constants/regions';
export function idToRange(id: number): Region {
if (id >= 42e8) {
throw new Error('bot id');
} else if (id >= 31e8) {
throw new Error('china id');
} else if (id >= 20e8) {
return 'asia';
} else if (id >= 10e8) {
return 'com';
} else if (id >= 5e8) {
return 'eu';
} else if (id >= 0) {
throw new Error('ru id');
} else {
throw new Error(`id ${id} is out of range`);
}
}
+3 -35
View File
@@ -1,10 +1,7 @@
import { degToRad } from 'three/src/math/MathUtils'; import { mutateDuel } from '../../stores/duel';
import { DuelMember, mutateDuel } from '../../stores/duel';
import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants';
import { availableProvisions } from './availableProvisions';
import { modelDefinitions } from './modelDefinitions';
import { provisionDefinitions } from './provisionDefinitions'; import { provisionDefinitions } from './provisionDefinitions';
import { tankDefinitions } from './tankDefinitions'; import { tankDefinitions } from './tankDefinitions';
import { tankToDuelMember } from './tankToDuelMember';
export async function assignDuelMember( export async function assignDuelMember(
type: 'antagonist' | 'protagonist' | 'both', type: 'antagonist' | 'protagonist' | 'both',
@@ -12,37 +9,8 @@ export async function assignDuelMember(
) { ) {
const awaitedTankDefinitions = await tankDefinitions; const awaitedTankDefinitions = await tankDefinitions;
const awaitedProvisionDefinitions = await provisionDefinitions; const awaitedProvisionDefinitions = await provisionDefinitions;
const awaitedModelDefinitions = await modelDefinitions;
const tankModelDefinition = awaitedModelDefinitions[id];
const tank = awaitedTankDefinitions[id]; const tank = awaitedTankDefinitions[id];
const turret = tank.turrets.at(-1)!; const member = tankToDuelMember(tank, awaitedProvisionDefinitions);
const gun = turret.guns.at(-1)!;
const shell = gun.shells[0];
const engine = tank.engines.at(-1)!;
const track = tank.tracks.at(-1)!;
const provisionsList = availableProvisions(
tank,
gun,
awaitedProvisionDefinitions,
);
const member: DuelMember = {
tank,
engine,
turret,
gun,
shell,
track,
equipment: genericDefaultEquipmentMatrix,
pitch: degToRad(tankModelDefinition.turretRotation?.pitch ?? 0),
yaw: 0,
camouflage: true,
consumables: [],
cooldownBooster: 0,
crewMastery: 1,
provisions: provisionsList
.filter((provision) => provision.crew !== undefined)
.map((provision) => provision.id),
};
mutateDuel((draft) => { mutateDuel((draft) => {
draft.assigned = true; draft.assigned = true;
@@ -0,0 +1,14 @@
import { permanentSkills } from '../../app/tools/tankopedia/[id]/components/Characteristics/components/Skills/constants';
import { SkillDefinitions } from './skillDefinitions';
export function createDefaultSkills(skillDefinitions: SkillDefinitions) {
const skills: Record<string, number> = {};
Object.values(skillDefinitions.classes).forEach((tankSkills) => {
tankSkills.forEach((skill) => {
skills[skill] = permanentSkills.includes(skill) ? 7 : 0;
});
});
return skills;
}
-21
View File
@@ -1,21 +0,0 @@
import { Region } from '../../constants/regions';
import { discordBlitzDB } from './discordBlitzDB';
export function linkBlitzAndDiscord(
discord: number,
region: Region,
blitz: number,
) {
return discordBlitzDB.discord_blitz.upsert({
where: { discord },
update: { region, blitz },
create: { discord, region, blitz },
});
}
export function getBlitzFromDiscord(discord: number) {
return discordBlitzDB.discord_blitz.findUnique({
where: { discord },
select: { region: true, blitz: true },
}) as Promise<null | { region: Region; blitz: number }>;
}
-3
View File
@@ -1,3 +0,0 @@
import { PrismaClient } from '@prisma/client';
export const discordBlitzDB = new PrismaClient();
+30
View File
@@ -0,0 +1,30 @@
import { usersDatabase } from '../../databases/users';
import { idToRange } from '../blitz/idToRegion';
export async function flagUserActivity(blitz: number) {
await usersDatabase.users.upsert({
where: { blitz },
update: {},
create: { blitz },
});
}
export async function linkBlitzAndDiscord(discord: bigint, blitz: number) {
await usersDatabase.discordUsers.upsert({
where: { discord },
update: { blitz },
create: { discord, blitz },
});
await flagUserActivity(blitz);
}
export async function getBlitzFromDiscord(discord: bigint) {
const unique = await usersDatabase.discordUsers.findUnique({
where: { discord },
});
if (unique === null) return null;
await flagUserActivity(unique.blitz);
return { id: unique.blitz, region: idToRange(unique.blitz) };
}
+10
View File
@@ -0,0 +1,10 @@
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export interface GameDefinitions {
version: string;
}
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
asset('definitions/game.cdon.lz4'),
);
+4 -7
View File
@@ -1,16 +1,13 @@
import { useDuel } from '../../stores/duel'; import { EquipmentMatrix } from '../../stores/duel';
import { equipmentDefinitions } from './equipmentDefinitions'; import { equipmentDefinitions } from './equipmentDefinitions';
export async function hasEquipment( export async function hasEquipment(
id: number, id: number,
antagonist = false, equipmentPreset: string,
equipmentMatrix = useDuel.getState()[ equipmentMatrix: EquipmentMatrix,
antagonist ? 'antagonist' : 'protagonist'
]!.equipment,
) { ) {
const awaitedEquipmentDefinitions = await equipmentDefinitions; const awaitedEquipmentDefinitions = await equipmentDefinitions;
const member = useDuel.getState()[antagonist ? 'antagonist' : 'protagonist']!; const presetRows = awaitedEquipmentDefinitions.presets[equipmentPreset];
const presetRows = awaitedEquipmentDefinitions.presets[member.tank.equipment];
return presetRows.some((equipmentRow, rowIndex) => { return presetRows.some((equipmentRow, rowIndex) => {
return equipmentRow.some((equipment, columnIndex) => { return equipmentRow.some((equipment, columnIndex) => {
const choice = equipmentMatrix[rowIndex][columnIndex]; const choice = equipmentMatrix[rowIndex][columnIndex];
+2 -2
View File
@@ -1,5 +1,5 @@
import { usersDatabase } from '../../databases/users';
import { client } from '../discord/client'; import { client } from '../discord/client';
import { discordBlitzDB } from './discordBlitzDB';
const EXIT_EVENTS = [ const EXIT_EVENTS = [
'beforeExit', 'beforeExit',
@@ -13,7 +13,7 @@ const EXIT_EVENTS = [
async function cleanup() { async function cleanup() {
console.log('Cleaning up...'); console.log('Cleaning up...');
await Promise.all([discordBlitzDB.$disconnect(), client.destroy()]); await Promise.all([usersDatabase.$disconnect(), client.destroy()]);
console.log('Gracefully exiting...'); console.log('Gracefully exiting...');
@@ -17,3 +17,5 @@ export interface ProvisionDefinitions {
export const provisionDefinitions = fetchCdonLz4<ProvisionDefinitions>( export const provisionDefinitions = fetchCdonLz4<ProvisionDefinitions>(
asset('definitions/provisions.cdon.lz4'), asset('definitions/provisions.cdon.lz4'),
); );
provisionDefinitions.then(console.log);
+1 -1
View File
@@ -5,7 +5,7 @@ config();
const SECRET_KEYS = [ const SECRET_KEYS = [
'DISCORD_TOKEN', 'DISCORD_TOKEN',
'GH_TOKEN', 'GH_TOKEN',
'DISCORD_BLITZ_DB', 'DATABASE_URL',
'WOTB_DLC_CDN', 'WOTB_DLC_CDN',
] as const; ] as const;
+11
View File
@@ -0,0 +1,11 @@
import { TankClass } from '../../components/Tanks';
import { asset } from './asset';
import { fetchCdonLz4 } from './fetchCdonLz4';
export interface SkillDefinitions {
classes: Record<TankClass, string[]>;
}
export const skillDefinitions = fetchCdonLz4<SkillDefinitions>(
asset('definitions/skills.cdon.lz4'),
);
+484
View File
@@ -0,0 +1,484 @@
import { EquipmentMatrix } from '../../stores/duel';
import { isExplosive } from '../blitz/isExplosive';
import { resolveNearPenetration } from '../blitz/resolveNearPenetration';
import { coefficient } from './coefficient';
import { degressiveStat } from './degressiveStat';
import { EquipmentDefinitions } from './equipmentDefinitions';
import { ModelDefinitions } from './modelDefinitions';
import { normalizeBoundingBox } from './normalizeBoundingBox';
import { progressiveStat } from './progressiveStat';
import { ProvisionDefinitions } from './provisionDefinitions';
import { resolveDpm } from './resolveDpm';
import { sum } from './sum';
import {
EngineDefinition,
GunDefinition,
ShellDefinition,
TankDefinition,
TrackDefinition,
TurretDefinition,
} from './tankDefinitions';
import { unionBoundingBox } from './unionBoundingBox';
export function tankCharacteristics(
{
tank,
turret,
gun,
shell,
consumables,
equipmentMatrix,
crewMastery,
provisions,
engine,
crewSkills,
camouflage,
track,
stockEngine,
stockTrack,
stockTurret,
stockGun,
applyReactive,
applyDynamicArmor,
applySpallLiner,
}: {
tank: TankDefinition;
turret: TurretDefinition;
gun: GunDefinition;
shell: ShellDefinition;
track: TrackDefinition;
engine: EngineDefinition;
stockEngine: EngineDefinition;
stockTrack: TrackDefinition;
stockTurret: TurretDefinition;
stockGun: GunDefinition;
consumables: number[];
equipmentMatrix: EquipmentMatrix;
crewMastery: number;
provisions: number[];
crewSkills: Record<string, number>;
camouflage: boolean;
applyReactive: boolean;
applyDynamicArmor: boolean;
applySpallLiner: boolean;
},
{
modelDefinitions,
equipmentDefinitions,
provisionDefinitions,
}: {
modelDefinitions: ModelDefinitions;
equipmentDefinitions: EquipmentDefinitions;
provisionDefinitions: ProvisionDefinitions;
},
) {
const presetRows = equipmentDefinitions.presets[tank.equipment];
const tankModelDefinition = modelDefinitions[tank.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition = turretModelDefinition.guns[gun.id];
function equipment(id: number) {
return presetRows.some((equipmentRow, rowIndex) => {
return equipmentRow.some((equipment, columnIndex) => {
const choice = equipmentMatrix[rowIndex][columnIndex];
if (choice === 0) return false;
const equipped = equipment[choice === -1 ? 0 : 1];
return equipped === id;
});
});
}
function consumable(id: number) {
return consumables.includes(id);
}
function provision(id: number) {
return provisions.includes(id);
}
const hasRammer = equipment(100);
const hasImprovedVentilation = equipment(102);
const hasCalibratedShells = equipment(103);
const hasEnhancedGunLayingDrive = equipment(104);
const hasSupercharger = equipment(107);
const hasVerticalStabilizer = equipment(105);
const hasRefinedGun = equipment(106);
const hasImprovedAssembly = equipment(111);
const hasImprovedOptics = equipment(114);
const hasImprovedControl = equipment(116);
const hasEngineAccelerator = equipment(117);
const hasCamouflageNet = equipment(115);
const hasImprovedVerticalStabilizer = equipment(122);
const hasImprovedSuspension = equipment(123);
const hasTungsten = consumable(45);
const hasAdrenaline = consumable(18);
const hasShellReloadBoost = consumable(29);
const hasEnginePowerBoost = consumable(14);
const hasImprovedEnginePowerBoost = consumable(27);
const hasReticleCalibration = consumable(28);
const hasStandardFuel = provision(18);
const hasImprovedFuel = provision(19);
const hasProtectiveKit = provision(22);
const hasSandbagArmor = provision(28);
const hasEnhancedSandbagArmor = provision(29);
const hasImprovedGunPowder = provision(46);
const hasGearOil = provision(44);
const hasImprovedGearOil = provision(45);
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const provisionCrewBonus =
provisions.reduce(
(total, provision) =>
provisionDefinitions[provision].crew
? total + provisionDefinitions[provision].crew! / 100
: total,
0,
) + (hasImprovedVentilation ? 0.05 : 0);
const commanderMastery = crewMastery + provisionCrewBonus;
const crewMemberMastery = commanderMastery * 1.1;
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
const damageCoefficient =
coefficient([hasTungsten, 0.15]) *
coefficient(
[applyReactive, -0.2],
[applyDynamicArmor, -0.1],
[applySpallLiner && shell.type === 'he', -0.2],
);
const reloadCoefficient =
(coefficient([hasRammer, -0.07]) *
coefficient([true, degressiveStat(crewMemberMastery)])) /
coefficient([hasAdrenaline && gun.type === 'regular', 0.2]);
const penetrationCoefficient = coefficient([
hasCalibratedShells,
isExplosive(shell.type) ? 0.1 : 0.05,
]);
const healthCoefficient = coefficient(
[hasSandbagArmor, 0.03],
[hasEnhancedSandbagArmor, 0.06],
[hasImprovedAssembly, 0.06],
);
const shellVelocityCoefficient = coefficient(
[hasSupercharger, 0.3],
[hasImprovedGunPowder, 0.3],
);
const penetrationLossOverDistanceCoefficient = coefficient([
hasSupercharger,
-0.5,
]);
const aimTimeCoefficient =
coefficient(
[hasEnhancedGunLayingDrive, -0.1],
[hasReticleCalibration, -0.4],
) * coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionDamagedCoefficient = coefficient([
hasVerticalStabilizer,
-0.15,
]);
const dispersionHullTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turn / 100],
);
const dispersionAfterShotCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.soft_recoil / 100],
);
const dispersionMovingCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_driving / 100],
);
const dispersionTurretTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turret * (2 / 100)],
);
const enginePowerCoefficient = coefficient(
[
hasEngineAccelerator &&
(tank.class === 'lightTank' || tank.class === 'mediumTank'),
0.05,
],
[
hasEngineAccelerator &&
(tank.class === 'heavyTank' || tank.class === 'AT-SPG'),
0.07,
],
[hasEnginePowerBoost, 0.2],
[hasImprovedEnginePowerBoost, 0.4],
[hasStandardFuel, 0.03],
[hasImprovedFuel, 0.1],
[hasGearOil, 0.03],
[hasImprovedGearOil, 0.06],
);
const turretTraverseCoefficient =
coefficient([hasStandardFuel, 0.03], [hasImprovedFuel, 0.1]) *
coefficient([true, progressiveStat(crewMemberMastery)]);
const hullTraverseCoefficient =
coefficient(
[hasImprovedControl, 0.1],
[hasImprovedEnginePowerBoost, 0.05],
[true, crewSkills.virtuoso / 100],
) * coefficient([true, progressiveStat(crewMemberMastery)]);
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
const viewRangeCoefficient =
coefficient([
hasImprovedOptics,
tank.class === 'AT-SPG' ? 0.05 : tank.class === 'heavyTank' ? 0.07 : 0.1,
]) * coefficient([true, progressiveStat(commanderMastery)]);
const fireChanceCoefficient = coefficient([hasProtectiveKit, -0.2]);
const speedForwardsSum = sum(
[hasImprovedEnginePowerBoost, 5],
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const speedBackwardsSum = sum(
[hasImprovedEnginePowerBoost, 10],
[hasGearOil, 2],
[hasImprovedGearOil, 4],
);
const camouflageSumMoving = sum(
[
hasCamouflageNet,
tank.class === 'heavyTank' ? 0.03 : tank.class === 'AT-SPG' ? 0.07 : 0.05,
],
[
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
);
const camouflageSumStill = sum(
[
hasCamouflageNet,
2 *
(tank.class === 'heavyTank'
? 0.03
: tank.class === 'AT-SPG'
? 0.07
: 0.05),
],
[
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
[true, crewSkills.camouflage * (3 / 100)],
);
const size = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.boundingBox,
turretModelDefinition.boundingBox,
),
);
const weightKg =
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
const weightTons = weightKg / 1000;
const stockWeight =
tank.weight +
stockEngine.weight +
stockTrack.weight +
stockTurret.weight +
stockGun.weight;
const resolvedEnginePower = engine.power * enginePowerCoefficient;
const dpm = resolveDpm(
gun,
shell,
damageCoefficient,
reloadCoefficient,
intraClipCoefficient,
);
const dpmMaximum =
gun.type === 'autoReloader'
? gun.reload.at(-1)! < gun.reload.at(-2)!
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
60
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient)) *
60
: undefined;
const dpmEffective =
gun.type === 'autoReloader'
? gun.reload.at(-1)! < gun.reload.at(-2)!
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(0, -1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient *
shell.damage.armor *
gun.reload.slice(0, -1).length
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient * shell.damage.armor * gun.reload.slice(1).length
: undefined;
const shells = gun.type === 'regular' ? 1 : gun.count;
const mostOptimalShellIndex =
gun.type === 'autoReloader'
? gun.reload.at(-1)! < gun.reload.at(-2)!
? gun.reload.length
: 1
: undefined;
const shellReloads =
gun.type === 'autoReloader'
? gun.reload.map((reload) => reload * reloadCoefficient)
: undefined;
const shellReload =
gun.type === 'autoReloader' ? undefined : gun.reload * reloadCoefficient;
const intraClip =
gun.type === 'regular' ? undefined : gun.intraClip * intraClipCoefficient;
const caliber = shell.caliber;
const penetration =
resolveNearPenetration(shell.penetration) * penetrationCoefficient;
const damage = shell.damage.armor * damageCoefficient;
const moduleDamage = shell.damage.module * damageCoefficient;
const explosionRadius = shell.explosionRadius;
const shellVelocity = shell.speed * shellVelocityCoefficient;
const aimTime = gun.aimTime * aimTimeCoefficient;
const dispersion = gun.dispersion.base * dispersionStillCoefficient;
const dispersionMoving = track.dispersion.move * dispersionMovingCoefficient;
const dispersionHullTraversing =
track.dispersion.traverse * dispersionHullTraverseCoefficient;
const dispersionTurretTraversing =
gun.dispersion.traverse * dispersionTurretTraverseCoefficient;
const dispersionShooting =
gun.dispersion.shot * dispersionAfterShotCoefficient;
const dispersionGunDamaged =
gun.dispersion.damaged * dispersionDamagedCoefficient;
const gunDepression =
gunModelDefinition.pitch.max +
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0);
const gunElevation =
-gunModelDefinition.pitch.min -
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0);
const gunFrontalDepression = gunModelDefinition.pitch.front?.max;
const gunFrontalElevation = gunModelDefinition.pitch.front
? -gunModelDefinition.pitch.front.min
: undefined;
const gunRearDepression = gunModelDefinition.pitch.back?.max;
const gunRearElevation = gunModelDefinition.pitch.back
? -gunModelDefinition.pitch.back.min
: undefined;
const azimuthLeft = turretModelDefinition.yaw
? -turretModelDefinition.yaw.min
: undefined;
const azimuthRight = turretModelDefinition.yaw?.max;
const speedForwards = tank.speed.forwards + speedForwardsSum;
const speedBackwards = tank.speed.backwards + speedBackwardsSum;
const enginePower = resolvedEnginePower;
const powerToWeightRatioHardTerrain =
resolvedEnginePower /
weightTons /
(track.resistance.hard * resistanceCoefficient);
const powerToWeightRatioMediumTerrain =
resolvedEnginePower /
weightTons /
(track.resistance.medium * resistanceCoefficient);
const powerToWeightRatioSoftTerrain =
resolvedEnginePower /
weightTons /
(track.resistance.soft * resistanceCoefficient);
const weight = weightTons;
const turretTraverseSpeed = turret.traverseSpeed * turretTraverseCoefficient;
const hullTraverseHardTerrain =
(resolvedEnginePower / stockEngine.power) *
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard / (track.resistance.hard * resistanceCoefficient)) *
(stockWeight / weightKg);
const hullTraverseMediumTerrain =
(resolvedEnginePower / stockEngine.power) *
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard /
(track.resistance.medium * resistanceCoefficient)) *
(stockWeight / weightKg);
const hullTraverseSoftTerrain =
(resolvedEnginePower / stockEngine.power) *
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard / (track.resistance.soft * resistanceCoefficient)) *
(stockWeight / weightKg);
const health = (tank.health + turret.health) * healthCoefficient;
const fireChance = engine.fireChance * fireChanceCoefficient;
const viewRange = turret.viewRange * viewRangeCoefficient;
const camouflageStill = tank.camouflage.still + camouflageSumStill;
const camouflageMoving = tank.camouflage.moving + camouflageSumMoving;
const camouflageShootingStill =
tank.camouflage.still * gun.camouflageLoss + camouflageSumStill;
const camouflageShootingMoving =
tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving;
const camouflageCaughtOnFire =
tank.camouflage.onFire * tank.camouflage.still + camouflageSumStill;
const width = size[2];
const height = size[0];
const length = size[1];
const volume = width * height * length;
return {
dpm,
dpmMaximum,
dpmEffective,
shells,
mostOptimalShellIndex,
shellReloads,
shellReload,
intraClip,
caliber,
penetration,
damage,
moduleDamage,
explosionRadius,
shellVelocity,
aimTime,
dispersion,
dispersionMoving,
dispersionHullTraversing,
dispersionTurretTraversing,
dispersionShooting,
dispersionGunDamaged,
gunDepression,
gunElevation,
gunFrontalDepression,
gunFrontalElevation,
gunRearDepression,
gunRearElevation,
azimuthLeft,
azimuthRight,
speedForwards,
speedBackwards,
enginePower,
powerToWeightRatioHardTerrain,
powerToWeightRatioMediumTerrain,
powerToWeightRatioSoftTerrain,
weight,
turretTraverseSpeed,
hullTraverseHardTerrain,
hullTraverseMediumTerrain,
hullTraverseSoftTerrain,
health,
fireChance,
viewRange,
camouflageStill,
camouflageMoving,
camouflageShootingStill,
camouflageShootingMoving,
camouflageCaughtOnFire,
width,
height,
length,
volume,
};
}
@@ -55,6 +55,7 @@ export interface TankDefinition {
export interface TrackDefinition { export interface TrackDefinition {
id: number; id: number;
tier: Tier; tier: Tier;
name: string;
weight: number; weight: number;
traverseSpeed: number; traverseSpeed: number;
xp?: number; xp?: number;
@@ -0,0 +1,15 @@
import { uniqueId } from 'lodash';
import { CompareMember } from '../../stores/compare';
import { ProvisionDefinitions } from './provisionDefinitions';
import { TankDefinition } from './tankDefinitions';
import { tankToDuelMember } from './tankToDuelMember';
export function tankToCompareMember(
tank: TankDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
return {
...tankToDuelMember(tank, provisionDefinitions),
key: uniqueId(),
} satisfies CompareMember;
}
+45
View File
@@ -0,0 +1,45 @@
import { DuelMember } from '../../stores/duel';
import { genericDefaultEquipmentMatrix } from '../../stores/duel/constants';
import { availableProvisions } from './availableProvisions';
import { ProvisionDefinitions } from './provisionDefinitions';
import { TankDefinition } from './tankDefinitions';
const PROVISION_PREFERENCES = [
19, // improved fuel
18, // standard fuel
22, // protective kit
];
export function tankToDuelMember(
tank: TankDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const provisionsList = availableProvisions(tank, gun, provisionDefinitions);
return {
camouflage: true,
consumables: [],
equipmentMatrix: genericDefaultEquipmentMatrix,
cooldownBooster: 0,
crewMastery: 1,
provisions: provisionsList
.sort(
(a, b) =>
PROVISION_PREFERENCES.indexOf(a.id) -
PROVISION_PREFERENCES.indexOf(b.id),
)
.sort((a, b) => (b.crew ?? 0) - (a.crew ?? 0))
.slice(0, tank.provisions)
.map(({ id }) => id),
engine: tank.engines.at(-1)!,
gun,
shell: tank.turrets.at(-1)!.guns.at(-1)!.shells[0],
tank,
track: tank.tracks.at(-1)!,
yaw: 0,
pitch: 0,
turret,
} satisfies DuelMember;
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more