Compare commits

...
246 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
TrèsAbhi 034429cd47 Merge branch 'main' of https://github.com/tresabhi/blitzkrieg 2024-03-31 17:48:30 -05:00
TrèsAbhi 57867317cc fix: missing info 2024-03-31 17:48:15 -05:00
TrèsAbhi c2e314b193 feat: log 2024-03-31 17:48:01 -05:00
TrèsAbhi 9d2af1de13 fix: quick equipments 2024-03-31 17:45:22 -05:00
TrèsAbhi 232823566b fix: missing info 2024-03-30 12:48:17 -05:00
TrèsAbhi 958f0f2a52 fix: log version 2024-03-30 12:44:46 -05:00
TrèsAbhi b34fef2147 feat: logs 2024-03-30 12:44:34 -05:00
TrèsAbhi fe661124fa Merge branch 'dev' of https://github.com/tresabhi/blitzkrieg into dev 2024-03-30 12:39:19 -05:00
TrèsAbhi 457dcbcad5 fix: 0 battle handling 2024-03-30 12:38:53 -05:00
TrèsAbhi c18ee70eba fix: /rating use midnight leaderboard 2024-03-30 12:38:53 -05:00
TrèsAbhi 095ffc3d6a fix: sync on click inspector 2024-03-30 12:38:53 -05:00
TrèsAbhi e8b91648b1 fix: click through post inputs 2024-03-30 12:38:53 -05:00
TrèsAbhi 1b43f6ba99 fix: use final thickness 2024-03-30 12:38:53 -05:00
TrèsAbhi fc5360363c fix: uniform inv proj mat 2024-03-30 12:38:53 -05:00
TrèsAbhi 47b3c3029e fix: more accurate HE 2024-03-30 12:38:53 -05:00
TrèsAbhi dea7f34ca3 fix: use normal proj mat 2024-03-30 12:38:53 -05:00
TrèsAbhi 1e5026ea4a fix: persistent equipment 2024-03-30 12:38:53 -05:00
TrèsAbhi 98f568ff6a fix: use three js inverse view proj 2024-03-30 12:38:53 -05:00
TrèsAbhi 10a6f8371d fix: 0 battle handling 2024-03-30 12:33:17 -05:00
TrèsAbhi 0933aa6615 fix: /rating use midnight leaderboard 2024-03-30 12:32:48 -05:00
TrèsAbhi 746592418d Update cd-rating-latest.yml 2024-03-30 12:22:20 -05:00
TrèsAbhi e2404b063c fix: sync on click inspector 2024-03-30 12:14:49 -05:00
TrèsAbhi bae1301556 fix: click through post inputs 2024-03-30 12:13:47 -05:00
TrèsAbhi c2de4952ad fix: use final thickness 2024-03-30 11:34:39 -05:00
TrèsAbhi cce61d8075 fix: uniform inv proj mat 2024-03-30 11:31:44 -05:00
TrèsAbhi 34a0388f45 fix: more accurate HE 2024-03-30 11:28:34 -05:00
TrèsAbhi 9d62965fc3 fix: use normal proj mat 2024-03-30 10:39:58 -05:00
TrèsAbhi 892434e28f fix: persistent equipment 2024-03-30 10:30:30 -05:00
TrèsAbhi 2597fe4bcd fix: use three js inverse view proj 2024-03-30 10:23:23 -05:00
TrèsAbhi f85b4b36bd fix: async exec 2024-03-29 16:45:17 -05:00
TrèsAbhi f777dafefc feat: generated list 2024-03-29 16:42:56 -05:00
TrèsAbhi 6ef65ccea3 fix: don't install fixed sharp twice 2024-03-29 16:40:07 -05:00
TrèsAbhi 974b27a313 fix: production flag 2024-03-29 16:38:51 -05:00
TrèsAbhi 5124d8abcd Merge branch 'dev' of https://github.com/tresabhi/blitzkrieg into dev 2024-03-29 16:33:15 -05:00
TrèsAbhi 849bd356c0 feat: logs 2024-03-29 16:33:13 -05:00
TrèsAbhi 6406486441 New Crowdin updates (#104) 2024-03-28 10:33:46 -05:00
TrèsAbhi 2993ada2e5 fix: always absolute deltas 2024-03-28 10:33:05 -05:00
TrèsAbhi f083c18dd0 feat: more optimal fetching 2024-03-28 10:31:36 -05:00
TrèsAbhi c7021c5689 feat: finalized 2024-03-28 10:27:33 -05:00
TrèsAbhi 815369805a fix: null rating type safety 2024-03-25 21:11:04 -05:00
TrèsAbhi d8feb5c3a8 fix: null rating type safety 2024-03-25 19:22:21 -05:00
TrèsAbhi cb101da8a8 fix: types 2024-03-25 18:43:23 -05:00
TrèsAbhi 71481f5971 feat: renaming 2024-03-25 18:22:37 -05:00
TrèsAbhi 817796ed3c feat: full command 2024-03-25 18:12:33 -05:00
TrèsAbhi ebb7618f2d feat: hero stat 2024-03-25 18:00:07 -05:00
TrèsAbhi 9717f153cd New Crowdin updates (#103) 2024-03-25 10:20:01 -05:00
TrèsAbhi afa0cfaeef feat: start on final design 2024-03-24 22:06:48 -05:00
TrèsAbhi 298b89b227 feat: new rating stat style 2024-03-21 22:32:01 -05:00
TrèsAbhi 3b94034767 feat: change ratings path to rating 2024-03-21 16:44:47 -05:00
TrèsAbhi 444b4c21af feat: sitemao 2024-03-21 16:29:59 -05:00
TrèsAbhi 3e9c828781 New Crowdin updates (#102) 2024-03-21 14:19:41 -05:00
TrèsAbhi ca3a1fbe3f feat: ratings stats 2024-03-20 20:40:01 -05:00
TrèsAbhi 5f28b541e3 feat: started work on rating stats 2024-03-20 18:03:00 -05:00
TrèsAbhi 18edd349f2 fix: only exclude root test files 2024-03-20 17:37:56 -05:00
TrèsAbhi f7c6a065c1 feat: renamed /ratings to /ratings-leaderboard 2024-03-20 17:33:21 -05:00
TrèsAbhi 91d48eeadd feat: remove reward section 2024-03-20 17:04:59 -05:00
TrèsAbhi 21bafa1b7b feat: added more stats 2024-03-20 16:58:43 -05:00
TrèsAbhi 38af2f2817 feat: extended ratings stats 2024-03-19 18:21:56 -05:00
TrèsAbhi 8faa1143e9 fix: only build assets on push to main 2024-03-18 18:13:34 -05:00
TrèsAbhi 8f34ebbca4 feat: grouped ratings workflows 2024-03-18 18:12:55 -05:00
TrèsAbhi 1c03701e17 feat: ratings leaderboard disk format 2024-03-18 18:06:45 -05:00
183 changed files with 7541 additions and 3024 deletions

No files matched your search

+3 -2
View File
@@ -2,6 +2,7 @@ name: CD Build Assets
on: on:
push: push:
branches: main
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: '0 2 * * *' - cron: '0 2 * * *'
@@ -52,7 +53,7 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}" $env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
yarn setup:patches --depot yarn setup:patches --depot
@@ -60,5 +61,5 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }} yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
+1 -7
View File
@@ -23,12 +23,6 @@ jobs:
with: with:
node-version: 18 node-version: 18
- name: Install Yarn
run: npm install --location=global yarn
- name: Add fixed sharp
run: yarn add [email protected] --ignore-engines
- name: Download release assets - name: Download release assets
run: | run: |
DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \ DOWNLOAD_URL=$(curl -s https://api.github.com/repos/tresabhi/blitzkrieg/releases/latest \
@@ -39,4 +33,4 @@ jobs:
unzip bot.zip unzip bot.zip
- name: Run bot - name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} node --enable-source-maps index.cjs
@@ -1,11 +1,7 @@
name: CD Ratings Asia name: CD Rating Asia
# latest: '0 0 * * *'
# asia midnight: '0 17 * * *'
on: on:
schedule: schedule:
- cron: '0 0 * * *'
- cron: '0 17 * * *' - cron: '0 17 * * *'
jobs: jobs:
@@ -35,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
@@ -1,11 +1,7 @@
name: CD Ratings Europe name: CD Rating Europe
# latest: '0 0 * * *'
# eu midnight: '0 23 * * *'
on: on:
schedule: schedule:
- cron: '0 0 * * *'
- cron: '0 23 * * *' - cron: '0 23 * * *'
jobs: jobs:
@@ -35,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
+34
View File
@@ -0,0 +1,34 @@
name: CD Rating Latest
on:
schedule:
- cron: '0 0 * * *'
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate all leaderboards
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
@@ -1,11 +1,7 @@
name: CD Ratings North America name: CD Rating North America
# latest: '0 0 * * *'
# na midnight: '0 5 * * *'
on: on:
schedule: schedule:
- cron: '0 0 * * *'
- cron: '0 5 * * *' - cron: '0 5 * * *'
jobs: jobs:
@@ -35,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na --target=midnight --production
+3 -3
View File
@@ -33,11 +33,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Add fixed sharp - name: Build Prisma
run: yarn add [email protected] --ignore-engines run: yarn prisma generate
- name: Build - name: Build
run: yarn build:bot run: yarn build:bot --production
- name: Archive build - name: Archive build
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+1 -1
View File
@@ -8,7 +8,6 @@ dist/
dist.* dist.*
yarn-error.log yarn-error.log
/test.* /test.*
test.*
/docs/.vitepress/dist /docs/.vitepress/dist
/docs/.vitepress/cache /docs/.vitepress/cache
servers.csv servers.csv
@@ -19,3 +18,4 @@ temp/
/.VSCodeCounter /.VSCodeCounter
/.yarn /.yarn
depots depots
public/sitemap*
+8 -2
View File
@@ -3,6 +3,7 @@
"AABBOX", "AABBOX",
"Abhi", "Abhi",
"accountinfo", "accountinfo",
"adsense",
"aeson", "aeson",
"alphatest", "alphatest",
"APAC", "APAC",
@@ -16,6 +17,7 @@
"betakrieg", "betakrieg",
"BINORMAL", "BINORMAL",
"Binormals", "Binormals",
"BKRL",
"bkwd", "bkwd",
"blitzstars", "blitzstars",
"Camo", "Camo",
@@ -71,6 +73,7 @@
"leaderboards", "leaderboards",
"lerp", "lerp",
"LINEARSIZE", "LINEARSIZE",
"loadouts",
"loddist", "loddist",
"lods", "lods",
"metallicness", "metallicness",
@@ -99,13 +102,13 @@
"POSITIVEZ", "POSITIVEZ",
"Promisable", "Promisable",
"Pyogenics", "Pyogenics",
"ratingsleaderboard", "ratingleaderboard",
"ratingsplot",
"raycaster", "raycaster",
"researchable", "researchable",
"Resvg", "Resvg",
"roboto", "roboto",
"SCPG", "SCPG",
"scroller",
"searchclans", "searchclans",
"searchplayers", "searchplayers",
"searchtanks", "searchtanks",
@@ -125,6 +128,7 @@
"Synx", "Synx",
"tankaverages", "tankaverages",
"Tankerch", "Tankerch",
"tankmen",
"tankopedia", "tankopedia",
"tankopedias", "tankopedias",
"tankstats", "tankstats",
@@ -143,8 +147,10 @@
"Vovko", "Vovko",
"wargaming", "wargaming",
"webp", "webp",
"whitespaces",
"winrate", "winrate",
"wireframe", "wireframe",
"Woah",
"WOTB", "WOTB",
"wotblitz", "wotblitz",
"xffn", "xffn",
+19
View File
@@ -0,0 +1,19 @@
# Blitzkrieg 1.18.0
Introducing the new `/rating` command!
## Features
- `/rating` command
- Features statistics on today's performance in ratings
- The included stats are battles, winrate, damage, survival, and kills
- This command also features the score, rank, and league you are at plus the reward you'll get if you are high enough on the leaderboard
- `/ratings`, the old leaderboard command, has been renamed `/rating-leaderboard`
- Reward icons have been removed from the leaderboard
## Technical Changes
- Rating leaderboards are now built in a more action-efficient manner.
- This version also introduces the BKRL (Blitzkrieg rating leaderboard) file format which is an efficient way to store entire leaderboards of regions to disk (1 MB per 40,000 users).
- Latest archival actions now build all regions at once in a single action.
- The sharp module is now included in the build.
+11
View File
@@ -0,0 +1,11 @@
# Blitzkrieg 1.18.1
HE gets more accurate and say goodbye to NaNaNaNaNaN.
## Fixes
- Armor highlighting now works on Apple MacBooks
- Applied equipments now persist after switching shells
- The bottom of the tank sandbox panel can now be interacted with as it was previously blocked by an invisible layer
- `/rating` now uses the midnight leaderboard instead of the absolute latest one
- 0-battle stats are now hidden
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.18.2
Redefining how micro a micro-path is.
## Fixes
- The pitch and yaw controls, along with the quick equipment buttons, can once again be interacted with
+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
+45
View File
@@ -0,0 +1,45 @@
# Blitzkrieg Rating Leaderboard
A straightforward way of storing the entire World of Tanks Blitz rating leaderboard to disk.
```cpp
primary BlitzkriegRatingLeaderboard {
utf8"BKRL" magic;
Header header;
primary Body<header> body;
}
Header {
uint16 version; // always 1
Format format;
uint32 count;
}
Format enum<uint8> {
Base,
Superset1,
}
Body<Header header> match<header.format> {
Base {
primary {
uint32 id;
uint16 score;
}[header.count] entries;
};
Superset1 {
primary {
uint32 id;
uint16 score;
uint32 battles;
uint32 wins;
uint32 survived;
uint32 damage;
uint32 kills;
}[header.count] entries;
};
}
```
+1 -3
View File
@@ -1,7 +1,5 @@
# Cedilla Object Notation # Cedilla Object Notation
This documentation uses an experimental binary notation syntax called "Buffer Up".
1. JSON compressed as CDON, written directly to disk (a direct 50% reduction). 1. JSON compressed as CDON, written directly to disk (a direct 50% reduction).
2. CDON further compressed with LZ4 (a clean 90% reduction). 2. CDON further compressed with LZ4 (a clean 90% reduction).
3. The raw JSON file as fetched from a random data source. 3. The raw JSON file as fetched from a random data source.
@@ -19,7 +17,7 @@ primary CedillaObjectNotation {
} }
Header { Header {
uint16 version; uint16 version; // always 1
FSFormat fsFormat; FSFormat fsFormat;
StringTable<fsFormat> stringTable; StringTable<fsFormat> stringTable;
} }
+1 -1
View File
@@ -18,5 +18,5 @@ Blitzkrieg is available as a Discord bot and can be [installed very easily](/gui
Provides a bunch of very helpful tools; https://blitz-krieg.vercel.app/ Provides a bunch of very helpful tools; https://blitz-krieg.vercel.app/
- Session tracker - Session tracker
- Ratings leaderboard - Rating leaderboard
- Way more to come - Way more to come
+10 -10
View File
@@ -1,24 +1,25 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.17.1", "version": "1.19.0",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:bot": "vite-node --script scripts/build", "build:db": "prisma generate",
"build:website": "next build", "build:bot": "vite-node --script scripts/build --production",
"build:website": "yarn build:db && yarn build:sitemap && next build",
"build:sitemap": "vite-node --script scripts/sitemap",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard", "build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index", "build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
"build:test": "vite-node --script scripts/buildTest", "build:test": "vite-node --script scripts/buildTest",
"build:ratingsplot": "vite-node --script scripts/buildRatingsPlot", "dev:bot": "vite-node --script scripts/build && yarn start:bot",
"dev:bot": "yarn build:bot --dev && yarn start:bot",
"dev:docs": "vitepress dev docs", "dev:docs": "vitepress dev docs",
"dev:website": "next dev", "dev:website": "next dev",
"start:bot": "node --enable-source-maps dist/bot/index.cjs", "start:bot": "node --enable-source-maps dist/bot/index.cjs",
"start:website": "next start", "start:website": "next start",
"host:docs": "vitepress preview docs", "host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish", "publish:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard --publish",
"setup:patches": "vite-node --script scripts/applyPatches", "setup:patches": "vite-node --script scripts/applyPatches",
"lint": "npx tsc", "lint": "npx tsc",
"lint:website": "next lint", "lint:website": "next lint",
@@ -37,7 +38,7 @@
"@prisma/client": "^5.4.1", "@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0", "@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^2.0.1", "@radix-ui/themes": "^3.0.1",
"@react-three/drei": "^9.93.0", "@react-three/drei": "^9.93.0",
"@react-three/fiber": "^8.15.14", "@react-three/fiber": "^8.15.14",
"@resvg/resvg-js": "^2.4.1", "@resvg/resvg-js": "^2.4.1",
@@ -64,8 +65,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fuzzysort": "^1.0.1", "react-fuzzysort": "^1.0.1",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-spinners": "^0.13.8", "satori": "^0.10.13",
"satori": "^0.10.8",
"sharp": "0.33.1", "sharp": "0.33.1",
"three": "0.159.0", "three": "0.159.0",
"three-stdlib": "^2.29.4", "three-stdlib": "^2.29.4",
@@ -1,8 +0,0 @@
-- CreateTable
CREATE TABLE "discord_blitz" (
"discord" INT8 NOT NULL,
"region" STRING NOT NULL,
"blitz" INT4 NOT NULL,
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
);
-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

File renamed without changes.
File renamed without changes.
+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
+17 -4
View File
@@ -1,10 +1,13 @@
import { exec } from 'child_process';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { cp, readdir } from 'fs/promises'; import { cp, readdir } from 'fs/promises';
import { argv } from 'process'; import { argv } from 'process';
import { promisify } from 'util';
const isDev = argv.includes('--dev'); const execPromise = promisify(exec);
const isProduction = argv.includes('--production');
readdir('src/workers').then((workers) => { await readdir('src/workers').then((workers) => {
const files = [ const files = [
'bot', 'bot',
'index', 'index',
@@ -40,9 +43,19 @@ readdir('src/workers').then((workers) => {
}); });
// copy schema.prisma // copy schema.prisma
cp('prisma/schema.prisma', 'dist/bot/schema.prisma'); await cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
readdir('node_modules/prisma').then((files) => await readdir('node_modules/prisma').then((files) =>
files files
.filter((file) => file.endsWith('.node')) .filter((file) => file.endsWith('.node'))
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)), .map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
); );
if (isProduction) {
// install fixed sharp
console.log('Installing fixed sharp...');
await execPromise('yarn init -y && yarn add [email protected] --ignore-engines', {
cwd: 'dist/bot',
});
}
console.log('Generated:', await readdir('dist/bot'));
+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`,
}); });
+199
View File
@@ -0,0 +1,199 @@
import { chunk } from 'lodash';
import { argv } from 'process';
import {
RatingInfo,
RatingNeighbors,
RatingPlayer,
} from '../src/commands/ratingLeaderboard';
import { RegionSubdomain } from '../src/constants/regions';
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
import regionSubdomainToRegion from '../src/core/blitz/regionSubdomainToRegion';
import { commitAssets } from '../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../src/core/blitzkrieg/commitMultipleFiles.js';
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
import { superCompress } from '../src/core/blitzkrieg/superCompress';
import {
BkrlBaseEntry,
BkrlFormat,
BkrlSuperset1Entry,
BkrlWriteStream,
} from '../src/core/streams/bkrl';
import { CdonValue, CdonWriteStream } from '../src/core/streams/cdon';
/*
* Central North American Time (UTC-5): use 0 5 * * *.
* Central European Time (UTC+1): use 0 23 * * *.
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
*/
const production = argv.includes('--production');
const target = argv
.find((arg) => arg.startsWith('--target='))
?.split('=')[1] as 'midnight' | 'latest' | undefined;
const regionSubdomains = argv
.find((arg) => arg.startsWith('--region='))
?.split('=')[1];
if (!target) throw new Error('Target parameter not specified');
if (!regionSubdomains) throw new Error('Region parameter not specified');
regionSubdomains.split(',').forEach(async (regionSubdomain) => {
const region = regionSubdomainToRegion(regionSubdomain as RegionSubdomain);
console.log(`Building ${target} rating leaderboard for ${region}`);
const NEIGHBORS = 2 ** 8;
const PLAYERS = await patientFetchJSON<RatingInfo>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current rating season running');
}
const players: Record<number, RatingPlayer> = {};
const leaderboard: BkrlBaseEntry[] = [];
let registered = 0;
async function branchFromPlayer(id: number) {
const { neighbors } = await patientFetchJSON<RatingNeighbors>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
);
const firstPlayer = neighbors[0];
const lastPlayer = neighbors[neighbors.length - 1];
const branches: Promise<void>[] = [];
if (!players[firstPlayer.number])
branches.push(branchFromPlayer(firstPlayer.spa_id));
if (!players[lastPlayer.number])
branches.push(branchFromPlayer(lastPlayer.spa_id));
neighbors.forEach((neighbor) => {
if (players[neighbor.number]) return;
players[neighbor.number] = neighbor;
registered++;
});
console.log(
`Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS!) *
100
).toFixed(2)}%)`,
);
await Promise.all(branches);
}
async function branchFromLeague(id: number) {
const { result } = await patientFetchJSON<{ result: RatingPlayer[] }>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
);
const firstPlayer = result[0];
const lastPlayer = result.at(-1)!;
await Promise.all([
branchFromPlayer(firstPlayer.spa_id),
branchFromPlayer(lastPlayer.spa_id),
]);
}
console.log('Branching from leagues...');
await Promise.all([
branchFromLeague(0),
branchFromLeague(1),
branchFromLeague(2),
branchFromLeague(3),
branchFromLeague(4),
]);
console.log('Converting to an array...');
for (let index = 0; index < PLAYERS; index++) {
const listing = players[index + 1];
if (listing) {
leaderboard[index] = { id: listing.spa_id, score: listing.score };
} else {
console.warn(`Position ${index + 1} had no player`);
leaderboard[index] = { id: 0, score: 0 };
}
}
console.log('Requesting further stats...');
const comprehensiveLeaderboard: BkrlSuperset1Entry[] = [];
let chunkIndex = 0;
for (const leaderboardChunk of chunk(leaderboard, 100)) {
console.log(
`Chunk ${chunkIndex + 1} of ${Math.ceil(leaderboard.length / 100)}`,
);
const stats = await getAccountInfo(
region,
leaderboardChunk.map(({ id }) => id),
['statistics.rating'],
);
stats.forEach((stat, index) => {
const trueIndex = chunkIndex * 100 + index;
comprehensiveLeaderboard[trueIndex] =
leaderboardChunk[index].id === 0 ||
stat?.statistics.rating === undefined
? {
battles: 0,
damage: 0,
id: 0,
kills: 0,
score: 0,
survived: 0,
wins: 0,
}
: {
id: leaderboardChunk[index].id,
score: leaderboardChunk[index].score,
battles: stat.statistics.rating.battles,
damage: stat.statistics.rating.damage_dealt,
kills: stat.statistics.rating.frags,
survived: stat.statistics.rating.survived_battles,
wins: stat.statistics.rating.wins,
};
});
chunkIndex++;
}
const info = await patientFetchJSON<RatingInfo & { detail: undefined }>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
const normalizedServer = regionSubdomain === 'na' ? 'com' : regionSubdomain;
const infoContent = new CdonWriteStream().cdon(
info as unknown as CdonValue,
).uint8Array;
const leaderboardWriteStream = new BkrlWriteStream();
leaderboardWriteStream.bkrl({
format: BkrlFormat.Superset1,
entries: comprehensiveLeaderboard,
});
const changes: FileChange[] = [
{
path: `regions/${normalizedServer}/rating/${info.current_season}/info.cdon.lz4`,
content: superCompress(infoContent),
encoding: 'base64',
},
{
path: `regions/${normalizedServer}/rating/${info.current_season}/${target}.bkrl`,
content: Buffer.from(leaderboardWriteStream.uint8Array).toString(
'base64',
),
encoding: 'base64',
},
];
commitAssets(
`rating leaderboard ${regionSubdomain} ${target}`,
changes,
production,
);
});
-140
View File
@@ -1,140 +0,0 @@
import { rm, writeFile } from 'fs/promises';
import { argv } from 'process';
import {
RatingsInfo,
RatingsNeighbors,
RatingsPlayer,
} from '../src/commands/ratings';
import commitMultipleFiles from '../src/core/blitzkrieg/commitMultipleFiles.js';
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
/*
* Central North American Time (UTC-5): use 0 5 * * *.
* Central European Time (UTC+1): use 0 23 * * *.
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
*
* [region]/ratings/[season]/info.json: season info like rewards, start & end time, etc.
* [region]/ratings/[season]/latest.json: the absolute latest leaderboard recorded at UTC+0 midnight
* [region]/ratings/[season]/midnight.json: last midnight's leaderboard relative to the region
*/
export type BlitzkriegRatingsLeaderboardEntry = { id: number; score: number };
export type BlitzkriegRatingsLeaderboard = BlitzkriegRatingsLeaderboardEntry[];
const publish = argv.includes('--publish');
const target = argv
.find((arg) => arg.startsWith('--target='))
?.split('=')[1] as 'midnight' | 'latest' | undefined;
const server = argv.find((arg) => arg.startsWith('--region='))?.split('=')[1];
if (!target) throw new Error('Target parameter not specified');
if (!server) throw new Error('Region parameter not specified');
const NEIGHBORS = 2 ** 8;
const PLAYERS = await patientFetchJSON<RatingsInfo>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current ratings season running');
}
const players: Record<number, RatingsPlayer> = {};
const leaderboard: BlitzkriegRatingsLeaderboard = [];
let registered = 0;
async function branchFromPlayer(id: number) {
const { neighbors } = await patientFetchJSON<RatingsNeighbors>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
);
const firstPlayer = neighbors[0];
const lastPlayer = neighbors[neighbors.length - 1];
const branches: Promise<void>[] = [];
if (!players[firstPlayer.number])
branches.push(branchFromPlayer(firstPlayer.spa_id));
if (!players[lastPlayer.number])
branches.push(branchFromPlayer(lastPlayer.spa_id));
neighbors.forEach((neighbor) => {
if (players[neighbor.number]) return;
players[neighbor.number] = neighbor;
registered++;
});
console.log(
`Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS!) *
100
).toFixed(2)}%)`,
);
await Promise.all(branches);
}
async function branchFromLeague(id: number) {
const { result } = await patientFetchJSON<{ result: RatingsPlayer[] }>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
);
const firstPlayer = result[0];
const lastPlayer = result[result.length - 1];
await Promise.all([
branchFromPlayer(firstPlayer.spa_id),
branchFromPlayer(lastPlayer.spa_id),
]);
}
console.log('Branching from leagues...');
await Promise.all([
branchFromLeague(0),
branchFromLeague(1),
branchFromLeague(2),
branchFromLeague(3),
branchFromLeague(4),
]);
console.log('Converting to an array...');
for (let index = 0; index < PLAYERS; index++) {
const listing = players[index + 1];
if (!listing) {
console.warn(`Position ${index + 1} had no player`);
continue;
}
leaderboard[index] = {
id: listing.spa_id,
score: listing.score,
};
}
const leaderboardJSON = JSON.stringify(leaderboard);
if (publish) {
const info = await patientFetchJSON<RatingsInfo & { detail: undefined }>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
const normalizedServer = server === 'na' ? 'com' : server;
const infoPath = `regions/${normalizedServer}/ratings/${info.current_season}/info.json`;
const leaderboardPath = `regions/${normalizedServer}/ratings/${info.current_season}/${target}.json`;
const infoJSON = JSON.stringify(info);
console.log(`Publishing to season ${info.current_season}...`);
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
new Date().toString(),
[
{ path: infoPath, content: infoJSON, encoding: 'utf-8' },
{ path: leaderboardPath, content: leaderboardJSON, encoding: 'utf-8' },
],
);
} else {
console.log('Writing to disk...');
await rm('dist.json', { force: true });
await writeFile('dist.json', leaderboardJSON);
}
-46
View File
@@ -1,46 +0,0 @@
import { writeFile } from 'fs/promises';
import { Region } from '../src/constants/regions';
import { getRatingsNeighbors } from '../src/core/blitz/getRatingsNeighbors';
const REGION: Region = 'com';
const PLAYER = 1041988373;
const ITERATIONS = 2 ** 4;
const MAX_NEIGHBORS = 2 ** 7;
const data: [number, number][] = [];
let finished = 0;
for (let index = 0; index < ITERATIONS; index++) {
const neighbors = Math.round((Math.random() * MAX_NEIGHBORS) / 2) * 2;
const start = Date.now();
console.log(`🟡 Iteration ${index} with ${neighbors} neighbors`);
try {
await getRatingsNeighbors(REGION, PLAYER, neighbors);
finished++;
console.log(
`🟢 (${Math.round(
100 * (finished / ITERATIONS),
)}%) Iteration ${index} with ${neighbors} neighbors`,
);
const end = Date.now();
const time = end - start;
data.push([neighbors, time]);
} catch (error) {
console.log(
`🔴 Iteration ${index} with ${neighbors} neighbors\n\n${error}`,
);
}
}
console.log('🟢 Done, sorting');
data.sort(([neighborsA], [neighborsB]) => neighborsA - neighborsB);
console.log('🟢 Writing');
writeFile(
'dist/ratingsPlot.csv',
data.map(([neighbors, time]) => `${neighbors},${time}`).join('\n'),
);
+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`);
@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Region } from '../../../../../../constants/regions'; import { Region } from '../../../../../../constants/regions';
import getRatingsInfo from '../../../../../../core/blitz/getRatingsInfo'; import getRatingInfo from '../../../../../../core/blitz/getRatingInfo';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: { region: Region } }, { params }: { params: { region: Region } },
) { ) {
return NextResponse.json(await getRatingsInfo(params.region)); return NextResponse.json(await getRatingInfo(params.region));
} }
@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Region } from '../../../../../../../constants/regions'; import { Region } from '../../../../../../../constants/regions';
import { getRatingsLeague } from '../../../../../../../core/blitz/getRatingsLeague'; import { getRatingLeague } from '../../../../../../../core/blitz/getRatingLeague';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: { region: Region; league: number } }, { params }: { params: { region: Region; league: number } },
) { ) {
return NextResponse.json( return NextResponse.json(
await getRatingsLeague(params.region, params.league, true), await getRatingLeague(params.region, params.league, true),
); );
} }
@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Region } from '../../../../../../../constants/regions'; import { Region } from '../../../../../../../constants/regions';
import { searchCurrentRatingsPlayers } from '../../../../../../../core/blitz/searchCurrentRatingsPlayers'; import { searchCurrentRatingPlayers } from '../../../../../../../core/blitz/searchCurrentRatingPlayers';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: { region: Region; username: string } }, { params }: { params: { region: Region; username: string } },
) { ) {
return NextResponse.json( return NextResponse.json(
await searchCurrentRatingsPlayers(params.region, params.username), await searchCurrentRatingPlayers(params.region, params.username),
); );
} }
+42 -92
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
@@ -15,11 +15,11 @@ import { produce } from 'immer';
import { debounce, range } from 'lodash'; import { debounce, range } from 'lodash';
import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { create } from 'zustand'; import { create } from 'zustand';
import { RatingsInfo, RatingsPlayer } from '../../../commands/ratings'; import { RatingInfo, RatingPlayer } from '../../../commands/ratingLeaderboard';
import PageWrapper from '../../../components/PageWrapper'; import PageWrapper from '../../../components/PageWrapper';
import { Skeleton } from '../../../components/Skeleton'; import { Skeleton } from '../../../components/Skeleton';
import { LEAGUES } from '../../../constants/leagues'; import { LEAGUES } from '../../../constants/leagues';
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings'; import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../../../constants/rating';
import { import {
REGIONS, REGIONS,
Region, Region,
@@ -28,17 +28,17 @@ import {
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import fetchBlitz from '../../../core/blitz/fetchBlitz'; import fetchBlitz from '../../../core/blitz/fetchBlitz';
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../../../core/blitz/getRatingsInfo'; import getRatingInfo from '../../../core/blitz/getRatingInfo';
import { getRatingsLeague } from '../../../core/blitz/getRatingsLeague'; import { getRatingLeague } from '../../../core/blitz/getRatingLeague';
import { getRatingsNeighbors } from '../../../core/blitz/getRatingsNeighbors'; import { getRatingNeighbors } from '../../../core/blitz/getRatingNeighbors';
import { searchCurrentRatingsPlayers } from '../../../core/blitz/searchCurrentRatingsPlayers'; import { searchCurrentRatingPlayers } from '../../../core/blitz/searchCurrentRatingPlayers';
import { import {
AccountList, AccountList,
AccountListItem, AccountListItem,
} from '../../../core/blitz/searchPlayersAcrossRegions'; } from '../../../core/blitz/searchPlayersAcrossRegions';
import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber'; import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../../../core/blitzkrieg/getArchivedRatingsInfo'; import getArchivedRatingInfo from '../../../core/blitzkrieg/getArchivedRatingInfo';
import { getArchivedRatingsLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingsLeaderboard'; import { getArchivedRatingLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingLeaderboard';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
import { PageTurner } from './components/PageTurner'; import { PageTurner } from './components/PageTurner';
@@ -51,7 +51,7 @@ interface NameCacheEntry {
} }
type NameCache = Record<Region, Record<number, NameCacheEntry | null>>; type NameCache = Record<Region, Record<number, NameCacheEntry | null>>;
type LeaderboardCache = Record<Region, Record<number, Record<number, number>>>; type LeaderboardCache = Record<Region, Record<number, Record<number, number>>>;
type InfoCache = Record<Region, Record<number, RatingsInfo | undefined>>; type InfoCache = Record<Region, Record<number, RatingInfo | undefined>>;
type ScoreCache = Record<Region, Record<number, Record<number, number>>>; type ScoreCache = Record<Region, Record<number, Record<number, number>>>;
const useNameCache = create<NameCache>(() => ({ const useNameCache = create<NameCache>(() => ({
@@ -93,7 +93,7 @@ export default function Page() {
{ type: 'id'; id: number } | { type: 'position'; position: number } | null { type: 'id'; id: number } | { type: 'position'; position: number } | null
>(null); >(null);
const ratingsInfo = useInfoCache()[region][season]; const ratingInfo = useInfoCache()[region][season];
const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState< const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState<
number | null number | null
>(null); >(null);
@@ -111,7 +111,7 @@ export default function Page() {
}); });
const pages = Math.ceil( const pages = Math.ceil(
(ratingsInfo && ratingsInfo.detail === undefined ? ratingsInfo?.count : 1) / (ratingInfo && ratingInfo.detail === undefined ? ratingInfo?.count : 1) /
ROWS_PER_PAGE, ROWS_PER_PAGE,
); );
@@ -122,8 +122,8 @@ export default function Page() {
(async () => { (async () => {
const newInfo = const newInfo =
season === 0 season === 0
? await getRatingsInfo(region) ? await getRatingInfo(region)
: await getArchivedRatingsInfo(region, season); : await getArchivedRatingInfo(region, season);
useInfoCache.setState( useInfoCache.setState(
produce((draft: InfoCache) => { produce((draft: InfoCache) => {
draft[region][season] = newInfo; draft[region][season] = newInfo;
@@ -140,7 +140,7 @@ export default function Page() {
const results = ( const results = (
await Promise.all( await Promise.all(
range(0, 5).map(async (league: number) => { range(0, 5).map(async (league: number) => {
const data = await getRatingsLeague(region, league); const data = await getRatingLeague(region, league);
leaguePositionCache.current[region][league] = data[0].number - 1; leaguePositionCache.current[region][league] = data[0].number - 1;
return data; return data;
@@ -178,12 +178,12 @@ export default function Page() {
); );
})(); })();
} else { } else {
getArchivedRatingsLeaderboard(region, season).then((results) => { getArchivedRatingLeaderboard(region, season).then((results) => {
useLeaderboardCache.setState( useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => { produce((draft: LeaderboardCache) => {
if (!(season in draft[region])) draft[region][season] = {}; if (!(season in draft[region])) draft[region][season] = {};
results.forEach((result, index) => { results.entries.forEach((result, index) => {
draft[region][season][index] = result.id; draft[region][season][index] = result.id;
}); });
}), }),
@@ -192,7 +192,7 @@ export default function Page() {
produce((draft: ScoreCache) => { produce((draft: ScoreCache) => {
if (!(season in draft[region])) draft[region][season] = {}; if (!(season in draft[region])) draft[region][season] = {};
results.forEach((result) => { results.entries.forEach((result) => {
draft[region][season][result.id] = result.score; draft[region][season][result.id] = result.score;
}); });
}), }),
@@ -213,7 +213,7 @@ export default function Page() {
}, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]); }, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]);
// neighboring page caching for all seasons // neighboring page caching for all seasons
useEffect(() => { useEffect(() => {
if (!ratingsInfo || ratingsInfo?.detail) return; if (!ratingInfo || ratingInfo?.detail) return;
if (season === 0) { if (season === 0) {
const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2; const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2;
@@ -236,7 +236,7 @@ export default function Page() {
const ids = range( const ids = range(
Math.max(0, (page - 1) * ROWS_PER_PAGE), Math.max(0, (page - 1) * ROWS_PER_PAGE),
Math.min( Math.min(
ratingsInfo.count - 1, ratingInfo.count - 1,
(page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE, (page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE,
), ),
) )
@@ -265,7 +265,7 @@ export default function Page() {
page, page,
region, region,
season, season,
ratingsInfo?.detail, ratingInfo?.detail,
leaderboard[region][season] !== undefined && leaderboard[region][season] !== undefined &&
0 in leaderboard[region][season], 0 in leaderboard[region][season],
]); ]);
@@ -279,7 +279,7 @@ export default function Page() {
async function cacheNeighbors( async function cacheNeighbors(
id: number, id: number,
size: number, size: number,
): Promise<RatingsPlayer[] | null> { ): Promise<RatingPlayer[] | null> {
const radius = Math.round(size / 2); const radius = Math.round(size / 2);
const targetPosition = Object.entries(leaderboard[region][season]).find( const targetPosition = Object.entries(leaderboard[region][season]).find(
([, player]) => id === player, ([, player]) => id === player,
@@ -303,7 +303,7 @@ export default function Page() {
} }
} }
const { neighbors } = await getRatingsNeighbors(region, id, size, true); const { neighbors } = await getRatingNeighbors(region, id, size, true);
useLeaderboardCache.setState( useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => { produce((draft: LeaderboardCache) => {
@@ -341,11 +341,11 @@ export default function Page() {
return neighbors; return neighbors;
} }
async function jumpToPosition(position: number) { async function jumpToPosition(position: number) {
if (!ratingsInfo || ratingsInfo?.detail) return; if (!ratingInfo || ratingInfo?.detail) return;
const targetPosition = Math.min( const targetPosition = Math.min(
ratingsInfo.count - 1, ratingInfo.count - 1,
Math.max(0, Math.min(position, ratingsInfo.count - 1)), Math.max(0, Math.min(position, ratingInfo.count - 1)),
); );
if (season === 0) { if (season === 0) {
@@ -378,7 +378,7 @@ export default function Page() {
const loadingDirection = Math.sign(targetPosition - closestPosition); const loadingDirection = Math.sign(targetPosition - closestPosition);
let seedingPlayer = closestPlayerId as number; let seedingPlayer = closestPlayerId as number;
let targetPositionAcquired = false; let targetPositionAcquired = false;
let neighbors: RatingsPlayer[] | null; let neighbors: RatingPlayer[] | null;
setLoadingProgress([0, closestPositionDistance]); setLoadingProgress([0, closestPositionDistance]);
@@ -423,7 +423,7 @@ export default function Page() {
} }
} }
async function jumpToScore() { async function jumpToScore() {
if (!ratingsInfo || ratingsInfo?.detail) return; if (!ratingInfo || ratingInfo?.detail) return;
const targetScore = scoreInput.current!.valueAsNumber; const targetScore = scoreInput.current!.valueAsNumber;
@@ -522,7 +522,7 @@ export default function Page() {
} else { } else {
let playerIndex = -1; let playerIndex = -1;
for (let index = ratingsInfo.count; index >= 0; index--) { for (let index = ratingInfo.count; index >= 0; index--) {
if (!(index in leaderboard[region][season])) continue; if (!(index in leaderboard[region][season])) continue;
if (scores[leaderboard[region][season][index]] >= targetScore) { if (scores[leaderboard[region][season][index]] >= targetScore) {
@@ -545,7 +545,7 @@ export default function Page() {
} }
const totalPlayers = const totalPlayers =
!ratingsInfo?.detail && ratingsInfo?.count ? ratingsInfo.count : undefined; !ratingInfo?.detail && ratingInfo?.count ? ratingInfo.count : undefined;
return ( return (
<PageWrapper color="orange"> <PageWrapper color="orange">
@@ -573,7 +573,7 @@ export default function Page() {
<Select.Trigger style={{ flex: 1 }} /> <Select.Trigger style={{ flex: 1 }} />
<Select.Content> <Select.Content>
{ratingsInfo?.detail === undefined && ( {ratingInfo?.detail === undefined && (
<Select.Item value="0">Current season</Select.Item> <Select.Item value="0">Current season</Select.Item>
)} )}
@@ -582,7 +582,7 @@ export default function Page() {
{latestArchivedSeasonNumber && {latestArchivedSeasonNumber &&
range( range(
FIRST_ARCHIVED_RATINGS_SEASON, FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
latestArchivedSeasonNumber + 1, latestArchivedSeasonNumber + 1,
) )
.map((season) => ( .map((season) => (
@@ -605,7 +605,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={scoreInput} ref={scoreInput}
type="number" type="number"
placeholder="Type a score..." placeholder="Type a score..."
@@ -638,7 +638,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={positionInput} ref={positionInput}
type="number" type="number"
placeholder="Type a position..." placeholder="Type a position..."
@@ -681,8 +681,8 @@ export default function Page() {
<Select.Trigger /> <Select.Trigger />
<Select.Content> <Select.Content>
{ratingsInfo?.detail === undefined && {ratingInfo?.detail === undefined &&
ratingsInfo?.leagues.map(({ index, title }) => ( ratingInfo?.leagues.map(({ index, title }) => (
<Select.Item key={index} value={`${index}`}> <Select.Item key={index} value={`${index}`}>
{title} {title}
</Select.Item> </Select.Item>
@@ -714,11 +714,7 @@ export default function Page() {
); );
} }
} else { } else {
if ( if (!ratingInfo || ratingInfo.detail || !leaderboard)
!ratingsInfo ||
ratingsInfo.detail ||
!leaderboard
)
return; return;
const minScore = const minScore =
@@ -727,7 +723,7 @@ export default function Page() {
for ( for (
let index = 0; let index = 0;
index < ratingsInfo.count; index < ratingInfo.count;
index++ index++
) { ) {
if ( if (
@@ -763,105 +759,100 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" direction="column"> <Flex gap="4" direction="column">
<TextField.Root> <TextField.Root
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList = await searchCurrentRatingPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter((player) => !Number.isNaN(player.number)),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList =
await searchCurrentRatingsPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter(
(player) => !Number.isNaN(player.number),
),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
/>
</TextField.Root> </TextField.Root>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
@@ -885,12 +876,12 @@ export default function Page() {
id: player.account_id, id: player.account_id,
}); });
} else { } else {
if (!ratingsInfo || ratingsInfo.detail) return; if (!ratingInfo || ratingInfo.detail) return;
let playerIndex = -1; let playerIndex = -1;
for ( for (
let index = 0; let index = 0;
index < ratingsInfo.count; index < ratingInfo.count;
index++ index++
) { ) {
if ( if (
@@ -951,9 +942,7 @@ export default function Page() {
</Flex> </Flex>
<PageTurner <PageTurner
totalPlayers={ totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
}
leaderboard={leaderboard[region][season] ?? {}} leaderboard={leaderboard[region][season] ?? {}}
rowsPerPage={ROWS_PER_PAGE} rowsPerPage={ROWS_PER_PAGE}
page={page} page={page}
@@ -982,7 +971,7 @@ export default function Page() {
page * ROWS_PER_PAGE, page * ROWS_PER_PAGE,
Math.min( Math.min(
page * ROWS_PER_PAGE + ROWS_PER_PAGE, page * ROWS_PER_PAGE + ROWS_PER_PAGE,
ratingsInfo?.detail ? 0 : (ratingsInfo?.count ?? 1) - 1, ratingInfo?.detail ? 0 : (ratingInfo?.count ?? 1) - 1,
), ),
).map((index) => { ).map((index) => {
const id = leaderboard[region][season]?.[index]; const id = leaderboard[region][season]?.[index];
@@ -994,8 +983,8 @@ export default function Page() {
: highlightedPlayer.position === index : highlightedPlayer.position === index
: false; : false;
const reward = const reward =
!ratingsInfo?.detail && ratingsInfo?.rewards !ratingInfo?.detail && ratingInfo?.rewards
? ratingsInfo.rewards.find( ? ratingInfo.rewards.find(
(reward) => (reward) =>
reward.from_position <= position && reward.from_position <= position &&
reward.to_position >= position, reward.to_position >= position,
@@ -1090,9 +1079,7 @@ export default function Page() {
</Table.Root> </Table.Root>
<PageTurner <PageTurner
totalPlayers={ totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
}
rowsPerPage={ROWS_PER_PAGE} rowsPerPage={ROWS_PER_PAGE}
leaderboard={leaderboard[region][season] ?? {}} leaderboard={leaderboard[region][season] ?? {}}
page={page} page={page}
+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,37 +262,42 @@ export function Characteristics() {
setPenetrationDistance(value); setPenetrationDistance(value);
}, 500)} }, 500)}
/> />
<TextField.Root style={{ width: 64 }}> <TextField.Root
<TextField.Input style={{ width: 64 }}
ref={penetrationDistanceInput} ref={penetrationDistanceInput}
defaultValue={penetrationDistance} defaultValue={penetrationDistance}
onBlur={() => { onBlur={() => {
setPenetrationDistance( setPenetrationDistance(
Math.min( Math.min(
parseInt(penetrationDistanceInput.current!.value), parseInt(penetrationDistanceInput.current!.value),
500, 500,
), ),
); );
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
/> >
<TextField.Slot>m</TextField.Slot> <TextField.Slot side="right">m</TextField.Slot>
</TextField.Root> </TextField.Root>
</Flex> </Flex>
</> </>
)} )}
<InfoWithDelta name="Damage" unit="hp" decimals={0}> <InfoWithDelta name="Damage" unit="hp" decimals={0}>
{shell.damage.armor * damageCoefficient} {stats.damage}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="Module damage" unit="hp" decimals={0}> <InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{shell.damage.module * damageCoefficient} {stats.moduleDamage}
</InfoWithDelta> </InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{stats.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}> <InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{shell.speed * shellVelocityCoefficient} {stats.shellVelocity}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={2} decimals={2}
@@ -428,7 +305,7 @@ export function Characteristics() {
unit="s" unit="s"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.aimTime * aimTimeCoefficient} {stats.aimTime}
</InfoWithDelta> </InfoWithDelta>
<Info name="Dispersion at 100m" /> <Info name="Dispersion at 100m" />
<InfoWithDelta <InfoWithDelta
@@ -438,7 +315,7 @@ export function Characteristics() {
unit="m" unit="m"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.base * dispersionStillCoefficient} {stats.dispersion}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
prefix="+ " prefix="+ "
@@ -447,7 +324,7 @@ export function Characteristics() {
name="Moving" name="Moving"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{track.dispersion.move * dispersionMovingCoefficient} {stats.dispersionMoving}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -456,7 +333,7 @@ export function Characteristics() {
name="Hull traversing" name="Hull traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{track.dispersion.traverse * dispersionMovingCoefficient} {stats.dispersionHullTraversing}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -465,7 +342,7 @@ export function Characteristics() {
name="Turret traversing" name="Turret traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.traverse * dispersionMovingCoefficient} {stats.dispersionTurretTraversing}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -474,7 +351,7 @@ export function Characteristics() {
name="After shooting" name="After shooting"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.shot * dispersionMovingCoefficient} {stats.dispersionShooting}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={1} decimals={1}
@@ -484,46 +361,42 @@ export function Characteristics() {
unit="scalar" unit="scalar"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.damaged * dispersionMovingCoefficient} {stats.dispersionGunDamaged}
</InfoWithDelta> </InfoWithDelta>
<Info name="Gun flexibility" unit="°" /> <Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression"> <InfoWithDelta decimals={1} indent name="Depression">
{gunModelDefinition.pitch.max + {stats.gunDepression}
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation"> <InfoWithDelta decimals={1} indent name="Elevation">
{-gunModelDefinition.pitch.min - {stats.gunElevation}
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0)}
</InfoWithDelta> </InfoWithDelta>
{gunModelDefinition.pitch.front && ( {gunModelDefinition.pitch.front && (
<> <>
<InfoWithDelta decimals={1} indent name="Frontal depression"> <InfoWithDelta decimals={1} indent name="Frontal depression">
{gunModelDefinition.pitch.front.max} {stats.gunFrontalDepression!}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Frontal elevation"> <InfoWithDelta decimals={1} indent name="Frontal elevation">
{-gunModelDefinition.pitch.front.min} {stats.gunFrontalElevation!}
</InfoWithDelta> </InfoWithDelta>
</> </>
)} )}
{gunModelDefinition.pitch.back && ( {gunModelDefinition.pitch.back && (
<> <>
<InfoWithDelta decimals={1} indent name="Rear depression"> <InfoWithDelta decimals={1} indent name="Rear depression">
{gunModelDefinition.pitch.back.max} {stats.gunRearDepression!}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Rear elevation"> <InfoWithDelta decimals={1} indent name="Rear elevation">
{-gunModelDefinition.pitch.back.min} {stats.gunRearElevation!}
</InfoWithDelta> </InfoWithDelta>
</> </>
)} )}
{turretModelDefinition.yaw && ( {turretModelDefinition.yaw && (
<> <>
<InfoWithDelta decimals={1} indent name="Azimuth left"> <InfoWithDelta decimals={1} indent name="Azimuth left">
{-turretModelDefinition.yaw.min} {stats.azimuthLeft!}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Azimuth right"> <InfoWithDelta decimals={1} indent name="Azimuth right">
{turretModelDefinition.yaw.max} {stats.azimuthRight!}
</InfoWithDelta> </InfoWithDelta>
</> </>
)} )}
@@ -533,29 +406,13 @@ export function Characteristics() {
<Heading size="5">Maneuverability</Heading> <Heading size="5">Maneuverability</Heading>
<Info name="Speed" unit="km/hr" /> <Info name="Speed" unit="km/hr" />
<InfoWithDelta decimals={0} indent name="Forwards"> <InfoWithDelta decimals={0} indent name="Forwards">
{tank.speed.forwards + speedForwardsSum} {stats.speedForwards}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={0} indent name="Backwards"> <InfoWithDelta decimals={0} indent name="Backwards">
{tank.speed.backwards + speedBackwardsSum} {stats.speedBackwards}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={0} name="Power" unit="hp"> <InfoWithDelta decimals={0} name="Engine power" unit="hp">
{resolvedEnginePower} {stats.enginePower}
</InfoWithDelta>
<Info name="Power to weight ratio" unit="hp/tn" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{resolvedEnginePower /
weightTons /
(track.resistance.hard * resistanceCoefficient)}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{resolvedEnginePower /
weightTons /
(track.resistance.medium * resistanceCoefficient)}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{resolvedEnginePower /
weightTons /
(track.resistance.soft * resistanceCoefficient)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={1} decimals={1}
@@ -563,76 +420,98 @@ export function Characteristics() {
unit="tn" unit="tn"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{weightTons} {stats.weight}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={0}> <Info name="Power to weight ratio" unit="hp/tn" />
{turret.traverseSpeed * turretTraverseCoefficient}
</InfoWithDelta>
<Info name="Effective traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain"> <InfoWithDelta decimals={1} indent name="On hard terrain">
{(resolvedEnginePower / stockEngine.power) * {stats.powerToWeightRatioHardTerrain}
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard /
(track.resistance.hard * resistanceCoefficient)) *
(stockWeight / weight)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain"> <InfoWithDelta decimals={1} indent name="On medium terrain">
{(resolvedEnginePower / stockEngine.power) * {stats.powerToWeightRatioMediumTerrain}
track.traverseSpeed *
hullTraverseCoefficient *
(track.resistance.hard /
(track.resistance.medium * resistanceCoefficient)) *
(stockWeight / weight)}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain"> <InfoWithDelta decimals={1} indent name="On soft terrain">
{(resolvedEnginePower / stockEngine.power) * {stats.powerToWeightRatioSoftTerrain}
track.traverseSpeed * </InfoWithDelta>
hullTraverseCoefficient * <InfoWithDelta name="Turret traverse speed" unit="°/s" decimals={1}>
(track.resistance.hard / {stats.turretTraverseSpeed}
(track.resistance.soft * resistanceCoefficient)) * </InfoWithDelta>
(stockWeight / weight)} <Info name="Hull traverse speed" unit="°/s" />
<InfoWithDelta decimals={1} indent name="On hard terrain">
{stats.hullTraverseHardTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On medium terrain">
{stats.hullTraverseMediumTerrain}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="On soft terrain">
{stats.hullTraverseSoftTerrain}
</InfoWithDelta> </InfoWithDelta>
</Flex> </Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Heading size="5">Survivability</Heading> <Heading size="5">Survivability</Heading>
<InfoWithDelta name="Health" unit="hp" decimals={0}> <InfoWithDelta name="Health" unit="hp" decimals={0}>
{(tank.health + turret.health) * healthCoefficient} {stats.health}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="Fire chance" unit="%" deltaType="lowerIsBetter"> <InfoWithDelta
{Math.round(engine.fireChance * fireChanceCoefficient * 100)} name="Fire chance"
unit="%"
deltaType="lowerIsBetter"
decimals={0}
>
{stats.fireChance * 100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta name="View range" unit="m" decimals={0}> <InfoWithDelta name="View range" unit="m" decimals={0}>
{turret.viewRange * viewRangeCoefficient} {stats.viewRange}
</InfoWithDelta> </InfoWithDelta>
<Info name="Camouflage" unit="%" /> <Info name="Camouflage" unit="%" />
<InfoWithDelta indent name="Still" decimals={2}> <InfoWithDelta indent name="Still" decimals={0}>
{(tank.camouflage.still + camouflageSumStill) * 100} {stats.camouflageStill * 100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Moving" decimals={2}> <InfoWithDelta indent name="Moving" decimals={0}>
{(tank.camouflage.moving + camouflageSumMoving) * 100} {stats.camouflageMoving * 100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Shooting still" decimals={2}> <InfoWithDelta indent name="Shooting still" decimals={0}>
{(tank.camouflage.still * gun.camouflageLoss + camouflageSumStill) * {stats.camouflageShootingStill * 100}
100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Shooting on move" decimals={2}> <InfoWithDelta indent name="Shooting moving" decimals={0}>
{(tank.camouflage.moving * gun.camouflageLoss + camouflageSumMoving) * {stats.camouflageShootingMoving * 100}
100}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta indent name="Caught on fire" decimals={2}> <InfoWithDelta indent name="Caught on fire" decimals={0}>
{(tank.camouflage.onFire * tank.camouflage.still + {stats.camouflageCaughtOnFire * 100}
camouflageSumStill) *
100}
</InfoWithDelta> </InfoWithDelta>
<Info name="Size" unit="m"> <Info name="Width" unit="m" decimals={0} deltaType="lowerIsBetter">
{size[0].toFixed(2)} x {size[2].toFixed(2)} x {size[1].toFixed(2)} {stats.width}
</Info>
<Info name="Height" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.height}
</Info>
<Info name="Length" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.length}
</Info>
<Info name="Volume" unit="m" decimals={0} deltaType="lowerIsBetter">
{stats.volume}
</Info> </Info>
</Flex> </Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Heading size="5">Crew training</Heading> <Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>represents the other roles a crew member can take.</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => { {tank.crew.map((member) => {
const count = member.count ?? 1; const count = member.count ?? 1;
@@ -69,7 +69,12 @@ export function Controls() {
); );
async function handlePoseEvent(event: Pose) { async function handlePoseEvent(event: Pose) {
const hasImprovedVerticalStabilizer = await hasEquipment(122); const duel = useDuel.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
switch (event) { switch (event) {
case Pose.HullDown: { case Pose.HullDown: {
@@ -1,5 +1,5 @@
import { LightningBoltIcon } from '@radix-ui/react-icons'; import { LightningBoltIcon } from '@radix-ui/react-icons';
import { Button, Card, Flex, Tabs, Theme } from '@radix-ui/themes'; import { Button, Card, Flex, Tabs, Text, Theme } from '@radix-ui/themes';
import { PerspectiveCamera } from '@react-three/drei'; import { PerspectiveCamera } from '@react-three/drei';
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { Suspense, use, useEffect, useRef, useState } from 'react'; import { Suspense, use, useEffect, useRef, useState } from 'react';
@@ -12,7 +12,9 @@ import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTran
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose'; import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon'; import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat'; import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { BlitzkriegWormWide } from '../../../../../../icons/BlitzkriegWormWide';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import mutateTankopediaPersistent, { import mutateTankopediaPersistent, {
TankopediaMode, TankopediaMode,
@@ -33,7 +35,6 @@ export function TankSandbox() {
const hasImprovedVerticalStabilizer = useEquipment(122); const hasImprovedVerticalStabilizer = useEquipment(122);
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const canvasWrapper = useRef<HTMLDivElement>(null); const canvasWrapper = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const mode = useTankopediaPersistent((state) => state.mode); const mode = useTankopediaPersistent((state) => state.mode);
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id]; const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
@@ -43,6 +44,7 @@ export function TankSandbox() {
const wideFormat = useWideFormat(); const wideFormat = useWideFormat();
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile); const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
const duel = useDuel(); const duel = useDuel();
const isFullScreen = useFullScreen();
function handlePointerDown() { function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
@@ -59,10 +61,6 @@ export function TankSandbox() {
} }
useEffect(() => { useEffect(() => {
function handleFullScreenChange() {
setIsFullScreen(document.fullscreenElement !== null);
}
function handlePoseEvent(pose: Pose) { function handlePoseEvent(pose: Pose) {
switch (pose) { switch (pose) {
case Pose.HullDown: { case Pose.HullDown: {
@@ -121,10 +119,8 @@ export function TankSandbox() {
} }
poseEvent.on(handlePoseEvent); poseEvent.on(handlePoseEvent);
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => { return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
poseEvent.off(handlePoseEvent); poseEvent.off(handlePoseEvent);
}; };
}); });
@@ -145,117 +141,127 @@ export function TankSandbox() {
<Theme radius={isFullScreen ? 'none' : undefined}> <Theme radius={isFullScreen ? 'none' : undefined}>
<Card <Card
style={{ style={{
position: 'relative', position: isFullScreen ? 'fixed' : 'relative',
border: isFullScreen ? 'none' : 'unset', border: isFullScreen ? 'none' : 'unset',
width: isFullScreen ? '100vw' : 'unset',
height: isFullScreen ? 'calc(100vh)' : 'unset',
top: isFullScreen ? 0 : 'unset',
left: isFullScreen ? 0 : 'unset',
zIndex: isFullScreen ? 2 : 'unset',
}} }}
ref={canvasWrapper} ref={canvasWrapper}
> >
<Theme radius="full" style={{ height: '100%' }}> <Flex
<Flex style={{
style={{ height: isFullScreen ? '100%' : '75vh',
height: isFullScreen ? '100%' : '75vh', maxHeight: isFullScreen ? 'unset' : 576,
maxHeight: isFullScreen ? 'unset' : 576, }}
direction="column"
gap="2"
>
<Tabs.Root
value={mode}
onValueChange={(mode) => {
mutateTankopediaPersistent((draft) => {
draft.mode = mode as TankopediaMode;
});
}} }}
direction="column"
gap="2"
> >
<Tabs.Root <Tabs.List>
value={mode} <Tabs.Trigger value="model">
onValueChange={(mode) => { <Flex gap="2" align="center">
mutateTankopediaPersistent((draft) => { Model
draft.mode = mode as TankopediaMode;
});
}}
>
<Tabs.List>
<Tabs.Trigger value="model">
<Flex gap="2" align="center">
Model
</Flex>
</Tabs.Trigger>
<Tabs.Trigger value="armor">Armor</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div
style={{
height: 'calc(100% - 64px)',
position: 'absolute',
width: '100%',
bottom: 0,
}}
>
{loadModel ? (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
<SceneProps />
<Suspense fallback={<ModelLoader />}>
{/* TODO: add rotation cube to the top right */}
<Lighting />
<TankModel />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
</div>
) : (
<Flex
align="center"
justify="center"
style={{ height: '100%', position: 'relative' }}
>
<Button
style={{
zIndex: 1,
}}
onClick={() => {
setLoadModel(true);
}}
>
<LightningBoltIcon /> Load model
</Button>
<Flex
style={{
filter: 'blur(16px)',
width: 256,
height: 128,
display: 'flex',
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
transform: 'translate(25%, -25%) scale(300%)',
}}
>
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
</Flex>
</Flex> </Flex>
)} </Tabs.Trigger>
</div> <Tabs.Trigger value="armor">Armor</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<Options <Text
canvasWrapper={canvasWrapper} color="gray"
isFullScreen={isFullScreen} style={{
/> position: 'absolute',
left: '50%',
top: 11,
}}
>
<BlitzkriegWormWide height={32} />
</Text>
{isFullScreen && <AntagonistBar floating />} <div
style={{
height: 'calc(100% - 64px)',
position: 'absolute',
width: '100%',
bottom: 0,
}}
>
{loadModel ? (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
shadows
ref={canvas}
onPointerDown={handlePointerDown}
onPointerMissed={() => {
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
<PerspectiveCamera makeDefault fov={25} far={32} />
<Controls />
<SceneProps />
{loadModel && <RotationInputs isFullScreen={isFullScreen} />} <Suspense fallback={<ModelLoader />}>
</Flex> <Lighting />
</Theme> <TankModel />
<Armor />
<ShotDisplay />
</Suspense>
</Canvas>
</div>
) : (
<Flex
align="center"
justify="center"
style={{ height: '100%', position: 'relative' }}
>
<Button
style={{
zIndex: 1,
}}
onClick={() => {
setLoadModel(true);
}}
>
<LightningBoltIcon /> Load model
</Button>
<Flex
style={{
filter: 'blur(16px)',
width: 256,
height: 128,
display: 'flex',
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
transform: 'translate(25%, -25%) scale(300%)',
}}
>
<img src={tankIcon(duel.antagonist!.tank.id, 'big')} />
</Flex>
</Flex>
)}
</div>
<Options canvasWrapper={canvasWrapper} isFullScreen={isFullScreen} />
{isFullScreen && <AntagonistBar floating />}
{loadModel && <RotationInputs isFullScreen={isFullScreen} />}
</Flex>
</Card> </Card>
</Theme> </Theme>
); );
@@ -1,5 +1,4 @@
import { Html, useProgress } from '@react-three/drei'; import { Html, useProgress } from '@react-three/drei';
import { theme } from '../../../../../../../stitches.config';
import { Loader } from '../../../../../components/Loader'; import { Loader } from '../../../../../components/Loader';
export function ModelLoader() { export function ModelLoader() {
@@ -7,11 +6,7 @@ export function ModelLoader() {
return ( return (
<Html center position={[0, 1.5, 0]}> <Html center position={[0, 1.5, 0]}>
<Loader <Loader naked progress={progress / 100} />
naked
color={theme.colors.textHighContrast_purple}
progress={progress / 100}
/>
</Html> </Html>
); );
} }
@@ -18,7 +18,7 @@ interface OptionsProps {
canvasWrapper: RefObject<HTMLElement>; canvasWrapper: RefObject<HTMLElement>;
} }
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { export function Options({ isFullScreen }: OptionsProps) {
const showGrid = useTankopediaPersistent( const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid, (state) => state.model.visual.showGrid,
); );
@@ -50,136 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
right: 18, right: 18,
}} }}
> >
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <EyeOpenIcon />
<EyeOpenIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down Hull down
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug Face hug
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default Default
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <GearIcon />
<GearIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label> <DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showSpacedArmor} checked={showSpacedArmor}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showSpacedArmor = checked; draft.model.visual.showSpacedArmor = checked;
}); });
mutateTankopediaTemporary((draft) => { mutateTankopediaTemporary((draft) => {
draft.shot = undefined; draft.shot = undefined;
}); });
}} }}
> >
Spaced armor Spaced armor
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={greenPenetration} checked={greenPenetration}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked; draft.model.visual.greenPenetration = checked;
}); });
}} }}
> >
Green penetration Green penetration
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={wireframe} checked={wireframe}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked; draft.model.visual.wireframe = checked;
}); });
}} }}
> >
Wireframe Wireframe
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={opaque} checked={opaque}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked; draft.model.visual.opaque = checked;
}); });
}} }}
> >
Opaque Opaque
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Environment</DropdownMenu.Label> <DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showGrid} checked={showGrid}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked; draft.model.visual.showGrid = checked;
}); });
}} }}
> >
Show grid Show grid
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showEnvironment} checked={showEnvironment}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked; draft.model.visual.showEnvironment = checked;
}); });
}} }}
> >
View environment View environment
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger> <DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent> <DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}> <DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => ( {ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem <DropdownMenu.RadioItem
key={environment} key={environment}
value={environment} value={environment}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment; draft.model.visual.environment = environment;
}); });
}} }}
> >
{environment[0].toUpperCase() + environment.slice(1)} {environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem> </DropdownMenu.RadioItem>
))} ))}
</DropdownMenu.RadioGroup> </DropdownMenu.RadioGroup>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{fullScreenAvailable && ( {fullScreenAvailable && (
<Button <Button
@@ -187,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
onClick={() => { onClick={() => {
if (isFullScreen) { if (isFullScreen) {
document.exitFullscreen(); document.exitFullscreen();
} else canvasWrapper.current?.requestFullscreen(); } else document.body.requestFullscreen();
}} }}
> >
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />} {isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
@@ -162,7 +162,12 @@ export const TankModel = memo(() => {
} }
} }
async function handlePointerMove(event: PointerEvent) { async function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = await hasEquipment(122); const duel = useDuel.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
[pitch, yaw] = applyPitchYawLimits( [pitch, yaw] = applyPitchYawLimits(
pitch, pitch,
yaw + event.movementX * (Math.PI / canvas.width), yaw + event.movementX * (Math.PI / canvas.width),
@@ -223,7 +228,12 @@ export const TankModel = memo(() => {
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
} }
async function handlePointerMove(event: PointerEvent) { async function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = await hasEquipment(122); const duel = useDuel.getState();
const hasImprovedVerticalStabilizer = await hasEquipment(
122,
duel.protagonist!.tank.equipment,
duel.protagonist!.equipmentMatrix,
);
[pitch, yaw] = applyPitchYawLimits( [pitch, yaw] = applyPitchYawLimits(
pitch - event.movementY * (Math.PI / canvas.height), pitch - event.movementY * (Math.PI / canvas.height),
yaw + event.movementX * (Math.PI / canvas.width), yaw + event.movementX * (Math.PI / canvas.width),
@@ -46,19 +46,48 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
position: 'absolute', position: 'absolute',
left: 8, left: 8,
bottom: isFullScreen ? 80 : 8, bottom: isFullScreen ? 80 : 8,
pointerEvents: 'none',
}} }}
gap="4" gap="4"
> >
<Flex <Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
gap="2"
style={{
width: 256,
}}
>
<TextField.Root <TextField.Root
radius="full"
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(turretYawInput.current!.value);
if (isNaN(value)) {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -67,46 +96,46 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Yaw Yaw
</TextField.Slot> </TextField.Slot>
<TextField.Input <TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(Number(turretYawInput.current!.value)),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root> </TextField.Root>
<TextField.Root <TextField.Root
radius="full"
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(gunPitchInput.current!.value);
if (isNaN(value)) {
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -115,35 +144,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Pitch Pitch
</TextField.Slot> </TextField.Slot>
<TextField.Input
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(
-Number(gunPitchInput.current!.value) + initialGunPitch,
),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot <TextField.Slot
style={{ style={{
userSelect: 'none', userSelect: 'none',
@@ -155,13 +155,15 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
</Flex> </Flex>
{mode === 'armor' && ( {mode === 'armor' && (
<Flex gap="4"> <Flex gap="4" style={{ pointerEvents: 'auto' }}>
<QuickEquipmentButton <QuickEquipmentButton
equipment={103} equipment={103}
active={hasCalibratedShells} active={hasCalibratedShells}
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.antagonist!.equipment[0][0] = hasCalibratedShells ? 0 : 1; draft.antagonist!.equipmentMatrix[0][0] = hasCalibratedShells
? 0
: 1;
}); });
}} }}
/> />
@@ -170,7 +172,9 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
active={hasEnhancedArmor} active={hasEnhancedArmor}
onClick={() => { onClick={() => {
mutateDuel((draft) => { mutateDuel((draft) => {
draft.protagonist!.equipment[1][1] = hasEnhancedArmor ? 0 : -1; draft.protagonist!.equipmentMatrix[1][1] = hasEnhancedArmor
? 0
: -1;
}); });
}} }}
/> />
@@ -13,8 +13,6 @@ export function Title() {
return ( return (
<> <>
<title>{protagonist.tank.name}</title>
<Flex gap="6" justify="center" align="center" style={{ padding: 16 }}> <Flex gap="6" justify="center" align="center" style={{ padding: 16 }}>
<Flex gap="2" align="center"> <Flex gap="2" align="center">
<Heading <Heading
+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}`);
+2 -2
View File
@@ -7,7 +7,7 @@ import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import resolveTankId from '../core/blitz/resolveTankId'; import resolveTankId from '../core/blitz/resolveTankId';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import getTankHistories from '../core/blitzkrieg/getTankHistories'; import getTankHistories from '../core/blitzkrieg/getTankHistories';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getPlayerHistories from '../core/blitzstars/getPlayerHistories'; import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
@@ -41,7 +41,7 @@ async function render(
) { ) {
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const logo = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const logo = clan ? emblemURL(clan.emblem_set_id) : undefined;
const histories = await (type === 'player' const histories = await (type === 'player'
? getPlayerHistories(region, id, { ? getPlayerHistories(region, id, {
start, start,
+2 -2
View File
@@ -6,7 +6,7 @@ import TierWeights from '../components/TierWeights';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
@@ -40,7 +40,7 @@ async function render(
) { ) {
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod( const diffedTankStats = await getStatsInPeriod(
region, region,
id, id,
+4 -4
View File
@@ -2,11 +2,11 @@ import { teal } from '@radix-ui/colors';
import { GuildMemberRoleManager } from 'discord.js'; import { GuildMemberRoleManager } from 'discord.js';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' }; import discord from '../../discord.json' assert { type: 'json' };
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow'; import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
import CommandWrapper from '../components/CommandWrapper'; import CommandWrapper from '../components/CommandWrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitzLink';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
async handler(interaction) { async handler(interaction) {
const { t, translate } = translator(interaction.locale); const { t, translate } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction); const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = parseInt(interaction.user.id); const discordId = BigInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']); const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
await linkBlitzAndDiscord(discordId, region, id); await linkBlitzAndDiscord(discordId, id);
if (interaction.guildId === discord.sklld_guild_id) { if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
+494
View File
@@ -0,0 +1,494 @@
import markdownEscape from 'markdown-escape';
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
import CommandWrapper from '../components/CommandWrapper';
import { DeltaCaret } from '../components/DeltaCaret';
import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { getLeagueFromScore } from '../core/blitz/getLeagueFromScore';
import getRatingInfo from '../core/blitz/getRatingInfo';
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon';
import { emblemURL } from '../core/blitzkrieg/emblemURL';
import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
import { webpToPng } from '../core/blitzkrieg/iconPng';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { deltaBkrlBlitzStats } from '../core/statistics/deltaBkrlBlitzStats';
import { BkrlFormat } from '../core/streams/bkrl';
import { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config';
const LEAGUE_ACCENT = [
'_purple',
'_sand',
'_amber',
'_mauve',
'_brown',
] as const;
export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command:
createLocalizedCommand('rating').addStringOption(addUsernameChoices),
inProduction: true,
inPublic: true,
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const leaderboard = await getArchivedRatingMidnightLeaderboard(
region,
53,
);
if (leaderboard?.format === BkrlFormat.Base) {
throw new Error(
'Encountered bkrl minimal format in latest season. Wait till next cron?',
);
}
const ratingInfo = await getRatingInfo(region);
const accountInfo = await getAccountInfo(region, id, [
'statistics.rating',
]);
const statsIndex = leaderboard?.entries.findIndex(
(entry) => entry.id === id,
);
const statsA = leaderboard ? leaderboard.entries[statsIndex!] : null;
const positionA = leaderboard ? statsIndex! + 1 : undefined;
const statsB1 = accountInfo.statistics.rating!;
const [statsB2] = (await getRatingNeighbors(region, id, 0)).neighbors;
const league = getLeagueFromScore(statsB2.score);
if (ratingInfo.detail) {
throw new Error(`Rating info not available for ${region}`);
}
if (!statsA) {
return t`bot.commands.rating.errors.unrecorded_stats`;
}
if (!statsB1) {
return translate('bot.commands.rating.errors.no_participation', [
markdownEscape(accountInfo.nickname),
]);
}
const reward = ratingInfo.rewards.find(
(reward) =>
statsB2.number >= reward.from_position &&
statsB2.number <= reward.to_position,
);
const positionDelta = leaderboard
? statsB2.number - positionA!
: undefined;
const delta = deltaBkrlBlitzStats(
statsA,
accountInfo.statistics.rating!,
statsB2,
);
const leagueTheme = LEAGUE_ACCENT[league.index];
return (
<CommandWrapper>
<TitleBar
title={accountInfo.nickname}
image={clanImage}
description={`${t`bot.commands.rating.body.subtitle`} • ${new Date().toLocaleDateString(
interaction.locale,
)}`}
/>
<div
style={{
position: 'relative',
display: 'flex',
overflow: 'hidden',
}}
>
<div
style={{
position: 'relative',
padding: 16,
backgroundColor: theme.colors[`appBackground2${leagueTheme}`],
borderRadius: 4,
border: `1px solid ${theme.colors[`componentInteractive${leagueTheme}`]}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}
>
{reward === undefined && (
<Glow
endOpacity={3 / 4}
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
transform: 'translateX(-16px) rotate(90deg)',
}}
/>
)}
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
}}
>
<img
style={{
width: 64,
height: 64,
objectFit: 'contain',
}}
src={normalizeLeagueIcon(
ratingInfo.leagues[league.index].big_icon,
)}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<span
style={{
color: theme.colors[`textHighContrast${leagueTheme}`],
fontSize: 24,
fontWeight: 900,
}}
>
{statsB2.score.toLocaleString(interaction.locale)}
</span>
{delta.score !== 0 && (
<>
<DeltaCaret delta={delta.score} />
<span
style={{
color:
delta.score > 0
? theme.colors.textLowContrast_green
: theme.colors.textLowContrast_red,
fontSize: 16,
}}
>
{Math.abs(delta.score).toLocaleString(
interaction.locale,
)}
</span>
</>
)}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<span
style={{
color: theme.colors[`textLowContrast${leagueTheme}`],
fontSize: 16,
}}
>
#{statsB2.number.toLocaleString(interaction.locale)}
</span>
{positionDelta !== undefined && positionDelta !== 0 && (
<>
<DeltaCaret delta={positionDelta} />
<span
style={{
color:
positionDelta > 0
? theme.colors.textLowContrast_green
: theme.colors.textLowContrast_red,
fontSize: 16,
}}
>
{Math.abs(positionDelta).toLocaleString(
interaction.locale,
)}
</span>
</>
)}
</div>
</div>
</div>
{reward && (
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<span
style={{
color: theme.colors[`textHighContrast${leagueTheme}`],
fontSize: 16,
maxWidth: 136,
textAlign: 'right',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{reward.type === 'vehicle'
? reward.vehicle.user_string
: reward.stuff.title}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<span
style={{
color: theme.colors[`textLowContrast${leagueTheme}`],
fontSize: 16,
}}
>
Reward
{reward.type === 'stuff' ? ` x${reward.count}` : ''}
</span>
</div>
</div>
<img
style={{
width: 64,
height: 64,
objectFit: 'cover',
}}
src={await webpToPng(
reward.type === 'vehicle'
? reward.vehicle.preview_image_url
: reward.stuff.image_url,
)}
/>
</div>
)}
{reward === undefined && (
<Glow
endOpacity={3 / 4}
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
transform: 'translateX(16px) rotate(-90deg)',
}}
/>
)}
</div>
{/* separated because fragment middle man invalidates relative parent: https://github.com/vercel/satori/issues/605 */}
{reward && (
<Glow
direction="reverse"
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
position: 'absolute',
top: -40,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
{reward && (
<Glow
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
position: 'absolute',
bottom: -40,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
</div>
{delta.battles > 0 && (
<div style={{ padding: '8px 0', display: 'flex' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{delta.battles.toLocaleString(interaction.locale)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Battles
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.wins / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Winrate
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{Math.round(delta.damage / delta.battles).toLocaleString(
interaction.locale,
)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Damage
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.survived / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Survival
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(delta.kills / delta.battles).toFixed(2)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Kills
</span>
</div>
</div>
)}
</CommandWrapper>
);
},
});
});
+513
View File
@@ -0,0 +1,513 @@
import {
APIApplicationCommandOptionChoice,
Locale,
SlashCommandSubcommandBuilder,
} from 'discord.js';
import { range } from 'lodash';
import markdownEscape from 'markdown-escape';
import CommandWrapper from '../components/CommandWrapper';
import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import { LEAGUES } from '../constants/leagues';
import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../constants/rating';
import { Region } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingInfo from '../core/blitz/getRatingInfo';
import { getRatingLeague } from '../core/blitz/getRatingLeague';
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
import { isOnGoingRatingSeason } from '../core/blitz/isOnGoingRatingSeason';
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingInfo from '../core/blitzkrieg/getArchivedRatingInfo';
import { getArchivedRatingLeaderboard } from '../core/blitzkrieg/getArchivedRatingLeaderboard';
import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { localizationObject } from '../core/discord/localizationObject';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { BkrlDiscriminatedEntries } from '../core/streams/bkrl';
import { CommandRegistry } from '../events/interactionCreate';
import { UserError } from '../hooks/userError';
export interface RatingPlayer {
spa_id: number;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
percentile: number;
skip: boolean;
updated_at: string;
score: number;
nickname: string;
clan_tag: string;
}
export interface LeagueTop {
result: RatingPlayer[];
}
export type RatingReward = {
from_position: number;
to_position: number;
count: number;
} & (
| {
type: 'vehicle';
vehicle: {
id: number;
name: string;
nation: string;
subnation: string;
use_subnation_flag: boolean;
type_slug: string;
level: number;
roman_level: string;
user_string: string;
image_url: string;
preview_image_url: string;
is_premium: boolean;
is_collectible: boolean;
};
}
| {
type: 'stuff';
stuff: {
name: string;
count: number;
title: string;
image_url: string;
type: string;
sizes: {};
};
}
);
export interface RatingLeague {
title: string;
small_icon: string;
big_icon: string;
background: string;
index: number;
percentile: number;
}
export type RatingInfo =
| {
detail: undefined;
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingReward[];
leagues: RatingLeague[];
}
| { detail: { error: string } };
export interface RatingNeighbors {
neighbors: RatingPlayer[];
}
interface SimplifiedPlayer {
id: number;
score: number;
position: number;
clan: string | undefined;
nickname: string;
}
const DEFAULT_LIMIT = 10;
export const ratingLeaderboardCommand = new Promise<CommandRegistry>(
async (resolve) => {
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const onGoingSeason = await isOnGoingRatingSeason();
const seasonNumbers = range(
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addOptions(option: SlashCommandSubcommandBuilder) {
const { t, translate } = translator(Locale.EnglishUS);
return option
.addIntegerOption((option) =>
option
.setName(t`bot.commands.rating_leaderboard.options.limit`)
.setNameLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.limit',
),
)
.setDescription(
translate(
'bot.commands.rating_leaderboard.options.limit.description',
[`${DEFAULT_LIMIT}`],
),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.limit.description',
[`${DEFAULT_LIMIT}`],
),
)
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
)
.addStringOption((option) =>
option
.setName(t`bot.commands.rating_leaderboard.options.season`)
.setNameLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.season',
),
)
.setDescription(
translate(
'bot.commands.rating_leaderboard.options.season.description',
),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.season.description',
),
)
.setRequired(false)
.addChoices(
...seasonNumbers
.map((number, index) => {
const current =
index === seasonNumbers.length - 1 && onGoingSeason;
return {
name: current
? translate(
'bot.commands.rating_leaderboard.options.season.choices.current',
[`${number}`],
)
: `${number}`,
value: `${number}`,
name_localizations: current
? localizationObject(
'bot.commands.rating_leaderboard.options.season.choices.current',
[`${number}`],
)
: undefined,
} satisfies APIApplicationCommandOptionChoice<string>;
})
.reverse(),
),
);
}
resolve({
inProduction: true,
inPublic: true,
command: createLocalizedCommand('rating-leaderboard', [
{
subcommand: 'neighbors',
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
addOptions(option);
},
},
{
subcommand: 'league',
modify(option: SlashCommandSubcommandBuilder) {
option
.addStringOption((option) => {
const { t, translate } = translator(Locale.EnglishUS);
return option
.setName(t`bot.commands.rating_leaderboard.options.league`)
.setNameLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.league',
),
)
.setDescription(
translate(
'bot.commands.rating_leaderboard.options.league.description',
),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.league.description',
),
)
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name: translate(`common.leagues.${name}`),
value: `${value}`,
name_localizations: localizationObject(
`common.leagues.${name}`,
),
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true);
})
.addStringOption(addRegionChoices);
addOptions(option);
},
},
]),
// BIG TODO: title seems to be in english
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const subcommand = interaction.options.getSubcommand(true) as
| 'league'
| 'neighbors';
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
const seasonOption = interaction.options.getString('season');
const season = seasonOption
? parseInt(seasonOption)
: onGoingSeason
? latestArchivedSeasonNumber + 1
: latestArchivedSeasonNumber;
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
let titleName: string;
let titleImage: string | undefined;
let titleDescription: string;
let playersAfter: number;
let players: SimplifiedPlayer[] | undefined;
let midnightLeaderboard: BkrlDiscriminatedEntries | null;
let playerId: number | undefined;
let regionRatingInfo: RatingInfo;
if (subcommand === 'league') {
const leagueIndex = parseInt(
interaction.options.getString('league')!,
);
const region = interaction.options.getString('region') as Region;
regionRatingInfo = await (isCurrentSeason
? getRatingInfo(region)
: getArchivedRatingInfo(region, season!));
if (regionRatingInfo.detail !== undefined)
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
const result = isCurrentSeason
? await getRatingLeague(region, leagueIndex).then(async (result) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingLeaderboard(region, season!).then(
async (players) => {
const firstPlayerIndex =
leagueIndex === 0
? 0
: players.entries.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore,
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.entries.slice(
firstPlayerIndex,
lastPlayerIndex + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map(({ id }) => id),
['clan'],
);
return trimmed.map(
(player, index) =>
({
id: player.id,
score: player.score,
position: firstPlayerIndex + index + 1,
clan: clanData[player.id]?.clan?.tag,
nickname:
clanData[index]?.account_name ??
translate(
'bot.commands.rating_leaderboard.body.deleted_player',
[`${player.id}`],
),
}) satisfies SimplifiedPlayer,
);
},
);
const leagueInfo = regionRatingInfo.leagues[leagueIndex];
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingMidnightLeaderboard(
region,
regionRatingInfo.current_season,
)
: await getArchivedRatingMidnightLeaderboard(region, season!);
players = result;
playersAfter =
regionRatingInfo.count - result[result.length - 1].position;
titleName = `${
leagueInfo
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
: ''
} - ${translate(`common.regions.short.${region}`)}`;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
titleDescription = translate(
'bot.commands.rating_leaderboard.body.top_players',
[`${limit}`],
);
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingInfo = isCurrentSeason
? await getRatingInfo(region)
: await getArchivedRatingInfo(region, season!);
if (regionRatingInfo.detail !== undefined)
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const neighbors = isCurrentSeason
? await getRatingNeighbors(region, id, Math.round(limit / 2)).then(
(players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingLeaderboard(region, season!).then(
async (players) => {
const playerIndex = players.entries.findIndex(
(player) => player.id === id,
);
if (playerIndex === -1) {
throw new UserError(
translate(
'bot.commands.rating_leaderboard.body.no_participation',
[markdownEscape(accountInfo.nickname), `${season}`],
),
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.entries.slice(
playerIndex - halfRange,
playerIndex + halfRange + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map((player) => player.id),
['clan'],
);
return trimmed.map((player, index) => ({
id: player.id,
score: player.score,
position: playerIndex + index + 1,
clan: clanData[index]?.clan?.tag,
nickname: clanData[index]
? clanData[index]!.account_name
: `Deleted ${player.id}`,
}));
},
);
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingMidnightLeaderboard(
region,
regionRatingInfo.current_season,
)
: await getArchivedRatingMidnightLeaderboard(region, season!);
players = neighbors;
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
titleName = neighbors ? accountInfo.nickname : '';
titleDescription = t`bot.commands.rating_leaderboard.body.neighbors`;
playersAfter =
regionRatingInfo.count - neighbors[neighbors.length - 1].position;
playerId = id;
}
const items = players.map((player) => {
const midnightIndex = midnightLeaderboard?.entries.findIndex(
(item) => item.id === player.id,
);
const midnightScore =
midnightIndex === undefined
? player.score
: midnightLeaderboard!.entries[midnightIndex].score;
const midnightPosition =
midnightIndex === undefined ? player.position : midnightIndex + 1;
return (
<Leaderboard.Item
nickname={player.nickname}
score={player.score}
position={player.position}
clan={player.clan}
deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.position}
key={player.id}
highlight={player.id === playerId}
/>
);
});
return (
<CommandWrapper>
<TitleBar
title={titleName}
image={titleImage}
description={titleDescription}
/>
{items && (
<Leaderboard.Root>
{items}
{playersAfter > 0 && (
<Leaderboard.Gap
message={translate(
'bot.commands.rating_leaderboard.body.more',
[`${playersAfter.toLocaleString(interaction.locale)}`],
)}
/>
)}
</Leaderboard.Root>
)}
</CommandWrapper>
);
},
autocomplete: autocompleteUsername,
});
},
);
-505
View File
@@ -1,505 +0,0 @@
import {
APIApplicationCommandOptionChoice,
Locale,
SlashCommandSubcommandBuilder,
} from 'discord.js';
import { range } from 'lodash';
import markdownEscape from 'markdown-escape';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
import CommandWrapper from '../components/CommandWrapper';
import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import { LEAGUES } from '../constants/leagues';
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../constants/ratings';
import { Region } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo';
import { getRatingsLeague } from '../core/blitz/getRatingsLeague';
import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors';
import { isOnGoingRatingsSeason } from '../core/blitz/isOnGoingRatingsSeason';
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard';
import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { localizationObject } from '../core/discord/localizationObject';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { CommandRegistry } from '../events/interactionCreate';
import { UserError } from '../hooks/userError';
export interface RatingsPlayer {
spa_id: number;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
percentile: number;
skip: boolean;
updated_at: string;
score: number;
nickname: string;
clan_tag: string;
}
export interface LeagueTop {
result: RatingsPlayer[];
}
export type RatingsReward = {
from_position: number;
to_position: number;
count: number;
} & (
| {
type: 'vehicle';
vehicle: {
id: number;
name: string;
nation: string;
subnation: string;
use_subnation_flag: boolean;
type_slug: string;
level: number;
roman_level: string;
user_string: string;
image_url: string;
preview_url: string;
is_premium: boolean;
is_collectible: boolean;
};
}
| {
type: 'stuff';
stuff: {
name: string;
count: number;
title: string;
image_url: string;
type: string;
sizes: {};
};
}
);
export interface RatingsLeague {
title: string;
small_icon: string;
big_icon: string;
background: string;
index: number;
percentile: number;
}
export type RatingsInfo =
| {
detail: undefined;
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingsReward[];
leagues: RatingsLeague[];
}
| { detail: { error: string } };
export interface RatingsNeighbors {
neighbors: RatingsPlayer[];
}
interface SimplifiedPlayer {
id: number;
score: number;
position: number;
clan: string | undefined;
nickname: string;
}
const DEFAULT_LIMIT = 10;
export const ratingsCommand = new Promise<CommandRegistry>(async (resolve) => {
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const onGoingSeason = await isOnGoingRatingsSeason();
const seasonNumbers = range(
FIRST_ARCHIVED_RATINGS_SEASON,
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addOptions(option: SlashCommandSubcommandBuilder) {
const { t, translate } = translator(Locale.EnglishUS);
return option
.addIntegerOption((option) =>
option
.setName(t`bot.commands.ratings.options.limit`)
.setNameLocalizations(
localizationObject('bot.commands.ratings.options.limit'),
)
.setDescription(
translate('bot.commands.ratings.options.limit.description', [
`${DEFAULT_LIMIT}`,
]),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.ratings.options.limit.description',
[`${DEFAULT_LIMIT}`],
),
)
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
)
.addStringOption((option) =>
option
.setName(t`bot.commands.ratings.options.season`)
.setNameLocalizations(
localizationObject('bot.commands.ratings.options.season'),
)
.setDescription(
translate('bot.commands.ratings.options.season.description'),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.ratings.options.season.description',
),
)
.setRequired(false)
.addChoices(
...seasonNumbers
.map((number, index) => {
const current =
index === seasonNumbers.length - 1 && onGoingSeason;
return {
name: current
? translate(
'bot.commands.ratings.options.season.choices.current',
[`${number}`],
)
: `${number}`,
value: `${number}`,
name_localizations: current
? localizationObject(
'bot.commands.ratings.options.season.choices.current',
[`${number}`],
)
: undefined,
} satisfies APIApplicationCommandOptionChoice<string>;
})
.reverse(),
),
);
}
resolve({
inProduction: true,
inPublic: true,
command: createLocalizedCommand('ratings', [
{
subcommand: 'neighbors',
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
addOptions(option);
},
},
{
subcommand: 'league',
modify(option: SlashCommandSubcommandBuilder) {
option
.addStringOption((option) => {
const { t, translate } = translator(Locale.EnglishUS);
return option
.setName(t`bot.commands.ratings.options.league`)
.setNameLocalizations(
localizationObject('bot.commands.ratings.options.league'),
)
.setDescription(
translate('bot.commands.ratings.options.league.description'),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.ratings.options.league.description',
),
)
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name: translate(`common.leagues.${name}`),
value: `${value}`,
name_localizations: localizationObject(
`common.leagues.${name}`,
),
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true);
})
.addStringOption(addRegionChoices);
addOptions(option);
},
},
]),
// BIG TODO: title seems to be in english
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const subcommand = interaction.options.getSubcommand(true) as
| 'league'
| 'neighbors';
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
const seasonOption = interaction.options.getString('season');
const season = seasonOption
? parseInt(seasonOption)
: onGoingSeason
? latestArchivedSeasonNumber + 1
: latestArchivedSeasonNumber;
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
let titleName: string;
let titleImage: string | undefined;
let titleDescription: string;
let playersAfter: number;
let players: SimplifiedPlayer[] | undefined;
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
let playerId: number | undefined;
let regionRatingsInfo: RatingsInfo;
if (subcommand === 'league') {
const leagueIndex = parseInt(interaction.options.getString('league')!);
const region = interaction.options.getString('region') as Region;
regionRatingsInfo = await (isCurrentSeason
? getRatingsInfo(region)
: getArchivedRatingsInfo(region, season!));
if (regionRatingsInfo.detail !== undefined)
return t`bot.commands.ratings.body.no_ongoing_season`;
const result = isCurrentSeason
? await getRatingsLeague(region, leagueIndex).then(async (result) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingsLeaderboard(region, season!).then(
async (players) => {
const firstPlayerIndex =
leagueIndex === 0
? 0
: players.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore,
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.slice(
firstPlayerIndex,
lastPlayerIndex + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map(({ id }) => id),
['clan'],
);
return trimmed.map(
(player, index) =>
({
id: player.id,
score: player.score,
position: firstPlayerIndex + index + 1,
clan: clanData[player.id]?.clan?.tag,
nickname:
clanData[index]?.account_name ??
translate('bot.commands.ratings.body.deleted_player', [
`${player.id}`,
]),
}) satisfies SimplifiedPlayer,
);
},
);
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingsMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getArchivedRatingsMidnightLeaderboard(region, season!);
players = result;
playersAfter =
regionRatingsInfo.count - result[result.length - 1].position;
titleName = `${
leagueInfo
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
: ''
} - ${translate(`common.regions.short.${region}`)}`;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
titleDescription = translate('bot.commands.ratings.body.top_players', [
`${limit}`,
]);
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo = isCurrentSeason
? await getRatingsInfo(region)
: await getArchivedRatingsInfo(region, season!);
if (regionRatingsInfo.detail !== undefined)
return t`bot.commands.ratings.body.no_ongoing_season`;
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const neighbors = isCurrentSeason
? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
(players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingsLeaderboard(region, season!).then(
async (players) => {
const playerIndex = players.findIndex(
(player) => player.id === id,
);
if (playerIndex === -1) {
throw new UserError(
translate('bot.commands.ratings.body.no_participation', [
markdownEscape(accountInfo.nickname),
`${season}`,
]),
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.slice(
playerIndex - halfRange,
playerIndex + halfRange + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map((player) => player.id),
['clan'],
);
return trimmed.map((player, index) => ({
id: player.id,
score: player.score,
position: playerIndex + index + 1,
clan: clanData[index]?.clan?.tag,
nickname: clanData[index]
? clanData[index]!.account_name
: `Deleted ${player.id}`,
}));
},
);
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingsMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getArchivedRatingsMidnightLeaderboard(region, season!);
players = neighbors;
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
titleName = neighbors ? accountInfo.nickname : '';
titleDescription = t`bot.commands.ratings.body.neighbors`;
playersAfter =
regionRatingsInfo.count - neighbors[neighbors.length - 1].position;
playerId = id;
}
const resolvedRegionRatingsInfo: typeof regionRatingsInfo & {
detail: undefined;
} = regionRatingsInfo;
const items = players.map((player) => {
const reward = resolvedRegionRatingsInfo.rewards.find(
(reward) =>
player.position >= reward.from_position &&
player.position <= reward.to_position,
);
const midnightIndex = midnightLeaderboard?.findIndex(
(item) => item.id === player.id,
);
const midnightScore =
midnightIndex === undefined
? player.score
: midnightLeaderboard![midnightIndex].score;
const midnightPosition =
midnightIndex === undefined ? player.position : midnightIndex + 1;
return (
<Leaderboard.Item
nickname={player.nickname}
score={player.score}
position={player.position}
clan={player.clan}
reward={reward}
deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.position}
key={player.id}
highlight={player.id === playerId}
/>
);
});
return (
<CommandWrapper>
<TitleBar
title={titleName}
image={titleImage}
description={titleDescription}
/>
{items && (
<Leaderboard.Root>
{items}
{playersAfter > 0 && (
<Leaderboard.Gap
message={translate('bot.commands.ratings.body.more', [
`${playersAfter.toLocaleString(interaction.locale)}`,
])}
/>
)}
</Leaderboard.Root>
)}
</CommandWrapper>
);
},
autocomplete: autocompleteUsername,
});
});
+20 -27
View File
@@ -8,8 +8,8 @@ import getTankStats from '../core/blitz/getTankStats';
import resolveTankId from '../core/blitz/resolveTankId'; import resolveTankId from '../core/blitz/resolveTankId';
import { asset } from '../core/blitzkrieg/asset'; import { asset } from '../core/blitzkrieg/asset';
import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine'; import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { iconPng } from '../core/blitzkrieg/iconPng'; import { webpToPng } from '../core/blitzkrieg/iconPng';
import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry'; import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry';
import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions'; import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
import { import {
@@ -155,7 +155,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
const line = [...techTreeLine, startingTankId].reverse(); const line = [...techTreeLine, startingTankId].reverse();
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const costs = await Promise.all( const costs = await Promise.all(
line.map(async (id) => { line.map(async (id) => {
const tank = awaitedTankDefinitions[id]; const tank = awaitedTankDefinitions[id];
@@ -276,7 +276,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/xp.webp'))} src={await webpToPng(asset('icons/currencies/xp.webp'))}
width={16} width={16}
height={16} height={16}
/> />
@@ -301,7 +301,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/silver.webp'))} src={await webpToPng(asset('icons/currencies/silver.webp'))}
width={16} width={16}
height={16} height={16}
/> />
@@ -326,7 +326,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/silver.webp'))} src={await webpToPng(asset('icons/currencies/silver.webp'))}
width={16} width={16}
height={16} height={16}
/> />
@@ -385,26 +385,17 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
right: '5%', right: '5%',
}} }}
> >
<div <img
src={await tankIconPng(id)}
style={{ style={{
height: 120, height: 100,
width: 240, width: 100,
display: 'flex', objectFit: 'contain',
alignItems: 'center', position: 'absolute',
justifyContent: 'flex-start',
left: '25%', left: '25%',
top: '5%', top: 0,
}} }}
> />
<img
src={await tankIconPng(id)}
style={{
height: '100%',
position: 'absolute',
objectFit: 'contain',
}}
/>
</div>
<div <div
style={{ style={{
@@ -452,7 +443,9 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/xp.webp'))} src={await webpToPng(
asset('icons/currencies/xp.webp'),
)}
width={16} width={16}
height={16} height={16}
/> />
@@ -479,7 +472,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng( src={await webpToPng(
asset('icons/currencies/free-xp.webp'), asset('icons/currencies/free-xp.webp'),
)} )}
width={16} width={16}
@@ -506,7 +499,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng( src={await webpToPng(
asset('icons/currencies/silver.webp'), asset('icons/currencies/silver.webp'),
)} )}
width={16} width={16}
@@ -534,7 +527,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng( src={await webpToPng(
asset('icons/currencies/silver.webp'), asset('icons/currencies/silver.webp'),
)} )}
width={16} width={16}
+2 -2
View File
@@ -5,7 +5,7 @@ import NoData from '../components/NoData';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
@@ -38,7 +38,7 @@ async function render(
) { ) {
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod( const diffedTankStats = await getStatsInPeriod(
region, region,
id, id,
@@ -0,0 +1,50 @@
import { ComponentProps } from 'react';
export interface GlowProps extends ComponentProps<'div'> {
color: string;
direction?: 'default' | 'reverse';
rotation?: number;
startOpacity?: number;
endOpacity?: number;
}
const width = 16;
export function Glow({
color,
direction = 'default',
rotation = 0,
style,
startOpacity = 0,
endOpacity = 1,
...props
}: GlowProps) {
const color1 = `${color}${Math.round(endOpacity * 255)
.toString(16)
.padStart(2, '0')}`;
const color2 = `${color}${Math.round(startOpacity * 255)
.toString(16)
.padStart(2, '0')}`;
const background = `linear-gradient(${
direction === 'default' ? 0 : 180
}deg, ${color1} 0%, ${color2} 100%)`;
return (
<div
style={{
display: 'flex',
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
...style,
}}
{...props}
>
<div style={{ height: 54, width, background }} />
<div style={{ height: 72, width, background }} />
<div style={{ height: 80, width, background }} />
<div style={{ height: 72, width, background }} />
<div style={{ height: 54, width, background }} />
</div>
);
}
@@ -1,5 +1,6 @@
import { Locale } from 'discord.js'; import { Locale } from 'discord.js';
import { Percentile } from '../../../../constants/percentiles'; import { Percentile } from '../../../../constants/percentiles';
import { SupplementaryStats } from '../../../../core/blitz/getAccountInfo';
import { translator } from '../../../../core/localization/translator'; import { translator } from '../../../../core/localization/translator';
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile'; import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
import { theme } from '../../../../stitches.config'; import { theme } from '../../../../stitches.config';
@@ -7,16 +8,19 @@ import { PERCENTILE_COLORS } from '../../../PercentileIndicator/constants';
import { Glow } from './components/Glow'; import { Glow } from './components/Glow';
export interface WN8DisplayProps { export interface WN8DisplayProps {
wn8?: number; stats: SupplementaryStats;
locale: Locale; locale: Locale;
} }
export function WN8Display({ wn8, locale }: WN8DisplayProps) { export function HeroStat({ stats, locale }: WN8DisplayProps) {
const percentile =
wn8 === undefined ? Percentile.VeryBad : getWN8Percentile(wn8);
const color = PERCENTILE_COLORS[percentile];
const { translate } = translator(locale); const { translate } = translator(locale);
const percentile =
stats.WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(stats.WN8);
const color = PERCENTILE_COLORS[percentile];
const heroStat = stats.WN8 === undefined ? '--' : stats.WN8.toFixed(0);
const subtitle = translate(`common.wn8_percentile.${percentile}`);
return ( return (
<div <div
style={{ style={{
@@ -43,23 +47,33 @@ export function WN8Display({ wn8, locale }: WN8DisplayProps) {
gap: -4, gap: -4,
}} }}
> >
<span <div
style={{ style={{
fontWeight: 900, display: 'flex',
fontSize: 32, flexDirection: 'column',
color: theme.colors.textHighContrast, alignItems: 'center',
justifyContent: 'center',
gap: -4,
}} }}
> >
{wn8 === undefined ? '--' : wn8.toFixed(0)} <span
</span> style={{
<span fontWeight: 900,
style={{ fontSize: 32,
fontSize: 16, color: theme.colors.textHighContrast,
color: theme.colors.textHighContrast, }}
}} >
> {heroStat}
{translate(`common.wn8_percentile.${percentile}`)} </span>
</span> <span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{subtitle}
</span>
</div>
</div> </div>
<Glow color={color} direction="reverse" /> <Glow color={color} direction="reverse" />
@@ -1,35 +0,0 @@
export interface GlowProps {
color: string;
direction?: 'default' | 'reverse';
rotation?: number;
}
export function Glow({
color,
direction = 'default',
rotation = 0,
}: GlowProps) {
const color1 = color;
const color2 = `${color}00`;
const background = `linear-gradient(${
direction === 'default' ? 0 : 180
}deg, ${color1} 0%, ${color2} 100%)`;
return (
<div
style={{
display: 'flex',
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
}}
>
<div style={{ height: 54, width: 16, background }} />
<div style={{ height: 72, width: 16, background }} />
<div style={{ height: 80, width: 16, background }} />
<div style={{ height: 72, width: 16, background }} />
<div style={{ height: 54, width: 16, background }} />
</div>
);
}
+23 -16
View File
@@ -1,14 +1,23 @@
import { Locale } from 'discord.js'; import { Locale } from 'discord.js';
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo'; import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
import { translator } from '../../core/localization/translator'; import { translator } from '../../core/localization/translator';
import { WN8Display } from './components/WN8Display'; import { HeroStat } from './components/HeroStat';
import { import {
WN8SurroundingStat, WN8SurroundingStat,
WN8SurroundingStatAlign, WN8SurroundingStatAlign,
} from './components/WN8SurroundingStat'; } from './components/WN8SurroundingStat';
export interface AllStatsOverviewProps { export interface AllStatsOverviewProps {
stats: AllStats; stats: {
battles: number;
wins: number;
damage_dealt: number;
damage_received: number;
survived_battles: number;
hits: number;
shots: number;
frags: number;
};
supplementaryStats: SupplementaryStats; supplementaryStats: SupplementaryStats;
locale: Locale; locale: Locale;
} }
@@ -24,6 +33,7 @@ export default function AllStatsOverview({
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center',
gap: -64, gap: -64,
width: '100%', width: '100%',
}} }}
@@ -38,18 +48,18 @@ export default function AllStatsOverview({
}} }}
> >
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.battles`} label={t`bot.common.hero_stats.battles`}
value={stats.battles.toLocaleString()} value={stats.battles.toLocaleString()}
align={WN8SurroundingStatAlign.Right} align={WN8SurroundingStatAlign.Right}
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.winrate`} label={t`bot.common.hero_stats.winrate`}
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`} value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
align={WN8SurroundingStatAlign.Right} align={WN8SurroundingStatAlign.Right}
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.damage`} label={t`bot.common.hero_stats.damage`}
value={Math.round( value={Math.round(
stats.damage_dealt / stats.battles, stats.damage_dealt / stats.battles,
).toLocaleString()} ).toLocaleString()}
@@ -57,13 +67,13 @@ export default function AllStatsOverview({
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.damage_ratio`} label={t`bot.common.hero_stats.damage_ratio`}
value={(stats.damage_dealt / stats.damage_received).toFixed(2)} value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
align={WN8SurroundingStatAlign.Right} align={WN8SurroundingStatAlign.Right}
/> />
</div> </div>
<WN8Display locale={locale} wn8={supplementaryStats.WN8} /> <HeroStat locale={locale} stats={supplementaryStats} />
<div <div
style={{ style={{
@@ -74,29 +84,26 @@ export default function AllStatsOverview({
}} }}
> >
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.tier`} label={t`bot.common.hero_stats.tier`}
value={ value={
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--' supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
} }
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.survival`} label={t`bot.common.hero_stats.survival`}
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed( value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2, 2,
)}%`} )}%`}
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.accuracy`} label={t`bot.common.hero_stats.accuracy`}
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`} value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.kill_ratio`} label={t`bot.common.hero_stats.kills`}
value={( value={(stats.frags / stats.battles).toFixed(2)}
stats.frags /
(stats.battles - stats.survived_battles)
).toFixed(2)}
/> />
</div> </div>
</div> </div>
@@ -20,6 +20,7 @@ export const CoreArmorScene = memo(() => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -50,6 +50,7 @@ export function CoreArmorSceneComponent({
opaque: { value: null }, opaque: { value: null },
useSpacedArmor: { value: null }, useSpacedArmor: { value: null },
inverseProjectionMatrix: { value: null },
resolution: { value: new Vector2() }, resolution: { value: new Vector2() },
spacedArmorBuffer: { value: null }, spacedArmorBuffer: { value: null },
spacedArmorDepth: { value: null }, spacedArmorDepth: { value: null },
@@ -57,10 +58,7 @@ export function CoreArmorSceneComponent({
}); });
useEffect(() => { useEffect(() => {
function handleShellChange(shell: ShellDefinition) { async function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90); material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
@@ -70,6 +68,10 @@ export function CoreArmorSceneComponent({
material.uniforms.canSplash.value = canSplash(shell.type); material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.damage.armor; material.uniforms.damage.value = shell.damage.armor;
material.uniforms.explosionRadius.value = shell.explosionRadius; material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = useDuel.getState();
await handleProtagonistEquipmentChange(duel.protagonist!.equipmentMatrix);
await handleAntagonistEquipmentChange(duel.antagonist!.equipmentMatrix);
} }
function handleVisualChange( function handleVisualChange(
visual: TankopediaPersistent['model']['visual'], visual: TankopediaPersistent['model']['visual'],
@@ -82,24 +84,40 @@ export function CoreArmorSceneComponent({
async function handleProtagonistEquipmentChange( async function handleProtagonistEquipmentChange(
equipment: EquipmentMatrix, equipment: EquipmentMatrix,
) { ) {
const hasEnhancedArmor = await hasEquipment(110, false, equipment); const duel = useDuel.getState();
const hasEnhancedArmor = await hasEquipment(
110,
duel.protagonist!.tank.equipment,
equipment,
);
material.uniforms.thickness.value = hasEnhancedArmor material.uniforms.thickness.value = hasEnhancedArmor
? thickness * 1.04 ? thickness * 1.04
: thickness; : thickness;
} }
async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) { async function handleAntagonistEquipmentChange(equipment: EquipmentMatrix) {
const shell = useDuel.getState().antagonist!.shell; const duel = useDuel.getState();
const penetration = resolveNearPenetration(shell.penetration); const penetration = resolveNearPenetration(
const hasCalibratedShells = await hasEquipment(103, true, equipment); duel.antagonist!.shell.penetration,
);
const hasCalibratedShells = await hasEquipment(
103,
duel.antagonist!.tank.equipment,
equipment,
);
material.uniforms.penetration.value = hasCalibratedShells material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05) ? penetration * (isExplosive(duel.antagonist!.shell.type) ? 1.1 : 1.05)
: penetration; : penetration;
} }
handleShellChange(useDuel.getState().antagonist!.shell); handleShellChange(useDuel.getState().antagonist!.shell);
handleVisualChange(useTankopediaPersistent.getState().model.visual); handleVisualChange(useTankopediaPersistent.getState().model.visual);
handleProtagonistEquipmentChange(useDuel.getState().protagonist!.equipment); handleProtagonistEquipmentChange(
handleAntagonistEquipmentChange(useDuel.getState().antagonist!.equipment); useDuel.getState().protagonist!.equipmentMatrix,
);
handleAntagonistEquipmentChange(
useDuel.getState().antagonist!.equipmentMatrix,
);
const unsubscribes = [ const unsubscribes = [
useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange), useDuel.subscribe((state) => state.antagonist!.shell, handleShellChange),
@@ -108,11 +126,11 @@ export function CoreArmorSceneComponent({
handleVisualChange, handleVisualChange,
), ),
useDuel.subscribe( useDuel.subscribe(
(state) => state.protagonist!.equipment, (state) => state.protagonist!.equipmentMatrix,
handleProtagonistEquipmentChange, handleProtagonistEquipmentChange,
), ),
useDuel.subscribe( useDuel.subscribe(
(state) => state.antagonist!.equipment, (state) => state.antagonist!.equipmentMatrix,
handleAntagonistEquipmentChange, handleAntagonistEquipmentChange,
), ),
]; ];
@@ -120,15 +138,17 @@ export function CoreArmorSceneComponent({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
useFrame(({ gl }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(), gl.getPixelRatio(),
); );
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture; material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value = material.uniforms.spacedArmorDepth.value =
spacedArmorRenderTarget.depthTexture; spacedArmorRenderTarget.depthTexture;
material.uniforms.inverseProjectionMatrix.value =
camera.projectionMatrixInverse;
}); });
return ( return (
@@ -1,6 +1,5 @@
varying vec3 vNormal; varying vec3 vNormal;
varying vec3 vViewPosition; varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
uniform float thickness; uniform float thickness;
uniform float penetration; uniform float penetration;
@@ -15,13 +14,14 @@ uniform float explosionRadius;
uniform bool greenPenetration; uniform bool greenPenetration;
uniform bool opaque; uniform bool opaque;
uniform bool useSpacedArmor; uniform bool useSpacedArmor;
uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer; uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth; uniform highp sampler2D spacedArmorDepth;
float depthToDistance(float depth) { float depthToDistance(float depth) {
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 eyePosition = inverse(vProjectionMatrix) * clipPosition; vec4 eyePosition = inverseProjectionMatrix * clipPosition;
return length(eyePosition.xyz / eyePosition.w); return length(eyePosition.xyz / eyePosition.w);
} }
@@ -42,36 +42,42 @@ void main() {
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0; bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization; float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization)); float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
float remainingPenetration = penetration;
// world space is in meters // world space in meters
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates); vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r); float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
float coreArmorDistance = depthToDistance(gl_FragCoord.z); float coreArmorDistance = depthToDistance(gl_FragCoord.z);
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance; float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
if (useSpacedArmor && isUnderSpacedArmor) { if (canSplash && useSpacedArmor && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration; float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness; float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * (finalThickness + spacedArmorThickness);
if (isExplosive && remainingPenetration > 0.0) { penetrationChance = 0.0;
// there is a 50% penetration loss per meter for HEAT shells splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
// only allow splashing if the damage equation deals more than 0 damage
float nominalArmorThickness = finalThickness - remainingPenetration;
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
splashChance = finalDamage > 0.0 ? 1.0 : 0.0; // TODO: add randomization here?
} else { } else {
splashChance = 0.0; float remainingPenetration = penetration;
if (useSpacedArmor && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness;
if (isExplosive && remainingPenetration > 0.0) {
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
float splashDamage = 0.5 * damage - 1.1 * finalThickness;
splashChance = splashDamage > 0.0 ? 1.0 : 0.0;
} else {
splashChance = 0.0;
}
} }
} }
@@ -1,12 +1,10 @@
varying vec3 vNormal; varying vec3 vNormal;
varying vec3 vViewPosition; varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
void main() { void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0); vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz; vViewPosition = vViewPosition4.xyz;
vProjectionMatrix = projectionMatrix;
vNormal = normalMatrix * normal; vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4; gl_Position = projectionMatrix * vViewPosition4;
@@ -4,6 +4,7 @@ import { useFrame } from '@react-three/fiber';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Mesh } from 'three'; import { Mesh } from 'three';
import { radToDeg } from 'three/src/math/MathUtils'; import { radToDeg } from 'three/src/math/MathUtils';
import { canSplash } from '../../../core/blitz/canSplash';
import { isExplosive } from '../../../core/blitz/isExplosive'; import { isExplosive } from '../../../core/blitz/isExplosive';
import { NaNFallback } from '../../../core/math/NaNFallback'; import { NaNFallback } from '../../../core/math/NaNFallback';
import { normalToEuler } from '../../../core/math/normalToEuler'; import { normalToEuler } from '../../../core/math/normalToEuler';
@@ -42,6 +43,7 @@ export function ShotDisplay() {
); );
const { shell } = useDuel.getState().antagonist!; const { shell } = useDuel.getState().antagonist!;
const explosive = isExplosive(shell.type); const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const midPointLayer = (shot?.layers.findLast( const midPointLayer = (shot?.layers.findLast(
(layer) => layer.type !== null && layer.status === 'ricochet', (layer) => layer.type !== null && layer.status === 'ricochet',
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase; ) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
@@ -64,6 +66,18 @@ export function ShotDisplay() {
return accumulator; return accumulator;
}, 0) + LENGTH_INFINITY }, 0) + LENGTH_INFINITY
: undefined; : undefined;
const preMidPointThickness = shot
? preMidPointLayers!.reduce(
(accumulator, layer) =>
accumulator +
(layer.type === null
? 0
: layer.type === ArmorType.External
? layer.thickness
: layer.thicknessAngled),
0,
)
: undefined;
const postMidPointGap = shot const postMidPointGap = shot
? postMidPointLayers!.reduce((accumulator, layer) => { ? postMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance; if (layer.type === null) return accumulator + layer.distance;
@@ -246,8 +260,9 @@ export function ShotDisplay() {
(1 - (1 -
distanceFromSpacedArmor! / shell.explosionRadius!) - distanceFromSpacedArmor! / shell.explosionRadius!) -
1.1 * 1.1 *
(midPointLayer as ShotLayerNonExternal) ((midPointLayer as ShotLayerNonExternal)
.thicknessAngled, .thicknessAngled +
preMidPointThickness!),
), ),
), ),
0, 0,
@@ -255,7 +270,7 @@ export function ShotDisplay() {
</Text> </Text>
{shot.layers.map((layer, index) => { {shot.layers.map((layer, index) => {
if (layer.type === null && !explosive) return null; if (layer.type === null && (!explosive || splashing)) return null;
return ( return (
<Flex key={index} align="center" gap="2"> <Flex key={index} align="center" gap="2">
@@ -31,6 +31,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -99,7 +100,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -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);
@@ -179,7 +188,7 @@ export function SpacedArmorSceneComponent({
const distance = ( const distance = (
shot.layers.at(-1) as ShotLayerBase shot.layers.at(-1) as ShotLayerBase
).point.distanceTo(intersections[0].point); ).point.distanceTo(intersections[0].point);
if (explosive) if (explosive && !splashing)
remainingPenetration -= 0.5 * remainingPenetration * distance; remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration < 0; const blocked = remainingPenetration < 0;
@@ -205,7 +214,7 @@ export function SpacedArmorSceneComponent({
const previousIntersection = intersections[layerIndex - 1]; const previousIntersection = intersections[layerIndex - 1];
const distance = const distance =
intersection.distance - previousIntersection.distance; intersection.distance - previousIntersection.distance;
if (explosive) if (explosive && !splashing)
remainingPenetration -= remainingPenetration -=
0.5 * remainingPenetration * distance; 0.5 * remainingPenetration * distance;
const wasted = remainingPenetration < 0; const wasted = remainingPenetration < 0;
@@ -216,7 +225,7 @@ export function SpacedArmorSceneComponent({
distance, distance,
}); });
if (wasted) return; if (wasted && !splashing) return;
} }
if (intersection.object.userData.type === ArmorType.External) { if (intersection.object.userData.type === ArmorType.External) {
@@ -233,7 +242,11 @@ export function SpacedArmorSceneComponent({
status: blocked ? 'blocked' : 'penetration', status: blocked ? 'blocked' : 'penetration',
}); });
if (blocked) break; if (splashing) {
remainingPenetration = Math.max(0, remainingPenetration);
} else if (blocked) {
break;
}
} else { } else {
const type = intersection.object.userData.type; const type = intersection.object.userData.type;
const angle = surfaceNormal.angleTo(shellNormal); const angle = surfaceNormal.angleTo(shellNormal);
+1
View File
@@ -16,6 +16,7 @@ export default function CommandWrapper({ children, fat }: CommandWrapperProps) {
gap: 16, gap: 16,
width: 480, width: 480,
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
position: 'relative',
background: theme.colors.appBackground1, background: theme.colors.appBackground1,
}} }}
> >
+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>
);
}
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more